Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3018a9c46b | ||
|
|
314118341e | ||
|
|
b4438c1ba5 | ||
|
|
725edc3fd8 | ||
|
|
1994ea5b08 | ||
|
|
f9e4e44b0c | ||
|
|
f2ee3d77a2 | ||
|
|
7fc70da14a | ||
|
|
8a0ae89bc0 |
70 changed files with 1322 additions and 227 deletions
|
|
@ -48,11 +48,15 @@ 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
|
||||||
|
- Allows automatically redownloading new media after a set period
|
||||||
|
- This can help improve the download quality of new content or improve SponsorBlock tags
|
||||||
- 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
|
||||||
- Can pass cookies to YouTube to download your private playlists ([docs](https://github.com/kieraneglin/pinchflat/wiki/YouTube-Cookies))
|
- Can pass cookies to YouTube to download your private playlists ([docs](https://github.com/kieraneglin/pinchflat/wiki/YouTube-Cookies))
|
||||||
- Sponsorblock integration
|
- Sponsorblock integration
|
||||||
|
- \[Advanced\] allows custom `yt-dlp` options ([docs](https://github.com/kieraneglin/pinchflat/wiki/%5BAdvanced%5D-Custom-yt%E2%80%90dlp-options))
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
@ -50,7 +52,8 @@ config :pinchflat, Oban,
|
||||||
{Oban.Plugins.Pruner, max_age: 30 * 24 * 60 * 60},
|
{Oban.Plugins.Pruner, max_age: 30 * 24 * 60 * 60},
|
||||||
{Oban.Plugins.Cron,
|
{Oban.Plugins.Cron,
|
||||||
crontab: [
|
crontab: [
|
||||||
{"@daily", Pinchflat.Downloading.MediaRetentionWorker}
|
{"0 1 * * *", Pinchflat.Downloading.MediaRetentionWorker},
|
||||||
|
{"0 2 * * *", Pinchflat.Downloading.MediaRedownloadWorker}
|
||||||
]}
|
]}
|
||||||
],
|
],
|
||||||
# TODO: consider making this an env var or something?
|
# TODO: consider making this an env var or something?
|
||||||
|
|
|
||||||
|
|
@ -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"]),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Settings
|
alias Pinchflat.Settings
|
||||||
alias Pinchflat.YtDlp.CommandRunner
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
|
||||||
|
|
||||||
def start_link(opts \\ []) do
|
def start_link(opts \\ []) do
|
||||||
GenServer.start_link(__MODULE__, %{}, opts)
|
GenServer.start_link(__MODULE__, %{}, opts)
|
||||||
|
|
@ -33,7 +32,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
||||||
@impl true
|
@impl true
|
||||||
def init(state) do
|
def init(state) do
|
||||||
reset_executing_jobs()
|
reset_executing_jobs()
|
||||||
create_blank_cookie_file()
|
create_blank_yt_dlp_files()
|
||||||
apply_default_settings()
|
apply_default_settings()
|
||||||
|
|
||||||
{:ok, state}
|
{:ok, state}
|
||||||
|
|
@ -51,20 +50,34 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
||||||
Logger.info("Reset #{count} executing jobs")
|
Logger.info("Reset #{count} executing jobs")
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_blank_cookie_file do
|
defp create_blank_yt_dlp_files do
|
||||||
|
files = ["cookies.txt", "yt-dlp-configs/base-config.txt"]
|
||||||
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||||
filepath = Path.join(base_dir, "cookies.txt")
|
|
||||||
|
|
||||||
if !File.exists?(filepath) do
|
Enum.each(files, fn file ->
|
||||||
Logger.info("Cookies does not exist - creating it")
|
filepath = Path.join(base_dir, file)
|
||||||
|
|
||||||
FilesystemHelpers.write_p!(filepath, "")
|
if !File.exists?(filepath) do
|
||||||
end
|
Logger.info("Creating blank file: #{filepath}")
|
||||||
|
|
||||||
|
FilesystemUtils.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.set(yt_dlp_version: yt_dlp_version)
|
Settings.set(yt_dlp_version: yt_dlp_version)
|
||||||
|
Settings.set(apprise_version: apprise_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
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||||
@doc """
|
@doc """
|
||||||
Builds the options for yt-dlp to download media based on the given media's profile.
|
Builds the options for yt-dlp to download media based on the given media's profile.
|
||||||
|
|
||||||
IDEA: consider adding the ability to pass in a second argument to override
|
Returns {:ok, [Keyword.t()]}
|
||||||
these options
|
|
||||||
"""
|
"""
|
||||||
def build(%MediaItem{} = media_item_with_preloads) do
|
def build(%MediaItem{} = media_item_with_preloads) do
|
||||||
media_profile = media_item_with_preloads.source.media_profile
|
media_profile = media_item_with_preloads.source.media_profile
|
||||||
|
|
@ -23,7 +22,8 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||||
metadata_options(media_profile) ++
|
metadata_options(media_profile) ++
|
||||||
quality_options(media_profile) ++
|
quality_options(media_profile) ++
|
||||||
sponsorblock_options(media_profile) ++
|
sponsorblock_options(media_profile) ++
|
||||||
output_options(media_item_with_preloads)
|
output_options(media_item_with_preloads) ++
|
||||||
|
config_file_options(media_item_with_preloads)
|
||||||
|
|
||||||
{:ok, built_options}
|
{:ok, built_options}
|
||||||
end
|
end
|
||||||
|
|
@ -128,6 +128,35 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# This is put here instead of the CommandRunner module because it should only
|
||||||
|
# be applied to downloading - if it were in CommandRunner it would apply to
|
||||||
|
# all yt-dlp commands (like indexing)
|
||||||
|
defp config_file_options(media_item) do
|
||||||
|
base_dir = Path.join(Application.get_env(:pinchflat, :extras_directory), "yt-dlp-configs")
|
||||||
|
# Ordered by priority - the first file has the highest priority
|
||||||
|
filenames = [
|
||||||
|
"media-item-#{media_item.id}-config.txt",
|
||||||
|
"source-#{media_item.source_id}-config.txt",
|
||||||
|
"media-profile-#{media_item.source.media_profile_id}-config.txt",
|
||||||
|
"base-config.txt"
|
||||||
|
]
|
||||||
|
|
||||||
|
config_filepaths =
|
||||||
|
Enum.reduce(filenames, [], fn filename, acc ->
|
||||||
|
filepath = Path.join(base_dir, filename)
|
||||||
|
|
||||||
|
case File.read(filepath) do
|
||||||
|
{:ok, file_data} ->
|
||||||
|
if String.trim(file_data) != "", do: [filepath | acc], else: acc
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
acc
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
Enum.map(config_filepaths, fn filepath -> {:config_locations, filepath} end)
|
||||||
|
end
|
||||||
|
|
||||||
defp output_options(media_item_with_preloads) do
|
defp output_options(media_item_with_preloads) do
|
||||||
[
|
[
|
||||||
output: build_output_path_for(media_item_with_preloads.source)
|
output: build_output_path_for(media_item_with_preloads.source)
|
||||||
|
|
@ -144,6 +173,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
|
||||||
|
|
|
||||||
|
|
@ -35,14 +35,17 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
||||||
"""
|
"""
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
def perform(%Oban.Job{args: %{"id" => media_item_id} = args}) do
|
def perform(%Oban.Job{args: %{"id" => media_item_id} = args}) do
|
||||||
|
should_force = Map.get(args, "force", false)
|
||||||
|
is_redownload = Map.get(args, "redownload?", false)
|
||||||
|
|
||||||
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 or media item is set to not download media, perform a no-op unless forced
|
# If the source or media item is set to not download media, perform a no-op unless forced
|
||||||
if (media_item.source.download_media && !media_item.prevent_download) || args["force"] do
|
if (media_item.source.download_media && !media_item.prevent_download) || should_force do
|
||||||
download_media_and_schedule_jobs(media_item)
|
download_media_and_schedule_jobs(media_item, is_redownload)
|
||||||
else
|
else
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
@ -51,10 +54,13 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
||||||
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: media item #{media_item_id} stale")
|
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: media item #{media_item_id} stale")
|
||||||
end
|
end
|
||||||
|
|
||||||
defp download_media_and_schedule_jobs(media_item) do
|
defp download_media_and_schedule_jobs(media_item, is_redownload) do
|
||||||
case MediaDownloader.download_for_media_item(media_item) do
|
case MediaDownloader.download_for_media_item(media_item) do
|
||||||
{:ok, updated_media_item} ->
|
{:ok, updated_media_item} ->
|
||||||
compute_and_save_media_filesize(updated_media_item)
|
Media.update_media_item(updated_media_item, %{
|
||||||
|
media_size_bytes: compute_media_filesize(updated_media_item),
|
||||||
|
media_redownloaded_at: get_redownloaded_at(is_redownload)
|
||||||
|
})
|
||||||
|
|
||||||
{:ok, updated_media_item}
|
{:ok, updated_media_item}
|
||||||
|
|
||||||
|
|
@ -66,13 +72,21 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp compute_and_save_media_filesize(media_item) do
|
defp compute_media_filesize(media_item) do
|
||||||
case File.stat(media_item.media_filepath) do
|
case File.stat(media_item.media_filepath) do
|
||||||
{:ok, %{size: size}} ->
|
{:ok, %{size: size}} ->
|
||||||
Media.update_media_item(media_item, %{media_size_bytes: size})
|
size
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
:ok
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_redownloaded_at(is_redownload) do
|
||||||
|
if is_redownload do
|
||||||
|
DateTime.utc_now()
|
||||||
|
else
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
||||||
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.Utils.FilesystemUtils
|
||||||
alias Pinchflat.Downloading.DownloadOptionBuilder
|
alias Pinchflat.Downloading.DownloadOptionBuilder
|
||||||
|
|
||||||
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
||||||
|
|
@ -30,7 +30,7 @@ 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
|
||||||
output_filepath = FilesystemHelpers.generate_metadata_tmpfile(:json)
|
output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||||
media_with_preloads = Repo.preload(media_item, [:metadata, source: :media_profile])
|
media_with_preloads = Repo.preload(media_item, [:metadata, source: :media_profile])
|
||||||
|
|
||||||
case download_with_options(media_item.original_url, media_with_preloads, output_filepath) do
|
case download_with_options(media_item.original_url, media_with_preloads, output_filepath) do
|
||||||
|
|
|
||||||
31
lib/pinchflat/downloading/media_redownload_worker.ex
Normal file
31
lib/pinchflat/downloading/media_redownload_worker.ex
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
defmodule Pinchflat.Downloading.MediaRedownloadWorker do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Oban.Worker,
|
||||||
|
queue: :media_fetching,
|
||||||
|
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
|
||||||
|
tags: ["media_item", "media_fetching"]
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias Pinchflat.Media
|
||||||
|
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Redownloads media items that are eligible for redownload.
|
||||||
|
|
||||||
|
This worker is scheduled to run daily via the Oban Cron plugin
|
||||||
|
and it should run _after_ the retention worker.
|
||||||
|
|
||||||
|
Returns :ok
|
||||||
|
"""
|
||||||
|
@impl Oban.Worker
|
||||||
|
def perform(%Oban.Job{}) do
|
||||||
|
redownloadable_media = Media.list_redownloadable_media_items()
|
||||||
|
Logger.info("Redownloading #{length(redownloadable_media)} media items")
|
||||||
|
|
||||||
|
Enum.each(redownloadable_media, fn media_item ->
|
||||||
|
MediaDownloadWorker.kickoff_with_task(media_item, %{redownload?: true})
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -25,7 +25,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
||||||
function starts individual indexing tasks for each new media item. I think it does
|
function starts individual indexing tasks for each new media item. I think it does
|
||||||
make sense grammatically, but I could see how that's confusing.
|
make sense grammatically, but I could see how that's confusing.
|
||||||
|
|
||||||
Returns :ok
|
Returns [binary()] where each binary is the media ID of a new media item.
|
||||||
"""
|
"""
|
||||||
def kickoff_indexing_tasks_from_youtube_rss_feed(%Source{} = source) do
|
def kickoff_indexing_tasks_from_youtube_rss_feed(%Source{} = source) do
|
||||||
{:ok, media_ids} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
{:ok, media_ids} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
||||||
|
|
@ -37,6 +37,8 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
||||||
|
|
||||||
MediaIndexingWorker.kickoff_with_task(source, url)
|
MediaIndexingWorker.kickoff_with_task(source, url)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
new_media_ids
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,10 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
|
||||||
alias __MODULE__
|
alias __MODULE__
|
||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
alias Pinchflat.Sources
|
alias Pinchflat.Sources
|
||||||
|
alias Pinchflat.Settings
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.FastIndexing.FastIndexingHelpers
|
alias Pinchflat.FastIndexing.FastIndexingHelpers
|
||||||
|
alias Pinchflat.Notifications.SourceNotifications
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Starts the source fast indexing worker and creates a task for the source.
|
Starts the source fast indexing worker and creates a task for the source.
|
||||||
|
|
@ -37,8 +39,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
|
||||||
source = Sources.get_source!(source_id)
|
source = Sources.get_source!(source_id)
|
||||||
|
|
||||||
if source.fast_index do
|
if source.fast_index do
|
||||||
FastIndexingHelpers.kickoff_indexing_tasks_from_youtube_rss_feed(source)
|
perform_indexing_and_notification(source)
|
||||||
|
|
||||||
reschedule_indexing(source)
|
reschedule_indexing(source)
|
||||||
else
|
else
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -48,6 +49,13 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
|
||||||
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
|
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp perform_indexing_and_notification(source) do
|
||||||
|
apprise_server = Settings.get!(:apprise_server)
|
||||||
|
new_media_items = FastIndexingHelpers.kickoff_indexing_tasks_from_youtube_rss_feed(source)
|
||||||
|
|
||||||
|
SourceNotifications.send_new_media_notification(apprise_server, source, length(new_media_items))
|
||||||
|
end
|
||||||
|
|
||||||
defp reschedule_indexing(source) do
|
defp reschedule_indexing(source) do
|
||||||
next_run_in = Source.fast_index_frequency() * 60
|
next_run_in = Source.fast_index_frequency() * 60
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ defmodule Pinchflat.Media do
|
||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaItem
|
||||||
alias Pinchflat.Media.MediaQuery
|
alias Pinchflat.Media.MediaQuery
|
||||||
alias Pinchflat.Metadata.MediaMetadata
|
alias Pinchflat.Metadata.MediaMetadata
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the list of media_items.
|
Returns the list of media_items.
|
||||||
|
|
@ -31,15 +31,39 @@ defmodule Pinchflat.Media do
|
||||||
def list_cullable_media_items do
|
def list_cullable_media_items do
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.with_media_filepath()
|
|> MediaQuery.with_media_filepath()
|
||||||
|> MediaQuery.with_passed_retention_period()
|
|> MediaQuery.where_past_retention_period()
|
||||||
|> MediaQuery.with_no_culling_prevention()
|
|> MediaQuery.where_culling_not_prevented()
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns a list of media_items that are redownloadable based on the redownload delay
|
||||||
|
of the media_profile their source belongs to.
|
||||||
|
|
||||||
|
The logic is that a media_item is past_redownload_delay if the media_item's
|
||||||
|
upload_date is at least redownload_delay_days ago AND
|
||||||
|
`media_downloaded_at` - `redownload_delay_days` is before the media_item's `upload_date`.
|
||||||
|
This logic grabs media that we've recently downloaded AND is recently uploaded, but
|
||||||
|
doesn't grab media that we've recently downloaded and was uploaded a long time ago.
|
||||||
|
This also makes things work as expected when downloading media from a source for the
|
||||||
|
first time.
|
||||||
|
|
||||||
|
Returns [%MediaItem{}, ...]
|
||||||
|
"""
|
||||||
|
def list_redownloadable_media_items do
|
||||||
|
MediaQuery.new()
|
||||||
|
|> MediaQuery.with_media_downloaded_at()
|
||||||
|
|> MediaQuery.where_download_not_prevented()
|
||||||
|
|> MediaQuery.where_not_culled()
|
||||||
|
|> MediaQuery.where_media_not_redownloaded()
|
||||||
|
|> MediaQuery.where_past_redownload_delay()
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
@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 satisfies `MediaQuery.with_media_pending_download`. You
|
matches satisfies `MediaQuery.where_pending_download`. You
|
||||||
should really check out that function if you need to know more
|
should really check out that function if you need to know more
|
||||||
because it has a lot going on.
|
because it has a lot going on.
|
||||||
|
|
||||||
|
|
@ -48,7 +72,7 @@ defmodule Pinchflat.Media do
|
||||||
def list_pending_media_items_for(%Source{} = source) do
|
def list_pending_media_items_for(%Source{} = source) do
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.for_source(source)
|
|> MediaQuery.for_source(source)
|
||||||
|> MediaQuery.with_media_pending_download()
|
|> MediaQuery.where_pending_download()
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -66,7 +90,7 @@ defmodule Pinchflat.Media do
|
||||||
|
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.with_id(media_item.id)
|
|> MediaQuery.with_id(media_item.id)
|
||||||
|> MediaQuery.with_media_pending_download()
|
|> MediaQuery.where_pending_download()
|
||||||
|> Repo.exists?()
|
|> Repo.exists?()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -199,7 +223,7 @@ defmodule Pinchflat.Media do
|
||||||
end)
|
end)
|
||||||
|> List.flatten()
|
|> List.flatten()
|
||||||
|> Enum.filter(&is_binary/1)
|
|> Enum.filter(&is_binary/1)
|
||||||
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
|
|> Enum.each(&FilesystemUtils.delete_file_and_remove_empty_directories/1)
|
||||||
|
|
||||||
{:ok, media_item}
|
{:ok, media_item}
|
||||||
end
|
end
|
||||||
|
|
@ -211,6 +235,6 @@ defmodule Pinchflat.Media do
|
||||||
MediaMetadata.filepath_attributes()
|
MediaMetadata.filepath_attributes()
|
||||||
|> Enum.map(fn field -> mapped_struct[field] end)
|
|> Enum.map(fn field -> mapped_struct[field] end)
|
||||||
|> Enum.filter(&is_binary/1)
|
|> Enum.filter(&is_binary/1)
|
||||||
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
|
|> Enum.each(&FilesystemUtils.delete_file_and_remove_empty_directories/1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,8 @@ defmodule Pinchflat.Media.MediaItem do
|
||||||
# These are user or system controlled fields
|
# These are user or system controlled fields
|
||||||
:prevent_download,
|
:prevent_download,
|
||||||
:prevent_culling,
|
:prevent_culling,
|
||||||
:culled_at
|
:culled_at,
|
||||||
|
:media_redownloaded_at
|
||||||
]
|
]
|
||||||
# Pretty much all the fields captured at index are required.
|
# Pretty much all the fields captured at index are required.
|
||||||
@required_fields ~w(
|
@required_fields ~w(
|
||||||
|
|
@ -61,6 +62,7 @@ defmodule Pinchflat.Media.MediaItem do
|
||||||
field :livestream, :boolean, default: false
|
field :livestream, :boolean, default: false
|
||||||
field :short_form_content, :boolean, default: false
|
field :short_form_content, :boolean, default: false
|
||||||
field :media_downloaded_at, :utc_datetime
|
field :media_downloaded_at, :utc_datetime
|
||||||
|
field :media_redownloaded_at, :utc_datetime
|
||||||
field :upload_date, :date
|
field :upload_date, :date
|
||||||
field :duration_seconds, :integer
|
field :duration_seconds, :integer
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ defmodule Pinchflat.Media.MediaQuery do
|
||||||
# Prefixes:
|
# Prefixes:
|
||||||
# - for_* - belonging to a certain record
|
# - for_* - belonging to a certain record
|
||||||
# - join_* - for joining on a certain record
|
# - join_* - for joining on a certain record
|
||||||
# - with_* - for filtering based on full, concrete attributes
|
# - with_*, where_* - for filtering based on full, concrete attributes
|
||||||
# - matching_* - for filtering based on partial attributes (e.g. LIKE, regex, full-text search)
|
# - matching_* - for filtering based on partial attributes (e.g. LIKE, regex, full-text search)
|
||||||
#
|
#
|
||||||
# Suffixes:
|
# Suffixes:
|
||||||
|
|
@ -33,24 +33,46 @@ defmodule Pinchflat.Media.MediaQuery do
|
||||||
from(mi in query, join: s in assoc(mi, :source), as: :sources)
|
from(mi in query, join: s in assoc(mi, :source), as: :sources)
|
||||||
end
|
end
|
||||||
|
|
||||||
def with_passed_retention_period(query) do
|
def where_past_retention_period(query) do
|
||||||
query
|
query
|
||||||
|> require_assoc(:source)
|
|> require_assoc(:source)
|
||||||
|> where(
|
|> where(
|
||||||
[mi, source],
|
[mi, source],
|
||||||
fragment(
|
fragment("""
|
||||||
"IFNULL(?, 0) > 0 AND DATETIME('now', '-' || ? || ' day') > ?",
|
IFNULL(retention_period_days, 0) > 0 AND
|
||||||
source.retention_period_days,
|
DATETIME('now', '-' || retention_period_days || ' day') > media_downloaded_at
|
||||||
source.retention_period_days,
|
""")
|
||||||
mi.media_downloaded_at
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def with_no_culling_prevention(query) do
|
def where_past_redownload_delay(query) do
|
||||||
|
query
|
||||||
|
|> require_assoc(:source)
|
||||||
|
|> require_assoc(:media_profile)
|
||||||
|
|> where(
|
||||||
|
[_mi, _source, _media_profile],
|
||||||
|
# Returns media items where the upload_date is at least redownload_delay_days ago AND
|
||||||
|
# downloaded_at minus the redownload_delay_days is before the upload date
|
||||||
|
fragment("""
|
||||||
|
IFNULL(redownload_delay_days, 0) > 0 AND
|
||||||
|
DATETIME('now', '-' || redownload_delay_days || ' day') > upload_date AND
|
||||||
|
DATETIME(media_downloaded_at, '-' || redownload_delay_days || ' day') < upload_date
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def where_culling_not_prevented(query) do
|
||||||
where(query, [mi], mi.prevent_culling == false)
|
where(query, [mi], mi.prevent_culling == false)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def where_not_culled(query) do
|
||||||
|
where(query, [mi], is_nil(mi.culled_at))
|
||||||
|
end
|
||||||
|
|
||||||
|
def where_media_not_redownloaded(query) do
|
||||||
|
where(query, [mi], is_nil(mi.media_redownloaded_at))
|
||||||
|
end
|
||||||
|
|
||||||
def with_id(query, id) do
|
def with_id(query, id) do
|
||||||
where(query, [mi], mi.id == ^id)
|
where(query, [mi], mi.id == ^id)
|
||||||
end
|
end
|
||||||
|
|
@ -59,6 +81,10 @@ defmodule Pinchflat.Media.MediaQuery do
|
||||||
where(query, [mi], mi.media_id in ^media_ids)
|
where(query, [mi], mi.media_id in ^media_ids)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def with_media_downloaded_at(query) do
|
||||||
|
where(query, [mi], not is_nil(mi.media_downloaded_at))
|
||||||
|
end
|
||||||
|
|
||||||
def with_media_filepath(query) do
|
def with_media_filepath(query) do
|
||||||
where(query, [mi], not is_nil(mi.media_filepath))
|
where(query, [mi], not is_nil(mi.media_filepath))
|
||||||
end
|
end
|
||||||
|
|
@ -73,7 +99,7 @@ defmodule Pinchflat.Media.MediaQuery do
|
||||||
|> where([mi, source], is_nil(source.download_cutoff_date) or mi.upload_date >= source.download_cutoff_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 where_download_not_prevented(query) do
|
||||||
where(query, [mi], mi.prevent_download == false)
|
where(query, [mi], mi.prevent_download == false)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -129,9 +155,9 @@ defmodule Pinchflat.Media.MediaQuery do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def with_media_pending_download(query) do
|
def where_pending_download(query) do
|
||||||
query
|
query
|
||||||
|> with_no_prevented_download()
|
|> where_download_not_prevented()
|
||||||
|> with_no_media_filepath()
|
|> with_no_media_filepath()
|
||||||
|> with_upload_date_after_source_cutoff()
|
|> with_upload_date_after_source_cutoff()
|
||||||
|> with_format_matching_profile_preference()
|
|> with_format_matching_profile_preference()
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
|
||||||
needed
|
needed
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the directory where metadata for a database record should be stored.
|
Returns the directory where metadata for a database record should be stored.
|
||||||
|
|
@ -36,7 +36,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
|
||||||
filepath = generate_filepath_for(database_record, "metadata.json.gz")
|
filepath = generate_filepath_for(database_record, "metadata.json.gz")
|
||||||
{:ok, json} = Phoenix.json_library().encode(metadata_map)
|
{:ok, json} = Phoenix.json_library().encode(metadata_map)
|
||||||
|
|
||||||
:ok = FilesystemHelpers.write_p!(filepath, json, [:compressed])
|
:ok = FilesystemUtils.write_p!(filepath, json, [:compressed])
|
||||||
|
|
||||||
filepath
|
filepath
|
||||||
end
|
end
|
||||||
|
|
@ -62,7 +62,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
|
||||||
filepath = generate_filepath_for(database_record, Path.basename(thumbnail_url))
|
filepath = generate_filepath_for(database_record, Path.basename(thumbnail_url))
|
||||||
thumbnail_blob = fetch_thumbnail_from_url(thumbnail_url)
|
thumbnail_blob = fetch_thumbnail_from_url(thumbnail_url)
|
||||||
|
|
||||||
:ok = FilesystemHelpers.write_p!(filepath, thumbnail_blob)
|
:ok = FilesystemUtils.write_p!(filepath, thumbnail_blob)
|
||||||
|
|
||||||
filepath
|
filepath
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ defmodule Pinchflat.Metadata.NfoBuilder do
|
||||||
import Pinchflat.Utils.XmlUtils, only: [safe: 1]
|
import Pinchflat.Utils.XmlUtils, only: [safe: 1]
|
||||||
|
|
||||||
alias Pinchflat.Metadata.MetadataFileHelpers
|
alias Pinchflat.Metadata.MetadataFileHelpers
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Builds an NFO file for a media item (read: single "episode") and
|
Builds an NFO file for a media item (read: single "episode") and
|
||||||
|
|
@ -18,7 +18,7 @@ defmodule Pinchflat.Metadata.NfoBuilder do
|
||||||
def build_and_store_for_media_item(filepath, metadata) do
|
def build_and_store_for_media_item(filepath, metadata) do
|
||||||
nfo = build_for_media_item(metadata)
|
nfo = build_for_media_item(metadata)
|
||||||
|
|
||||||
FilesystemHelpers.write_p!(filepath, nfo)
|
FilesystemUtils.write_p!(filepath, nfo)
|
||||||
|
|
||||||
filepath
|
filepath
|
||||||
end
|
end
|
||||||
|
|
@ -32,7 +32,7 @@ defmodule Pinchflat.Metadata.NfoBuilder do
|
||||||
def build_and_store_for_source(filepath, metadata) do
|
def build_and_store_for_source(filepath, metadata) do
|
||||||
nfo = build_for_source(metadata)
|
nfo = build_for_source(metadata)
|
||||||
|
|
||||||
FilesystemHelpers.write_p!(filepath, nfo)
|
FilesystemUtils.write_p!(filepath, nfo)
|
||||||
|
|
||||||
filepath
|
filepath
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ defmodule Pinchflat.Metadata.SourceImageParser do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Functions for parsing and storing source images.
|
Functions for parsing and storing source images.
|
||||||
"""
|
"""
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Given a base directory and source metadata, look for the appropriate images
|
Given a base directory and source metadata, look for the appropriate images
|
||||||
|
|
@ -62,7 +62,7 @@ defmodule Pinchflat.Metadata.SourceImageParser do
|
||||||
extension = Path.extname(tmp_filepath)
|
extension = Path.extname(tmp_filepath)
|
||||||
final_filepath = Path.join([base_directory, "#{filename}#{extension}"])
|
final_filepath = Path.join([base_directory, "#{filename}#{extension}"])
|
||||||
|
|
||||||
FilesystemHelpers.cp_p!(tmp_filepath, final_filepath)
|
FilesystemUtils.cp_p!(tmp_filepath, final_filepath)
|
||||||
|
|
||||||
{source_attr_name, final_filepath}
|
{source_attr_name, final_filepath}
|
||||||
end
|
end
|
||||||
|
|
|
||||||
12
lib/pinchflat/notifications/apprise_command_runner.ex
Normal file
12
lib/pinchflat/notifications/apprise_command_runner.ex
Normal file
|
|
@ -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
|
||||||
65
lib/pinchflat/notifications/command_runner.ex
Normal file
65
lib/pinchflat/notifications/command_runner.ex
Normal file
|
|
@ -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
|
||||||
77
lib/pinchflat/notifications/source_notifications.ex
Normal file
77
lib/pinchflat/notifications/source_notifications.ex
Normal file
|
|
@ -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.where_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
|
||||||
|
|
@ -26,12 +26,14 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
||||||
shorts_behaviour
|
shorts_behaviour
|
||||||
livestream_behaviour
|
livestream_behaviour
|
||||||
preferred_resolution
|
preferred_resolution
|
||||||
|
redownload_delay_days
|
||||||
)a
|
)a
|
||||||
|
|
||||||
@required_fields ~w(name output_path_template)a
|
@required_fields ~w(name output_path_template)a
|
||||||
|
|
||||||
schema "media_profiles" do
|
schema "media_profiles" do
|
||||||
field :name, :string
|
field :name, :string
|
||||||
|
field :redownload_delay_days, :integer
|
||||||
|
|
||||||
field :output_path_template, :string,
|
field :output_path_template, :string,
|
||||||
default: "/{{ source_custom_name }}/{{ upload_yyyy_mm_dd }} {{ title }}/{{ title }} [{{ id }}].{{ ext }}"
|
default: "/{{ source_custom_name }}/{{ upload_yyyy_mm_dd }} {{ title }}/{{ title }} [{{ id }}].{{ ext }}"
|
||||||
|
|
@ -60,7 +62,6 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
||||||
# See `build_format_clauses` in the Media context for more.
|
# See `build_format_clauses` in the Media context for more.
|
||||||
field :shorts_behaviour, Ecto.Enum, values: ~w(include exclude only)a, default: :include
|
field :shorts_behaviour, Ecto.Enum, values: ~w(include exclude only)a, default: :include
|
||||||
field :livestream_behaviour, Ecto.Enum, values: ~w(include exclude only)a, default: :include
|
field :livestream_behaviour, Ecto.Enum, values: ~w(include exclude only)a, default: :include
|
||||||
|
|
||||||
field :preferred_resolution, Ecto.Enum, values: ~w(2160p 1080p 720p 480p 360p audio)a, default: :"1080p"
|
field :preferred_resolution, Ecto.Enum, values: ~w(2160p 1080p 720p 480p 360p audio)a, default: :"1080p"
|
||||||
|
|
||||||
has_many :sources, Source
|
has_many :sources, Source
|
||||||
|
|
@ -75,6 +76,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
||||||
|> validate_required(@required_fields)
|
|> validate_required(@required_fields)
|
||||||
# Ensures it ends with `.{{ ext }}` or `.%(ext)s` or similar (with a little wiggle room)
|
# Ensures it ends with `.{{ ext }}` or `.%(ext)s` or similar (with a little wiggle room)
|
||||||
|> validate_format(:output_path_template, ext_regex(), message: "must end with .{{ ext }}")
|
|> validate_format(:output_path_template, ext_regex(), message: "must end with .{{ ext }}")
|
||||||
|
|> validate_number(:redownload_delay_days, greater_than_or_equal_to: 0)
|
||||||
|> unique_constraint(:name)
|
|> unique_constraint(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ defmodule Pinchflat.Release do
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
|
|
||||||
def migrate do
|
def migrate do
|
||||||
load_app()
|
load_app()
|
||||||
|
|
@ -39,7 +39,7 @@ defmodule Pinchflat.Release do
|
||||||
Logger.info("Checking permissions for #{dir}")
|
Logger.info("Checking permissions for #{dir}")
|
||||||
filepath = Path.join([dir, ".keep"])
|
filepath = Path.join([dir, ".keep"])
|
||||||
|
|
||||||
case FilesystemHelpers.write_p(filepath, "") do
|
case FilesystemUtils.write_p(filepath, "") do
|
||||||
:ok ->
|
:ok ->
|
||||||
Logger.info("Permissions OK")
|
Logger.info("Permissions OK")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ defmodule Pinchflat.Settings.Setting do
|
||||||
@allowed_fields [
|
@allowed_fields [
|
||||||
:onboarding,
|
:onboarding,
|
||||||
:pro_enabled,
|
:pro_enabled,
|
||||||
:yt_dlp_version
|
:yt_dlp_version,
|
||||||
|
:apprise_version,
|
||||||
|
:apprise_server
|
||||||
]
|
]
|
||||||
|
|
||||||
@required_fields ~w(
|
@required_fields ~w(
|
||||||
|
|
@ -21,6 +23,8 @@ defmodule Pinchflat.Settings.Setting do
|
||||||
field :onboarding, :boolean, default: true
|
field :onboarding, :boolean, default: true
|
||||||
field :pro_enabled, :boolean, default: false
|
field :pro_enabled, :boolean, default: false
|
||||||
field :yt_dlp_version, :string
|
field :yt_dlp_version, :string
|
||||||
|
field :apprise_version, :string
|
||||||
|
field :apprise_server, :string
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,17 @@ defmodule Pinchflat.Settings do
|
||||||
|> Repo.one()
|
|> Repo.one()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates the setting record.
|
||||||
|
|
||||||
|
Returns {:ok, %Setting{}} | {:error, %Ecto.Changeset{}}
|
||||||
|
"""
|
||||||
|
def update_setting(%Setting{} = setting, attrs) do
|
||||||
|
setting
|
||||||
|
|> Setting.changeset(attrs)
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Updates a setting, returning the new value.
|
Updates a setting, returning the new value.
|
||||||
Is setup to take a keyword list argument so you
|
Is setup to take a keyword list argument so you
|
||||||
|
|
@ -29,8 +40,7 @@ defmodule Pinchflat.Settings do
|
||||||
"""
|
"""
|
||||||
def set([{attr, value}]) do
|
def set([{attr, value}]) do
|
||||||
record()
|
record()
|
||||||
|> Setting.changeset(%{attr => value})
|
|> update_setting(%{attr => value})
|
||||||
|> Repo.update()
|
|
||||||
|> case do
|
|> case do
|
||||||
{:ok, %{^attr => _}} -> {:ok, value}
|
{:ok, %{^attr => _}} -> {:ok, value}
|
||||||
{:ok, _} -> {:error, :invalid_key}
|
{:ok, _} -> {:error, :invalid_key}
|
||||||
|
|
@ -61,4 +71,11 @@ defmodule Pinchflat.Settings do
|
||||||
{:error, _} -> raise "Setting `#{name}` not found"
|
{:error, _} -> raise "Setting `#{name}` not found"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns `%Ecto.Changeset{}`
|
||||||
|
"""
|
||||||
|
def change_setting(%Setting{} = setting, attrs \\ %{}) do
|
||||||
|
Setting.changeset(setting, attrs)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,11 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
||||||
alias __MODULE__
|
alias __MODULE__
|
||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
alias Pinchflat.Sources
|
alias Pinchflat.Sources
|
||||||
|
alias Pinchflat.Settings
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.FastIndexing.FastIndexingWorker
|
alias Pinchflat.FastIndexing.FastIndexingWorker
|
||||||
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
||||||
|
alias Pinchflat.Notifications.SourceNotifications
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Starts the source slow indexing worker and creates a task for the source.
|
Starts the source slow indexing worker and creates a task for the source.
|
||||||
|
|
@ -78,21 +80,21 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
||||||
case {source.index_frequency_minutes, source.last_indexed_at} do
|
case {source.index_frequency_minutes, source.last_indexed_at} do
|
||||||
{index_freq, _} when index_freq > 0 ->
|
{index_freq, _} when index_freq > 0 ->
|
||||||
# If the indexing is on a schedule simply run indexing and reschedule
|
# If the indexing is on a schedule simply run indexing and reschedule
|
||||||
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
perform_indexing_and_notification(source)
|
||||||
maybe_enqueue_fast_indexing_task(source)
|
maybe_enqueue_fast_indexing_task(source)
|
||||||
reschedule_indexing(source)
|
reschedule_indexing(source)
|
||||||
|
|
||||||
{_, nil} ->
|
{_, nil} ->
|
||||||
# If the source has never been indexed, index it once
|
# If the source has never been indexed, index it once
|
||||||
# even if it's not meant to reschedule
|
# even if it's not meant to reschedule
|
||||||
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
perform_indexing_and_notification(source)
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
# If the source HAS been indexed and is not meant to reschedule,
|
# If the source HAS been indexed and is not meant to reschedule,
|
||||||
# perform a no-op (unless forced)
|
# perform a no-op (unless forced)
|
||||||
if args["force"] do
|
if args["force"] do
|
||||||
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
perform_indexing_and_notification(source)
|
||||||
end
|
end
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
|
|
@ -102,6 +104,14 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
||||||
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
|
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp perform_indexing_and_notification(source) do
|
||||||
|
apprise_server = Settings.get!(:apprise_server)
|
||||||
|
|
||||||
|
SourceNotifications.wrap_new_media_notification(apprise_server, source, fn ->
|
||||||
|
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp reschedule_indexing(source) do
|
defp reschedule_indexing(source) do
|
||||||
next_run_in = source.index_frequency_minutes * 60
|
next_run_in = source.index_frequency_minutes * 60
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
||||||
def index_and_enqueue_download_for_media_items(%Source{} = source) do
|
def index_and_enqueue_download_for_media_items(%Source{} = source) do
|
||||||
# See the method definition below for more info on how file watchers work
|
# See the method definition below for more info on how file watchers work
|
||||||
# (important reading if you're not familiar with it)
|
# (important reading if you're not familiar with it)
|
||||||
{:ok, media_attributes} = get_media_attributes_for_collection_and_setup_file_watcher(source)
|
{:ok, media_attributes} = setup_file_watcher_and_kickoff_indexing(source)
|
||||||
# Reload because the source may have been updated during the (long-running) indexing process
|
# Reload because the source may have been updated during the (long-running) indexing process
|
||||||
# and important settings like `download_media` may have changed.
|
# and important settings like `download_media` may have changed.
|
||||||
source = Repo.reload!(source)
|
source = Repo.reload!(source)
|
||||||
|
|
@ -84,15 +84,15 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
||||||
# lines (ie: you should gracefully fail if you can't parse a line).
|
# lines (ie: you should gracefully fail if you can't parse a line).
|
||||||
#
|
#
|
||||||
# This works in-tandem with the normal (blocking) media indexing behaviour. When
|
# This works in-tandem with the normal (blocking) media indexing behaviour. When
|
||||||
# the `get_media_attributes_for_collection` method completes it'll return the FULL result to
|
# the `setup_file_watcher_and_kickoff_indexing` method completes it'll return the
|
||||||
# the caller for parsing. Ideally, every item in the list will have already
|
# FULL result to the caller for parsing. Ideally, every item in the list will have already
|
||||||
# been processed by the file follower, but if not, the caller handles creation
|
# been processed by the file follower, but if not, the caller handles creation
|
||||||
# of any media items that were missed/initially failed.
|
# of any media items that were missed/initially failed.
|
||||||
#
|
#
|
||||||
# It attempts a graceful shutdown of the file follower after the indexing is done,
|
# It attempts a graceful shutdown of the file follower after the indexing is done,
|
||||||
# but the FileFollowerServer will also stop itself if it doesn't see any activity
|
# but the FileFollowerServer will also stop itself if it doesn't see any activity
|
||||||
# for a sufficiently long time.
|
# for a sufficiently long time.
|
||||||
defp get_media_attributes_for_collection_and_setup_file_watcher(source) do
|
defp setup_file_watcher_and_kickoff_indexing(source) do
|
||||||
{:ok, pid} = FileFollowerServer.start_link()
|
{:ok, pid} = FileFollowerServer.start_link()
|
||||||
|
|
||||||
handler = fn filepath -> setup_file_follower_watcher(pid, filepath, source) end
|
handler = fn filepath -> setup_file_follower_watcher(pid, filepath, source) end
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ defmodule Pinchflat.Sources do
|
||||||
alias Pinchflat.Profiles.MediaProfile
|
alias Pinchflat.Profiles.MediaProfile
|
||||||
alias Pinchflat.YtDlp.MediaCollection
|
alias Pinchflat.YtDlp.MediaCollection
|
||||||
alias Pinchflat.Metadata.SourceMetadata
|
alias Pinchflat.Metadata.SourceMetadata
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
alias Pinchflat.Downloading.DownloadingHelpers
|
alias Pinchflat.Downloading.DownloadingHelpers
|
||||||
alias Pinchflat.FastIndexing.FastIndexingWorker
|
alias Pinchflat.FastIndexing.FastIndexingWorker
|
||||||
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
||||||
|
|
@ -153,7 +153,7 @@ defmodule Pinchflat.Sources do
|
||||||
Source.filepath_attributes()
|
Source.filepath_attributes()
|
||||||
|> Enum.map(fn field -> mapped_struct[field] end)
|
|> Enum.map(fn field -> mapped_struct[field] end)
|
||||||
|> Enum.filter(&is_binary/1)
|
|> Enum.filter(&is_binary/1)
|
||||||
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
|
|> Enum.each(&FilesystemUtils.delete_file_and_remove_empty_directories/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp delete_internal_metadata_files(source) do
|
defp delete_internal_metadata_files(source) do
|
||||||
|
|
@ -163,7 +163,7 @@ defmodule Pinchflat.Sources do
|
||||||
SourceMetadata.filepath_attributes()
|
SourceMetadata.filepath_attributes()
|
||||||
|> Enum.map(fn field -> mapped_struct[field] end)
|
|> Enum.map(fn field -> mapped_struct[field] end)
|
||||||
|> Enum.filter(&is_binary/1)
|
|> Enum.filter(&is_binary/1)
|
||||||
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
|
|> Enum.each(&FilesystemUtils.delete_file_and_remove_empty_directories/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp add_source_details_to_changeset(source, changeset) do
|
defp add_source_details_to_changeset(source, changeset) do
|
||||||
|
|
|
||||||
48
lib/pinchflat/utils/cli_utils.ex
Normal file
48
lib/pinchflat/utils/cli_utils.ex
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
defmodule Pinchflat.Utils.CliUtils do
|
||||||
|
@moduledoc """
|
||||||
|
Utility methods for working with CLI executables
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Pinchflat.Utils.StringUtils
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Parses a list of command options into a list of strings suitable for passing to
|
||||||
|
`System.cmd/3`.
|
||||||
|
|
||||||
|
We want to satisfy the following behaviours:
|
||||||
|
1. If the key is an atom, convert it to a string and convert it to kebab case (for convenience)
|
||||||
|
2. If the key is a string, assume we want it as-is and don't convert it
|
||||||
|
3. If the key is accompanied by a value, append the value to the list
|
||||||
|
4. If the key is not accompanied by a value, assume it's a flag and PREpend it to the list
|
||||||
|
|
||||||
|
Returns [binary()]
|
||||||
|
"""
|
||||||
|
def parse_options(command_opts) do
|
||||||
|
command_opts
|
||||||
|
|> List.wrap()
|
||||||
|
|> Enum.reduce([], &parse_option/2)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_option({k, v}, acc) when is_atom(k) do
|
||||||
|
stringified_key = StringUtils.to_kebab_case(Atom.to_string(k))
|
||||||
|
|
||||||
|
parse_option({"--#{stringified_key}", v}, acc)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_option({k, v}, acc) when is_binary(k) do
|
||||||
|
acc ++ [k, to_string(v)]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_option(arg, acc) when is_atom(arg) do
|
||||||
|
stringified_arg =
|
||||||
|
arg
|
||||||
|
|> Atom.to_string()
|
||||||
|
|> StringUtils.to_kebab_case()
|
||||||
|
|
||||||
|
parse_option("--#{stringified_arg}", acc)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_option(arg, acc) when is_binary(arg) do
|
||||||
|
acc ++ [arg]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
defmodule Pinchflat.Filesystem.FilesystemHelpers do
|
defmodule Pinchflat.Utils.FilesystemUtils do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Utility methods for working with the filesystem
|
Utility methods for working with the filesystem
|
||||||
"""
|
"""
|
||||||
|
|
@ -5,11 +5,11 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
alias Pinchflat.Utils.StringUtils
|
alias Pinchflat.Utils.CliUtils
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers, as: FSUtils
|
alias Pinchflat.YtDlp.YtDlpCommandRunner
|
||||||
alias Pinchflat.YtDlp.BackendCommandRunner
|
alias Pinchflat.Utils.FilesystemUtils, as: FSUtils
|
||||||
|
|
||||||
@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,17 +23,18 @@ 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.
|
|
||||||
# Also, can't use RAM file since yt-dlp needs a concrete filepath.
|
|
||||||
output_filepath = generate_output_filepath(addl_opts)
|
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()
|
external_file_opts = build_external_file_options()
|
||||||
formatted_command_opts = [url] ++ parse_options(command_opts ++ print_to_file_opts ++ cookie_opts)
|
# These must stay in exactly this order, hence why I'm giving it its own variable.
|
||||||
|
all_opts = command_opts ++ print_to_file_opts ++ external_file_opts
|
||||||
|
|
||||||
|
formatted_command_opts = [url] ++ CliUtils.parse_options(all_opts)
|
||||||
Logger.info("[yt-dlp] called with: #{Enum.join(formatted_command_opts, " ")}")
|
Logger.info("[yt-dlp] called with: #{Enum.join(formatted_command_opts, " ")}")
|
||||||
|
|
||||||
case System.cmd(command, formatted_command_opts, stderr_to_stdout: true) do
|
case System.cmd(command, formatted_command_opts, stderr_to_stdout: true) do
|
||||||
|
|
@ -48,7 +49,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()
|
||||||
|
|
||||||
|
|
@ -68,47 +74,25 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_cookie_options do
|
defp build_external_file_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")
|
filename_options_map = %{cookies: "cookies.txt"}
|
||||||
|
|
||||||
case File.read(cookie_file) do
|
Enum.reduce(filename_options_map, [], fn {opt_name, filename}, acc ->
|
||||||
{:ok, cookie_data} ->
|
filepath = Path.join(base_dir, filename)
|
||||||
if String.trim(cookie_data) != "", do: [cookies: cookie_file], else: []
|
|
||||||
|
|
||||||
{:error, _} ->
|
case File.read(filepath) do
|
||||||
[]
|
{:ok, file_data} ->
|
||||||
end
|
if String.trim(file_data) != "" do
|
||||||
end
|
[{opt_name, filepath} | acc]
|
||||||
|
else
|
||||||
|
acc
|
||||||
|
end
|
||||||
|
|
||||||
# We want to satisfy the following behaviours:
|
{:error, _} ->
|
||||||
#
|
acc
|
||||||
# 1. If the key is an atom, convert it to a string and convert it to kebab case (for convenience)
|
end
|
||||||
# 2. If the key is a string, assume we want it as-is and don't convert it
|
end)
|
||||||
# 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
|
end
|
||||||
|
|
||||||
defp backend_executable do
|
defp backend_executable do
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ defmodule Pinchflat.YtDlp.MediaCollection do
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -26,7 +26,7 @@ defmodule Pinchflat.YtDlp.MediaCollection do
|
||||||
# available formats since we're just getting the media details
|
# available formats since we're just getting the media details
|
||||||
command_opts = [:simulate, :skip_download, :ignore_no_formats_error]
|
command_opts = [:simulate, :skip_download, :ignore_no_formats_error]
|
||||||
output_template = YtDlpMedia.indexing_output_template()
|
output_template = YtDlpMedia.indexing_output_template()
|
||||||
output_filepath = FilesystemHelpers.generate_metadata_tmpfile(:json)
|
output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||||
file_listener_handler = Keyword.get(addl_opts, :file_listener_handler, false)
|
file_listener_handler = Keyword.get(addl_opts, :file_listener_handler, false)
|
||||||
|
|
||||||
if file_listener_handler do
|
if file_listener_handler do
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
defmodule Pinchflat.YtDlp.BackendCommandRunner do
|
defmodule Pinchflat.YtDlp.YtDlpCommandRunner do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
A behaviour for running CLI commands against a downloader backend (yt-dlp).
|
A behaviour for running CLI commands against a downloader backend (yt-dlp).
|
||||||
|
|
||||||
|
|
@ -247,6 +247,7 @@ defmodule PinchflatWeb.CoreComponents do
|
||||||
attr :label_suffix, :string, default: nil
|
attr :label_suffix, :string, default: nil
|
||||||
attr :value, :any
|
attr :value, :any
|
||||||
attr :help, :string, default: nil
|
attr :help, :string, default: nil
|
||||||
|
attr :html_help, :boolean, default: false
|
||||||
|
|
||||||
attr :type, :string,
|
attr :type, :string,
|
||||||
default: "text",
|
default: "text",
|
||||||
|
|
@ -298,7 +299,7 @@ defmodule PinchflatWeb.CoreComponents do
|
||||||
<%= @label %>
|
<%= @label %>
|
||||||
<span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span>
|
<span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span>
|
||||||
</label>
|
</label>
|
||||||
<.help :if={@help}><%= @help %></.help>
|
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
|
||||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
@ -325,7 +326,7 @@ defmodule PinchflatWeb.CoreComponents do
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<.help :if={@help}><%= @help %></.help>
|
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
|
||||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
@ -356,7 +357,7 @@ defmodule PinchflatWeb.CoreComponents do
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<.help :if={@help}><%= @help %></.help>
|
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
|
||||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -387,7 +388,7 @@ defmodule PinchflatWeb.CoreComponents do
|
||||||
</select>
|
</select>
|
||||||
<%= render_slot(@inner_block) %>
|
<%= render_slot(@inner_block) %>
|
||||||
</div>
|
</div>
|
||||||
<.help :if={@help}><%= @help %></.help>
|
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
|
||||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
@ -411,7 +412,7 @@ defmodule PinchflatWeb.CoreComponents do
|
||||||
]}
|
]}
|
||||||
{@rest}
|
{@rest}
|
||||||
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
|
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
|
||||||
<.help :if={@help}><%= @help %></.help>
|
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
|
||||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
@ -438,7 +439,7 @@ defmodule PinchflatWeb.CoreComponents do
|
||||||
]}
|
]}
|
||||||
{@rest}
|
{@rest}
|
||||||
/>
|
/>
|
||||||
<.help :if={@help}><%= @help %></.help>
|
<.help :if={@help}><%= if @html_help, do: Phoenix.HTML.raw(@help), else: @help %></.help>
|
||||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
<.sidebar_item icon="hero-home" text="Home" href={~p"/"} />
|
<.sidebar_item icon="hero-home" text="Home" href={~p"/"} />
|
||||||
<.sidebar_item icon="hero-tv" text="Sources" href={~p"/sources"} />
|
<.sidebar_item icon="hero-tv" text="Sources" href={~p"/sources"} />
|
||||||
<.sidebar_item icon="hero-adjustments-vertical" text="Media Profiles" href={~p"/media_profiles"} />
|
<.sidebar_item icon="hero-adjustments-vertical" text="Media Profiles" href={~p"/media_profiles"} />
|
||||||
|
<.sidebar_item icon="hero-cog-6-tooth" text="Settings" href={~p"/settings"} />
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -38,12 +39,7 @@
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href="https://github.com/kieraneglin/pinchflat/wiki"
|
href="https://github.com/kieraneglin/pinchflat/wiki"
|
||||||
/>
|
/>
|
||||||
<.sidebar_item
|
<.sidebar_item icon="hero-cog" text="Github" target="_blank" href="https://github.com/kieraneglin/pinchflat" />
|
||||||
icon="hero-code-bracket"
|
|
||||||
text="Github"
|
|
||||||
target="_blank"
|
|
||||||
href="https://github.com/kieraneglin/pinchflat"
|
|
||||||
/>
|
|
||||||
<li>
|
<li>
|
||||||
<span
|
<span
|
||||||
class={[
|
class={[
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,17 @@
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section x-data="{ presets: { default: null, media_center: 1, audio: null, archiving: 1 } }">
|
||||||
|
<.input
|
||||||
|
field={f[:redownload_delay_days]}
|
||||||
|
type="number"
|
||||||
|
label="Redownload Delay (days)"
|
||||||
|
min="0"
|
||||||
|
help="Delay in days until new media is redownloaded. Redownloading new media can improve its quality or SponsorBlock tags. Leave blank to not redownload"
|
||||||
|
x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<h3 class="mt-8 text-2xl text-black dark:text-white">
|
<h3 class="mt-8 text-2xl text-black dark:text-white">
|
||||||
Media Center Options
|
Media Center Options
|
||||||
</h3>
|
</h3>
|
||||||
|
|
|
||||||
26
lib/pinchflat_web/controllers/settings/setting_controller.ex
Normal file
26
lib/pinchflat_web/controllers/settings/setting_controller.ex
Normal file
|
|
@ -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
|
||||||
20
lib/pinchflat_web/controllers/settings/setting_html.ex
Normal file
20
lib/pinchflat_web/controllers/settings/setting_html.ex
Normal file
|
|
@ -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>
|
||||||
|
|
@ -63,7 +63,7 @@ defmodule PinchflatWeb.Sources.SourceController do
|
||||||
pending_media =
|
pending_media =
|
||||||
MediaQuery.new()
|
MediaQuery.new()
|
||||||
|> MediaQuery.for_source(source)
|
|> MediaQuery.for_source(source)
|
||||||
|> MediaQuery.with_media_pending_download()
|
|> MediaQuery.where_pending_download()
|
||||||
|> order_by(desc: :id)
|
|> order_by(desc: :id)
|
||||||
|> limit(100)
|
|> limit(100)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ defmodule PinchflatWeb.Router do
|
||||||
|
|
||||||
resources "/media_profiles", MediaProfiles.MediaProfileController
|
resources "/media_profiles", MediaProfiles.MediaProfileController
|
||||||
resources "/search", Searches.SearchController, only: [:show], singleton: true
|
resources "/search", Searches.SearchController, only: [:show], singleton: true
|
||||||
|
resources "/settings", Settings.SettingController, only: [:show, :update], singleton: true
|
||||||
|
|
||||||
resources "/sources", Sources.SourceController do
|
resources "/sources", Sources.SourceController do
|
||||||
post "/force_download", Sources.SourceController, :force_download
|
post "/force_download", Sources.SourceController, :force_download
|
||||||
|
|
|
||||||
2
mix.exs
2
mix.exs
|
|
@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
|
||||||
def project do
|
def project do
|
||||||
[
|
[
|
||||||
app: :pinchflat,
|
app: :pinchflat,
|
||||||
version: "0.1.9",
|
version: "0.1.11",
|
||||||
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,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
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
defmodule Pinchflat.Repo.Migrations.AddRedownloadedFields do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:media_profiles) do
|
||||||
|
add :redownload_delay_days, :integer
|
||||||
|
end
|
||||||
|
|
||||||
|
alter table(:media_items) do
|
||||||
|
add :media_redownloaded_at, :utc_datetime
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -79,7 +79,7 @@ ARG PORT=8945
|
||||||
|
|
||||||
RUN apt-get update -y
|
RUN apt-get update -y
|
||||||
RUN apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
|
RUN apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
|
||||||
ffmpeg curl git openssh-client nano
|
ffmpeg curl git openssh-client nano python3 python3-pip
|
||||||
RUN apt-get clean && rm -f /var/lib/apt/lists/*_*
|
RUN apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||||
|
|
||||||
# Download and update YT-DLP
|
# Download and update YT-DLP
|
||||||
|
|
@ -87,9 +87,11 @@ RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o
|
||||||
RUN chmod a+rx /usr/local/bin/yt-dlp
|
RUN chmod a+rx /usr/local/bin/yt-dlp
|
||||||
RUN yt-dlp -U
|
RUN yt-dlp -U
|
||||||
|
|
||||||
|
# Download Apprise
|
||||||
|
RUN python3 -m pip install -U apprise --break-system-packages
|
||||||
|
|
||||||
# Set the locale
|
# Set the locale
|
||||||
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||||
|
|
||||||
ENV LANG en_US.UTF-8
|
ENV LANG en_US.UTF-8
|
||||||
ENV LANGUAGE en_US:en
|
ENV LANGUAGE en_US:en
|
||||||
ENV LC_ALL en_US.UTF-8
|
ENV LC_ALL en_US.UTF-8
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
defmodule Pinchflat.Boot.PreJobStartupTasksTest do
|
defmodule Pinchflat.Boot.PreJobStartupTasksTest do
|
||||||
use Pinchflat.DataCase
|
use Pinchflat.DataCase
|
||||||
|
|
||||||
|
import Mox
|
||||||
import Pinchflat.JobFixtures
|
import Pinchflat.JobFixtures
|
||||||
|
|
||||||
alias Pinchflat.Settings
|
alias Pinchflat.Settings
|
||||||
alias Pinchflat.Boot.PreJobStartupTasks
|
alias Pinchflat.Boot.PreJobStartupTasks
|
||||||
|
|
||||||
|
setup do
|
||||||
|
stub(YtDlpRunnerMock, :version, fn -> {:ok, "1"} end)
|
||||||
|
stub(AppriseRunnerMock, :version, fn -> {:ok, "2"} end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
describe "reset_executing_jobs" do
|
describe "reset_executing_jobs" do
|
||||||
test "resets executing jobs" do
|
test "resets executing jobs" do
|
||||||
job = job_fixture()
|
job = job_fixture()
|
||||||
|
|
@ -13,13 +21,13 @@ defmodule Pinchflat.Boot.PreJobStartupTasksTest do
|
||||||
|
|
||||||
assert Repo.reload!(job).state == "executing"
|
assert Repo.reload!(job).state == "executing"
|
||||||
|
|
||||||
PreJobStartupTasks.start_link()
|
PreJobStartupTasks.init(%{})
|
||||||
|
|
||||||
assert Repo.reload!(job).state == "retryable"
|
assert Repo.reload!(job).state == "retryable"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "create_blank_cookie_file" do
|
describe "create_blank_yt_dlp_files" do
|
||||||
test "creates a blank cookie file" do
|
test "creates a blank cookie file" do
|
||||||
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||||
filepath = Path.join(base_dir, "cookies.txt")
|
filepath = Path.join(base_dir, "cookies.txt")
|
||||||
|
|
@ -27,21 +35,43 @@ defmodule Pinchflat.Boot.PreJobStartupTasksTest do
|
||||||
|
|
||||||
refute File.exists?(filepath)
|
refute File.exists?(filepath)
|
||||||
|
|
||||||
PreJobStartupTasks.start_link()
|
PreJobStartupTasks.init(%{})
|
||||||
|
|
||||||
|
assert File.exists?(filepath)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "creates a blank yt-dlp config file" do
|
||||||
|
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||||
|
filepath = Path.join([base_dir, "yt-dlp-configs", "base-config.txt"])
|
||||||
|
File.rm(filepath)
|
||||||
|
|
||||||
|
refute File.exists?(filepath)
|
||||||
|
|
||||||
|
PreJobStartupTasks.init(%{})
|
||||||
|
|
||||||
assert File.exists?(filepath)
|
assert File.exists?(filepath)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "apply_default_settings" do
|
describe "apply_default_settings" do
|
||||||
test "sets default settings" do
|
test "sets yt_dlp version" do
|
||||||
Settings.set(yt_dlp_version: nil)
|
Settings.set(yt_dlp_version: nil)
|
||||||
|
|
||||||
refute Settings.get!(:yt_dlp_version)
|
refute Settings.get!(:yt_dlp_version)
|
||||||
|
|
||||||
PreJobStartupTasks.start_link()
|
PreJobStartupTasks.init(%{})
|
||||||
|
|
||||||
assert Settings.get!(:yt_dlp_version)
|
assert Settings.get!(:yt_dlp_version)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "sets apprise version" do
|
||||||
|
Settings.set(apprise_version: nil)
|
||||||
|
|
||||||
|
refute Settings.get!(:apprise_version)
|
||||||
|
|
||||||
|
PreJobStartupTasks.init(%{})
|
||||||
|
|
||||||
|
assert Settings.get!(:apprise_version)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
||||||
import Pinchflat.ProfilesFixtures
|
import Pinchflat.ProfilesFixtures
|
||||||
|
|
||||||
alias Pinchflat.Profiles
|
alias Pinchflat.Profiles
|
||||||
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
alias Pinchflat.Downloading.DownloadOptionBuilder
|
alias Pinchflat.Downloading.DownloadOptionBuilder
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
|
|
@ -261,6 +262,96 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "build/1 when testing config file options" do
|
||||||
|
setup do
|
||||||
|
base_dir = Path.join(Application.get_env(:pinchflat, :extras_directory), "yt-dlp-configs")
|
||||||
|
|
||||||
|
{:ok, %{base_dir: base_dir}}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "includes base config file if it's present", %{media_item: media_item, base_dir: base_dir} do
|
||||||
|
filepath = Path.join(base_dir, "base-config.txt")
|
||||||
|
|
||||||
|
FilesystemUtils.write_p!(filepath, "base config")
|
||||||
|
|
||||||
|
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||||
|
assert {:config_locations, filepath} in res
|
||||||
|
end
|
||||||
|
|
||||||
|
test "includes media profile config file if it's present", %{media_item: media_item, base_dir: base_dir} do
|
||||||
|
media_profile = media_item.source.media_profile
|
||||||
|
filepath = Path.join(base_dir, "media-profile-#{media_profile.id}-config.txt")
|
||||||
|
|
||||||
|
FilesystemUtils.write_p!(filepath, "profile config")
|
||||||
|
|
||||||
|
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||||
|
assert {:config_locations, filepath} in res
|
||||||
|
end
|
||||||
|
|
||||||
|
test "includes source config file if it's present", %{media_item: media_item, base_dir: base_dir} do
|
||||||
|
source = media_item.source
|
||||||
|
filepath = Path.join(base_dir, "source-#{source.id}-config.txt")
|
||||||
|
|
||||||
|
FilesystemUtils.write_p!(filepath, "profile config")
|
||||||
|
|
||||||
|
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||||
|
assert {:config_locations, filepath} in res
|
||||||
|
end
|
||||||
|
|
||||||
|
test "includes media item config file if it's present", %{media_item: media_item, base_dir: base_dir} do
|
||||||
|
filepath = Path.join(base_dir, "media-item-#{media_item.id}-config.txt")
|
||||||
|
|
||||||
|
FilesystemUtils.write_p!(filepath, "media item config")
|
||||||
|
|
||||||
|
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||||
|
assert {:config_locations, filepath} in res
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not include config file options if they are not present", %{media_item: media_item} do
|
||||||
|
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||||
|
|
||||||
|
refute :config_locations in res
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not return a config file if it's blank", %{media_item: media_item, base_dir: base_dir} do
|
||||||
|
filepath = Path.join(base_dir, "base-config.txt")
|
||||||
|
|
||||||
|
FilesystemUtils.write_p!(filepath, " \n \n ")
|
||||||
|
|
||||||
|
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||||
|
refute :config_locations in res
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns config files in order of precedence", %{media_item: media_item, base_dir: base_dir} do
|
||||||
|
source = media_item.source
|
||||||
|
media_profile = source.media_profile
|
||||||
|
|
||||||
|
base_filepath = Path.join(base_dir, "base-config.txt")
|
||||||
|
source_filepath = Path.join(base_dir, "source-#{source.id}-config.txt")
|
||||||
|
media_item_filepath = Path.join(base_dir, "media-item-#{media_item.id}-config.txt")
|
||||||
|
media_profile_filepath = Path.join(base_dir, "media-profile-#{media_profile.id}-config.txt")
|
||||||
|
|
||||||
|
FilesystemUtils.write_p!(base_filepath, "config")
|
||||||
|
FilesystemUtils.write_p!(source_filepath, "config")
|
||||||
|
FilesystemUtils.write_p!(media_item_filepath, "config")
|
||||||
|
FilesystemUtils.write_p!(media_profile_filepath, "config")
|
||||||
|
|
||||||
|
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||||
|
|
||||||
|
expected_order = [
|
||||||
|
{:config_locations, base_filepath},
|
||||||
|
{:config_locations, media_profile_filepath},
|
||||||
|
{:config_locations, source_filepath},
|
||||||
|
{:config_locations, media_item_filepath}
|
||||||
|
]
|
||||||
|
|
||||||
|
assert Enum.filter(res, fn
|
||||||
|
{:config_locations, _} -> true
|
||||||
|
_ -> false
|
||||||
|
end) == expected_order
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "build_output_path_for/1" do
|
describe "build_output_path_for/1" do
|
||||||
test "builds an output path for a source", %{media_item: media_item} do
|
test "builds an output path for a source", %{media_item: media_item} do
|
||||||
path = DownloadOptionBuilder.build_output_path_for(media_item.source)
|
path = DownloadOptionBuilder.build_output_path_for(media_item.source)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
||||||
|
|
||||||
alias Pinchflat.Media
|
alias Pinchflat.Media
|
||||||
alias Pinchflat.Sources
|
alias Pinchflat.Sources
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||||
|
|
||||||
setup :verify_on_exit!
|
setup :verify_on_exit!
|
||||||
|
|
@ -62,7 +62,9 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
||||||
|
|
||||||
assert media_item.media_filepath == nil
|
assert media_item.media_filepath == nil
|
||||||
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
||||||
assert Repo.reload(media_item).media_filepath != nil
|
media_item = Repo.reload(media_item)
|
||||||
|
|
||||||
|
assert media_item.media_filepath != nil
|
||||||
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
|
||||||
|
|
@ -138,7 +140,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
||||||
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, _addl ->
|
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")
|
FilesystemUtils.write_p!(metadata["filepath"], "test")
|
||||||
|
|
||||||
{:ok, Phoenix.json_library().encode!(metadata)}
|
{:ok, Phoenix.json_library().encode!(metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
@ -149,6 +151,28 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
||||||
assert media_item.media_size_bytes > 0
|
assert media_item.media_size_bytes > 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "saves redownloaded_at if this is for a redownload", %{media_item: media_item} do
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
|
{:ok, render_metadata(:media_metadata)}
|
||||||
|
end)
|
||||||
|
|
||||||
|
perform_job(MediaDownloadWorker, %{id: media_item.id, redownload?: true})
|
||||||
|
media_item = Repo.reload(media_item)
|
||||||
|
|
||||||
|
assert media_item.media_redownloaded_at != nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "doesn't save redownloaded_at if this is not for a redownload", %{media_item: media_item} do
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||||
|
{:ok, render_metadata(:media_metadata)}
|
||||||
|
end)
|
||||||
|
|
||||||
|
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
||||||
|
media_item = Repo.reload(media_item)
|
||||||
|
|
||||||
|
assert media_item.media_redownloaded_at == nil
|
||||||
|
end
|
||||||
|
|
||||||
test "does not blow up if the record doesn't exist" do
|
test "does not blow up if the record doesn't exist" do
|
||||||
assert :ok = perform_job(MediaDownloadWorker, %{id: 0})
|
assert :ok = perform_job(MediaDownloadWorker, %{id: 0})
|
||||||
end
|
end
|
||||||
|
|
|
||||||
44
test/pinchflat/downloading/media_redownload_worker_test.exs
Normal file
44
test/pinchflat/downloading/media_redownload_worker_test.exs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
defmodule Pinchflat.Downloading.MediaRedownloadWorkerTest do
|
||||||
|
use Pinchflat.DataCase
|
||||||
|
|
||||||
|
import Pinchflat.MediaFixtures
|
||||||
|
import Pinchflat.SourcesFixtures
|
||||||
|
import Pinchflat.ProfilesFixtures
|
||||||
|
|
||||||
|
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||||
|
alias Pinchflat.Downloading.MediaRedownloadWorker
|
||||||
|
|
||||||
|
describe "perform/1" do
|
||||||
|
test "kicks off a task for redownloadable media items" do
|
||||||
|
media_profile = media_profile_fixture(%{redownload_delay_days: 4})
|
||||||
|
source = source_fixture(%{media_profile_id: media_profile.id, inserted_at: now_minus(10, :days)})
|
||||||
|
|
||||||
|
media_item =
|
||||||
|
media_item_fixture(%{
|
||||||
|
source_id: source.id,
|
||||||
|
upload_date: now_minus(6, :days),
|
||||||
|
media_downloaded_at: now_minus(5, :days)
|
||||||
|
})
|
||||||
|
|
||||||
|
perform_job(MediaRedownloadWorker, %{})
|
||||||
|
|
||||||
|
assert [_] = all_enqueued(worker: MediaDownloadWorker, args: %{id: media_item.id, redownload?: true})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not kickoff a task for non-redownloadable media items" do
|
||||||
|
media_profile = media_profile_fixture(%{redownload_delay_days: 4})
|
||||||
|
source = source_fixture(%{media_profile_id: media_profile.id, inserted_at: now_minus(10, :days)})
|
||||||
|
|
||||||
|
_media_item =
|
||||||
|
media_item_fixture(%{
|
||||||
|
source_id: source.id,
|
||||||
|
upload_date: now_minus(6, :days),
|
||||||
|
media_downloaded_at: now_minus(1, :day)
|
||||||
|
})
|
||||||
|
|
||||||
|
perform_job(MediaRedownloadWorker, %{})
|
||||||
|
|
||||||
|
assert [] = all_enqueued(worker: MediaDownloadWorker)
|
||||||
|
end
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,131 @@ defmodule Pinchflat.MediaTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "list_redownloadable_media_items/0" do
|
||||||
|
setup do
|
||||||
|
media_profile = media_profile_fixture(%{redownload_delay_days: 4})
|
||||||
|
source = source_fixture(%{media_profile_id: media_profile.id, inserted_at: now_minus(10, :days)})
|
||||||
|
|
||||||
|
{:ok, %{media_profile: media_profile, source: source}}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns media eligible for redownload", %{source: source} do
|
||||||
|
media_item =
|
||||||
|
media_item_fixture(%{
|
||||||
|
source_id: source.id,
|
||||||
|
upload_date: now_minus(6, :days),
|
||||||
|
media_downloaded_at: now_minus(5, :days)
|
||||||
|
})
|
||||||
|
|
||||||
|
assert Media.list_redownloadable_media_items() == [media_item]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns media items that were downloaded in past but still meet redownload delay", %{source: source} do
|
||||||
|
media_item =
|
||||||
|
media_item_fixture(%{
|
||||||
|
source_id: source.id,
|
||||||
|
upload_date: now_minus(20, :days),
|
||||||
|
media_downloaded_at: now_minus(19, :days)
|
||||||
|
})
|
||||||
|
|
||||||
|
assert Media.list_redownloadable_media_items() == [media_item]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not return media items without a media_downloaded_at", %{source: source} do
|
||||||
|
_media_item =
|
||||||
|
media_item_fixture(%{
|
||||||
|
source_id: source.id,
|
||||||
|
upload_date: now_minus(5, :days),
|
||||||
|
media_downloaded_at: nil
|
||||||
|
})
|
||||||
|
|
||||||
|
assert Media.list_redownloadable_media_items() == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not return media items that are set to prevent download", %{source: source} do
|
||||||
|
_media_item =
|
||||||
|
media_item_fixture(%{
|
||||||
|
source_id: source.id,
|
||||||
|
upload_date: now_minus(5, :days),
|
||||||
|
media_downloaded_at: now(),
|
||||||
|
prevent_download: true
|
||||||
|
})
|
||||||
|
|
||||||
|
assert Media.list_redownloadable_media_items() == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not return media items that have been culled", %{source: source} do
|
||||||
|
_media_item =
|
||||||
|
media_item_fixture(%{
|
||||||
|
source_id: source.id,
|
||||||
|
upload_date: now_minus(5, :days),
|
||||||
|
media_downloaded_at: now(),
|
||||||
|
culled_at: now()
|
||||||
|
})
|
||||||
|
|
||||||
|
assert Media.list_redownloadable_media_items() == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not return media items before the download delay", %{source: source} do
|
||||||
|
_media_item =
|
||||||
|
media_item_fixture(%{
|
||||||
|
source_id: source.id,
|
||||||
|
upload_date: now_minus(3, :days),
|
||||||
|
media_downloaded_at: now_minus(3, :days)
|
||||||
|
})
|
||||||
|
|
||||||
|
assert Media.list_redownloadable_media_items() == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not return media items that have already been redownloaded", %{source: source} do
|
||||||
|
_media_item =
|
||||||
|
media_item_fixture(%{
|
||||||
|
source_id: source.id,
|
||||||
|
upload_date: now_minus(5, :days),
|
||||||
|
media_downloaded_at: now(),
|
||||||
|
media_redownloaded_at: now()
|
||||||
|
})
|
||||||
|
|
||||||
|
assert Media.list_redownloadable_media_items() == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not return media items that were first downloaded well after the upload_date", %{source: source} do
|
||||||
|
_media_item =
|
||||||
|
media_item_fixture(%{
|
||||||
|
source_id: source.id,
|
||||||
|
media_downloaded_at: now(),
|
||||||
|
upload_date: now_minus(20, :days)
|
||||||
|
})
|
||||||
|
|
||||||
|
assert Media.list_redownloadable_media_items() == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not return media items that were recently uploaded", %{source: source} do
|
||||||
|
_media_item =
|
||||||
|
media_item_fixture(%{
|
||||||
|
source_id: source.id,
|
||||||
|
media_downloaded_at: now(),
|
||||||
|
upload_date: now_minus(2, :days)
|
||||||
|
})
|
||||||
|
|
||||||
|
assert Media.list_redownloadable_media_items() == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not return media items without a redownload delay" do
|
||||||
|
media_profile = media_profile_fixture(%{redownload_delay_days: nil})
|
||||||
|
source = source_fixture(%{media_profile_id: media_profile.id})
|
||||||
|
|
||||||
|
_media_item =
|
||||||
|
media_item_fixture(%{
|
||||||
|
source_id: source.id,
|
||||||
|
upload_date: now_minus(6, :days),
|
||||||
|
media_downloaded_at: now_minus(5, :days)
|
||||||
|
})
|
||||||
|
|
||||||
|
assert Media.list_redownloadable_media_items() == []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "list_pending_media_items_for/1" do
|
describe "list_pending_media_items_for/1" do
|
||||||
test "it returns pending without a filepath for a given source" do
|
test "it returns pending without a filepath for a given source" do
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@ defmodule Pinchflat.Metadata.NfoBuilderTest do
|
||||||
use Pinchflat.DataCase
|
use Pinchflat.DataCase
|
||||||
|
|
||||||
alias Pinchflat.Metadata.NfoBuilder
|
alias Pinchflat.Metadata.NfoBuilder
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
filepath = FilesystemHelpers.generate_metadata_tmpfile(:json)
|
filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||||
|
|
||||||
on_exit(fn -> File.rm!(filepath) end)
|
on_exit(fn -> File.rm!(filepath) end)
|
||||||
|
|
||||||
|
|
|
||||||
65
test/pinchflat/notifications/command_runner_test.exs
Normal file
65
test/pinchflat/notifications/command_runner_test.exs
Normal file
|
|
@ -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
|
||||||
100
test/pinchflat/notifications/source_notifications_test.exs
Normal file
100
test/pinchflat/notifications/source_notifications_test.exs
Normal file
|
|
@ -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
|
||||||
|
|
@ -24,6 +24,16 @@ defmodule Pinchflat.SettingsTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "update_setting/2" do
|
||||||
|
test "updates the setting" do
|
||||||
|
setting = Settings.record()
|
||||||
|
|
||||||
|
assert {:ok, false} = Settings.get(:onboarding)
|
||||||
|
assert {:ok, %Setting{}} = Settings.update_setting(setting, %{onboarding: true})
|
||||||
|
assert {:ok, true} = Settings.get(:onboarding)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "set/1" do
|
describe "set/1" do
|
||||||
test "updates the setting" do
|
test "updates the setting" do
|
||||||
assert {:ok, true} = Settings.set(onboarding: true)
|
assert {:ok, true} = Settings.set(onboarding: true)
|
||||||
|
|
@ -60,4 +70,12 @@ defmodule Pinchflat.SettingsTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "change_setting/2" do
|
||||||
|
test "returns a changeset" do
|
||||||
|
setting = Settings.record()
|
||||||
|
|
||||||
|
assert %Ecto.Changeset{} = Settings.change_setting(setting, %{onboarding: true})
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
defmodule Pinchflat.SlowIndexing.FileFollowerServerTest do
|
defmodule Pinchflat.SlowIndexing.FileFollowerServerTest do
|
||||||
use ExUnit.Case, async: true
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
alias alias Pinchflat.Filesystem.FilesystemHelpers
|
alias alias Pinchflat.Utils.FilesystemUtils
|
||||||
alias Pinchflat.SlowIndexing.FileFollowerServer
|
alias Pinchflat.SlowIndexing.FileFollowerServer
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
{:ok, pid} = FileFollowerServer.start_link()
|
{:ok, pid} = FileFollowerServer.start_link()
|
||||||
tmpfile = FilesystemHelpers.generate_metadata_tmpfile(:txt)
|
tmpfile = FilesystemUtils.generate_metadata_tmpfile(:txt)
|
||||||
|
|
||||||
{:ok, %{pid: pid, tmpfile: tmpfile}}
|
{:ok, %{pid: pid, tmpfile: tmpfile}}
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
||||||
import Pinchflat.SourcesFixtures
|
import Pinchflat.SourcesFixtures
|
||||||
|
|
||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
|
alias Pinchflat.Settings
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.FastIndexing.FastIndexingWorker
|
alias Pinchflat.FastIndexing.FastIndexingWorker
|
||||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||||
|
|
@ -51,6 +52,12 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "perform/1" do
|
describe "perform/1" do
|
||||||
|
setup do
|
||||||
|
stub(AppriseRunnerMock, :run, fn _, _ -> {:ok, ""} end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
test "it indexes the source if it should be indexed" do
|
test "it indexes the source if it should be indexed" do
|
||||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
|
||||||
|
|
||||||
|
|
@ -210,4 +217,30 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
|
||||||
assert :ok = perform_job(MediaCollectionIndexingWorker, %{id: 0})
|
assert :ok = perform_job(MediaCollectionIndexingWorker, %{id: 0})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "perform/1 when testing apprise notifications" do
|
||||||
|
setup do
|
||||||
|
Settings.set(apprise_server: "server_1")
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sends a notification if new media was found" do
|
||||||
|
source = source_fixture()
|
||||||
|
|
||||||
|
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts ->
|
||||||
|
{:ok, source_attributes_return_fixture()}
|
||||||
|
end)
|
||||||
|
|
||||||
|
expect(AppriseRunnerMock, :run, fn servers, opts ->
|
||||||
|
assert "server_1" = servers
|
||||||
|
assert is_binary(Keyword.get(opts, :title))
|
||||||
|
assert is_binary(Keyword.get(opts, :body))
|
||||||
|
|
||||||
|
{:ok, ""}
|
||||||
|
end)
|
||||||
|
|
||||||
|
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ defmodule Pinchflat.SourcesTest do
|
||||||
|
|
||||||
alias Pinchflat.Sources
|
alias Pinchflat.Sources
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
alias Pinchflat.Metadata.MetadataFileHelpers
|
alias Pinchflat.Metadata.MetadataFileHelpers
|
||||||
alias Pinchflat.Downloading.DownloadingHelpers
|
alias Pinchflat.Downloading.DownloadingHelpers
|
||||||
alias Pinchflat.FastIndexing.FastIndexingWorker
|
alias Pinchflat.FastIndexing.FastIndexingWorker
|
||||||
|
|
@ -561,7 +561,7 @@ defmodule Pinchflat.SourcesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not delete the source's non-metadata files" do
|
test "does not delete the source's non-metadata files" do
|
||||||
filepath = FilesystemHelpers.generate_metadata_tmpfile(:nfo)
|
filepath = FilesystemUtils.generate_metadata_tmpfile(:nfo)
|
||||||
source = source_fixture(%{nfo_filepath: filepath})
|
source = source_fixture(%{nfo_filepath: filepath})
|
||||||
|
|
||||||
assert {:ok, _} = Sources.delete_source(source)
|
assert {:ok, _} = Sources.delete_source(source)
|
||||||
|
|
@ -592,7 +592,7 @@ defmodule Pinchflat.SourcesTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "deletes the source's non-metadata files" do
|
test "deletes the source's non-metadata files" do
|
||||||
filepath = FilesystemHelpers.generate_metadata_tmpfile(:nfo)
|
filepath = FilesystemUtils.generate_metadata_tmpfile(:nfo)
|
||||||
source = source_fixture(%{nfo_filepath: filepath})
|
source = source_fixture(%{nfo_filepath: filepath})
|
||||||
|
|
||||||
assert {:ok, _} = Sources.delete_source(source, delete_files: true)
|
assert {:ok, _} = Sources.delete_source(source, delete_files: true)
|
||||||
|
|
|
||||||
23
test/pinchflat/utils/cli_utils_test.exs
Normal file
23
test/pinchflat/utils/cli_utils_test.exs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
defmodule Pinchflat.Utils.CliUtilsTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Pinchflat.Utils.CliUtils
|
||||||
|
|
||||||
|
describe "parse_options/1" do
|
||||||
|
test "it converts symbol k-v arg keys to kebab case" do
|
||||||
|
assert ["--buffer-size", "1024"] = CliUtils.parse_options(buffer_size: 1024)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it keeps string k-v arg keys untouched" do
|
||||||
|
assert ["--under_score", "1024"] = CliUtils.parse_options({"--under_score", 1024})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it converts symbol arg keys to kebab case" do
|
||||||
|
assert ["--ignore-errors"] = CliUtils.parse_options(:ignore_errors)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it keeps string arg keys untouched" do
|
||||||
|
assert ["-v"] = CliUtils.parse_options("-v")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
defmodule Pinchflat.Filesystem.FilesystemHelpersTest do
|
defmodule Pinchflat.Utils.FilesystemUtilsTest do
|
||||||
use Pinchflat.DataCase
|
use Pinchflat.DataCase
|
||||||
|
|
||||||
import Pinchflat.MediaFixtures
|
import Pinchflat.MediaFixtures
|
||||||
|
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
|
|
||||||
describe "generate_metadata_tmpfile/1" do
|
describe "generate_metadata_tmpfile/1" do
|
||||||
test "creates a tmpfile and returns its path" do
|
test "creates a tmpfile and returns its path" do
|
||||||
res = FilesystemHelpers.generate_metadata_tmpfile(:json)
|
res = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||||
|
|
||||||
assert String.ends_with?(res, ".json")
|
assert String.ends_with?(res, ".json")
|
||||||
assert File.exists?(res)
|
assert File.exists?(res)
|
||||||
|
|
@ -22,7 +22,7 @@ defmodule Pinchflat.Filesystem.FilesystemHelpersTest do
|
||||||
|
|
||||||
refute media_item.media_size_bytes
|
refute media_item.media_size_bytes
|
||||||
|
|
||||||
assert {:ok, media_item} = FilesystemHelpers.compute_and_save_media_filesize(media_item)
|
assert {:ok, media_item} = FilesystemUtils.compute_and_save_media_filesize(media_item)
|
||||||
|
|
||||||
assert Repo.reload!(media_item).media_size_bytes
|
assert Repo.reload!(media_item).media_size_bytes
|
||||||
end
|
end
|
||||||
|
|
@ -30,16 +30,16 @@ defmodule Pinchflat.Filesystem.FilesystemHelpersTest do
|
||||||
test "returns the error if operation fails" do
|
test "returns the error if operation fails" do
|
||||||
media_item = media_item_fixture(%{media_filepath: "/nonexistent/file.mkv"})
|
media_item = media_item_fixture(%{media_filepath: "/nonexistent/file.mkv"})
|
||||||
|
|
||||||
assert {:error, _} = FilesystemHelpers.compute_and_save_media_filesize(media_item)
|
assert {:error, _} = FilesystemUtils.compute_and_save_media_filesize(media_item)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "write_p/3" do
|
describe "write_p/3" do
|
||||||
test "writes content to a file" do
|
test "writes content to a file" do
|
||||||
filepath = FilesystemHelpers.generate_metadata_tmpfile(:json)
|
filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||||
content = "{}"
|
content = "{}"
|
||||||
|
|
||||||
assert :ok = FilesystemHelpers.write_p(filepath, content)
|
assert :ok = FilesystemUtils.write_p(filepath, content)
|
||||||
assert File.read!(filepath) == content
|
assert File.read!(filepath) == content
|
||||||
|
|
||||||
File.rm!(filepath)
|
File.rm!(filepath)
|
||||||
|
|
@ -50,7 +50,7 @@ defmodule Pinchflat.Filesystem.FilesystemHelpersTest do
|
||||||
filepath = Path.join([tmpfile_directory, "foo", "bar", "file.json"])
|
filepath = Path.join([tmpfile_directory, "foo", "bar", "file.json"])
|
||||||
content = "{}"
|
content = "{}"
|
||||||
|
|
||||||
assert :ok = FilesystemHelpers.write_p(filepath, content)
|
assert :ok = FilesystemUtils.write_p(filepath, content)
|
||||||
assert File.read!(filepath) == content
|
assert File.read!(filepath) == content
|
||||||
|
|
||||||
File.rm!(filepath)
|
File.rm!(filepath)
|
||||||
|
|
@ -59,10 +59,10 @@ defmodule Pinchflat.Filesystem.FilesystemHelpersTest do
|
||||||
|
|
||||||
describe "write_p!/3" do
|
describe "write_p!/3" do
|
||||||
test "writes content to a file" do
|
test "writes content to a file" do
|
||||||
filepath = FilesystemHelpers.generate_metadata_tmpfile(:json)
|
filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||||
content = "{}"
|
content = "{}"
|
||||||
|
|
||||||
assert :ok = FilesystemHelpers.write_p!(filepath, content)
|
assert :ok = FilesystemUtils.write_p!(filepath, content)
|
||||||
assert File.read!(filepath) == content
|
assert File.read!(filepath) == content
|
||||||
|
|
||||||
File.rm!(filepath)
|
File.rm!(filepath)
|
||||||
|
|
@ -73,7 +73,7 @@ defmodule Pinchflat.Filesystem.FilesystemHelpersTest do
|
||||||
filepath = Path.join([tmpfile_directory, "foo", "bar", "file.json"])
|
filepath = Path.join([tmpfile_directory, "foo", "bar", "file.json"])
|
||||||
content = "{}"
|
content = "{}"
|
||||||
|
|
||||||
assert :ok = FilesystemHelpers.write_p!(filepath, content)
|
assert :ok = FilesystemUtils.write_p!(filepath, content)
|
||||||
assert File.read!(filepath) == content
|
assert File.read!(filepath) == content
|
||||||
|
|
||||||
File.rm!(filepath)
|
File.rm!(filepath)
|
||||||
|
|
@ -82,11 +82,11 @@ defmodule Pinchflat.Filesystem.FilesystemHelpersTest do
|
||||||
|
|
||||||
describe "delete_file_and_remove_empty_directories/1" do
|
describe "delete_file_and_remove_empty_directories/1" do
|
||||||
test "deletes file at the provided filepath" do
|
test "deletes file at the provided filepath" do
|
||||||
filepath = FilesystemHelpers.generate_metadata_tmpfile(:json)
|
filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||||
|
|
||||||
assert File.exists?(filepath)
|
assert File.exists?(filepath)
|
||||||
|
|
||||||
assert :ok = FilesystemHelpers.delete_file_and_remove_empty_directories(filepath)
|
assert :ok = FilesystemUtils.delete_file_and_remove_empty_directories(filepath)
|
||||||
|
|
||||||
refute File.exists?(filepath)
|
refute File.exists?(filepath)
|
||||||
end
|
end
|
||||||
|
|
@ -94,9 +94,9 @@ defmodule Pinchflat.Filesystem.FilesystemHelpersTest do
|
||||||
test "deletes empty directories" do
|
test "deletes empty directories" do
|
||||||
tmpfile_directory = Application.get_env(:pinchflat, :tmpfile_directory)
|
tmpfile_directory = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||||
filepath = Path.join([tmpfile_directory, "foo", "bar", "baz", "qux.json"])
|
filepath = Path.join([tmpfile_directory, "foo", "bar", "baz", "qux.json"])
|
||||||
FilesystemHelpers.write_p!(filepath, "")
|
FilesystemUtils.write_p!(filepath, "")
|
||||||
|
|
||||||
assert :ok = FilesystemHelpers.delete_file_and_remove_empty_directories(filepath)
|
assert :ok = FilesystemUtils.delete_file_and_remove_empty_directories(filepath)
|
||||||
|
|
||||||
refute File.exists?(filepath)
|
refute File.exists?(filepath)
|
||||||
refute File.exists?(Path.join([tmpfile_directory, "foo", "bar", "baz"]))
|
refute File.exists?(Path.join([tmpfile_directory, "foo", "bar", "baz"]))
|
||||||
|
|
@ -108,10 +108,10 @@ defmodule Pinchflat.Filesystem.FilesystemHelpersTest do
|
||||||
tmpfile_directory = Application.get_env(:pinchflat, :tmpfile_directory)
|
tmpfile_directory = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||||
filepath_1 = Path.join([tmpfile_directory, "foo", "bar", "baz", "qux.json"])
|
filepath_1 = Path.join([tmpfile_directory, "foo", "bar", "baz", "qux.json"])
|
||||||
filepath_2 = Path.join([tmpfile_directory, "foo", "baz.json"])
|
filepath_2 = Path.join([tmpfile_directory, "foo", "baz.json"])
|
||||||
FilesystemHelpers.write_p!(filepath_1, "")
|
FilesystemUtils.write_p!(filepath_1, "")
|
||||||
FilesystemHelpers.write_p!(filepath_2, "")
|
FilesystemUtils.write_p!(filepath_2, "")
|
||||||
|
|
||||||
assert :ok = FilesystemHelpers.delete_file_and_remove_empty_directories(filepath_1)
|
assert :ok = FilesystemUtils.delete_file_and_remove_empty_directories(filepath_1)
|
||||||
|
|
||||||
refute File.exists?(filepath_1)
|
refute File.exists?(filepath_1)
|
||||||
refute File.exists?(Path.join([tmpfile_directory, "foo", "bar", "baz"]))
|
refute File.exists?(Path.join([tmpfile_directory, "foo", "bar", "baz"]))
|
||||||
|
|
@ -121,24 +121,24 @@ defmodule Pinchflat.Filesystem.FilesystemHelpersTest do
|
||||||
assert File.exists?(Path.join([tmpfile_directory, "foo"]))
|
assert File.exists?(Path.join([tmpfile_directory, "foo"]))
|
||||||
|
|
||||||
# cleanup
|
# cleanup
|
||||||
FilesystemHelpers.delete_file_and_remove_empty_directories(filepath_2)
|
FilesystemUtils.delete_file_and_remove_empty_directories(filepath_2)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns an error if file could not be deleted" do
|
test "returns an error if file could not be deleted" do
|
||||||
filepath = "/nonexistent/file.json"
|
filepath = "/nonexistent/file.json"
|
||||||
|
|
||||||
assert {:error, _} = FilesystemHelpers.delete_file_and_remove_empty_directories(filepath)
|
assert {:error, _} = FilesystemUtils.delete_file_and_remove_empty_directories(filepath)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "cp_p!/2" do
|
describe "cp_p!/2" do
|
||||||
test "copies a file from source to destination" do
|
test "copies a file from source to destination" do
|
||||||
source = "#{tmpfile_directory()}/source.json"
|
source = "#{tmpfile_directory()}/source.json"
|
||||||
FilesystemHelpers.write_p!(source, "TEST")
|
FilesystemUtils.write_p!(source, "TEST")
|
||||||
destination = "#{tmpfile_directory()}/destination.json"
|
destination = "#{tmpfile_directory()}/destination.json"
|
||||||
|
|
||||||
refute File.exists?(destination)
|
refute File.exists?(destination)
|
||||||
FilesystemHelpers.cp_p!(source, destination)
|
FilesystemUtils.cp_p!(source, destination)
|
||||||
assert File.exists?(destination)
|
assert File.exists?(destination)
|
||||||
assert File.read!(destination) == "TEST"
|
assert File.read!(destination) == "TEST"
|
||||||
|
|
||||||
|
|
@ -148,11 +148,11 @@ defmodule Pinchflat.Filesystem.FilesystemHelpersTest do
|
||||||
|
|
||||||
test "creates directories as needed" do
|
test "creates directories as needed" do
|
||||||
source = "#{tmpfile_directory()}/source.json"
|
source = "#{tmpfile_directory()}/source.json"
|
||||||
FilesystemHelpers.write_p!(source, "TEST")
|
FilesystemUtils.write_p!(source, "TEST")
|
||||||
destination = "#{tmpfile_directory()}/foo/bar/destination.json"
|
destination = "#{tmpfile_directory()}/foo/bar/destination.json"
|
||||||
|
|
||||||
refute File.exists?(destination)
|
refute File.exists?(destination)
|
||||||
FilesystemHelpers.cp_p!(source, destination)
|
FilesystemUtils.cp_p!(source, destination)
|
||||||
assert File.exists?(destination)
|
assert File.exists?(destination)
|
||||||
|
|
||||||
File.rm!(source)
|
File.rm!(source)
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
defmodule Pinchflat.YtDlp.CommandRunnerTest do
|
defmodule Pinchflat.YtDlp.CommandRunnerTest do
|
||||||
use ExUnit.Case, async: true
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
|
|
||||||
alias Pinchflat.YtDlp.CommandRunner, as: Runner
|
alias Pinchflat.YtDlp.CommandRunner, as: Runner
|
||||||
|
|
||||||
|
|
@ -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], "")
|
||||||
|
|
||||||
|
|
@ -67,16 +42,17 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "run/4 when testing cookie options" do
|
describe "run/4 when testing external file options" do
|
||||||
setup do
|
setup 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")
|
||||||
|
yt_dlp_file = Path.join([base_dir, "yt-dlp-configs", "main.txt"])
|
||||||
|
|
||||||
{:ok, cookie_file: cookie_file}
|
{:ok, cookie_file: cookie_file, yt_dlp_file: yt_dlp_file}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "includes cookie options when cookies.txt exists", %{cookie_file: cookie_file} do
|
test "includes cookie options when cookies.txt exists", %{cookie_file: cookie_file} do
|
||||||
FilesystemHelpers.write_p!(cookie_file, "cookie data")
|
FilesystemUtils.write_p!(cookie_file, "cookie data")
|
||||||
|
|
||||||
assert {:ok, output} = Runner.run(@media_url, [], "")
|
assert {:ok, output} = Runner.run(@media_url, [], "")
|
||||||
|
|
||||||
|
|
@ -84,7 +60,7 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "doesn't include cookie options when cookies.txt blank", %{cookie_file: cookie_file} do
|
test "doesn't include cookie options when cookies.txt blank", %{cookie_file: cookie_file} do
|
||||||
FilesystemHelpers.write_p!(cookie_file, " \n \n ")
|
FilesystemUtils.write_p!(cookie_file, " \n \n ")
|
||||||
|
|
||||||
assert {:ok, output} = Runner.run(@media_url, [], "")
|
assert {:ok, output} = Runner.run(@media_url, [], "")
|
||||||
|
|
||||||
|
|
|
||||||
23
test/pinchflat_web/controllers/setting_controller_test.exs
Normal file
23
test/pinchflat_web/controllers/setting_controller_test.exs
Normal file
|
|
@ -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
|
||||||
|
|
@ -5,7 +5,7 @@ defmodule Pinchflat.MediaFixtures do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Pinchflat.SourcesFixtures
|
alias Pinchflat.SourcesFixtures
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Generate a media_item.
|
Generate a media_item.
|
||||||
|
|
@ -52,8 +52,8 @@ defmodule Pinchflat.MediaFixtures do
|
||||||
json_gz_filepath = Path.join(metadata_dir, "metadata.json.gz")
|
json_gz_filepath = Path.join(metadata_dir, "metadata.json.gz")
|
||||||
thumbnail_filepath = Path.join(metadata_dir, "thumbnail.jpg")
|
thumbnail_filepath = Path.join(metadata_dir, "thumbnail.jpg")
|
||||||
|
|
||||||
FilesystemHelpers.cp_p!(media_metadata_filepath_fixture(), json_gz_filepath)
|
FilesystemUtils.cp_p!(media_metadata_filepath_fixture(), json_gz_filepath)
|
||||||
FilesystemHelpers.cp_p!(thumbnail_filepath_fixture(), thumbnail_filepath)
|
FilesystemUtils.cp_p!(thumbnail_filepath_fixture(), thumbnail_filepath)
|
||||||
|
|
||||||
merged_attrs =
|
merged_attrs =
|
||||||
Map.merge(attrs, %{
|
Map.merge(attrs, %{
|
||||||
|
|
@ -74,7 +74,7 @@ defmodule Pinchflat.MediaFixtures do
|
||||||
"#{:rand.uniform(1_000_000)}_media.mp4"
|
"#{:rand.uniform(1_000_000)}_media.mp4"
|
||||||
])
|
])
|
||||||
|
|
||||||
FilesystemHelpers.cp_p!(media_filepath_fixture(), stored_media_filepath)
|
FilesystemUtils.cp_p!(media_filepath_fixture(), stored_media_filepath)
|
||||||
|
|
||||||
merged_attrs = Map.merge(attrs, %{media_filepath: stored_media_filepath})
|
merged_attrs = Map.merge(attrs, %{media_filepath: stored_media_filepath})
|
||||||
media_item_fixture(merged_attrs)
|
media_item_fixture(merged_attrs)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ defmodule Pinchflat.SourcesFixtures do
|
||||||
alias Pinchflat.MediaFixtures
|
alias Pinchflat.MediaFixtures
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.ProfilesFixtures
|
alias Pinchflat.ProfilesFixtures
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
alias Pinchflat.Utils.FilesystemUtils
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Generate a source.
|
Generate a source.
|
||||||
|
|
@ -59,9 +59,9 @@ defmodule Pinchflat.SourcesFixtures do
|
||||||
poster_filepath = Path.join(metadata_dir, "poster.jpg")
|
poster_filepath = Path.join(metadata_dir, "poster.jpg")
|
||||||
fanart_filepath = Path.join(metadata_dir, "fanart.jpg")
|
fanart_filepath = Path.join(metadata_dir, "fanart.jpg")
|
||||||
|
|
||||||
FilesystemHelpers.cp_p!(MediaFixtures.media_metadata_filepath_fixture(), json_gz_filepath)
|
FilesystemUtils.cp_p!(MediaFixtures.media_metadata_filepath_fixture(), json_gz_filepath)
|
||||||
FilesystemHelpers.cp_p!(MediaFixtures.thumbnail_filepath_fixture(), poster_filepath)
|
FilesystemUtils.cp_p!(MediaFixtures.thumbnail_filepath_fixture(), poster_filepath)
|
||||||
FilesystemHelpers.cp_p!(MediaFixtures.thumbnail_filepath_fixture(), fanart_filepath)
|
FilesystemUtils.cp_p!(MediaFixtures.thumbnail_filepath_fixture(), fanart_filepath)
|
||||||
|
|
||||||
merged_attrs =
|
merged_attrs =
|
||||||
Map.merge(attrs, %{
|
Map.merge(attrs, %{
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue