Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7090349abd | |||
| f2c9c10437 | |||
| ff188f9351 | |||
| 04b14719ee | |||
| 112c6a4f14 | |||
| 8051107d32 | |||
| 3f74f199dc | |||
| 09cac46e14 |
@@ -66,6 +66,7 @@ If it doesn't work for your use case, please make a feature request! You can als
|
||||
- Can pass cookies to YouTube to download your private playlists ([docs](https://github.com/kieraneglin/pinchflat/wiki/YouTube-Cookies))
|
||||
- Sponsorblock integration
|
||||
- \[Advanced\] allows custom `yt-dlp` options ([docs](https://github.com/kieraneglin/pinchflat/wiki/%5BAdvanced%5D-Custom-yt%E2%80%90dlp-options))
|
||||
- \[Advanced\] supports running custom scripts when after downloading/deleting media (alpha - [docs](https://github.com/kieraneglin/pinchflat/wiki/%5BAdvanced%5D-Custom-lifecycle-scripts))
|
||||
|
||||
## Screenshots
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
window.copyTextToClipboard = async (text) => {
|
||||
// Navigator clipboard api needs a secure context (https)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} else {
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
// Move textarea out of the viewport so it's not visible
|
||||
textArea.style.position = 'absolute'
|
||||
textArea.style.left = '-999999px'
|
||||
|
||||
document.body.prepend(textArea)
|
||||
textArea.select()
|
||||
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
textArea.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.markVersionAsSeen = (versionString) => {
|
||||
localStorage.setItem('seenVersion', versionString)
|
||||
}
|
||||
|
||||
window.isVersionSeen = (versionString) => {
|
||||
return localStorage.getItem('seenVersion') === versionString
|
||||
}
|
||||
+1
-24
@@ -23,6 +23,7 @@ import { LiveSocket } from 'phoenix_live_view'
|
||||
import topbar from '../vendor/topbar'
|
||||
import Alpine from 'alpinejs'
|
||||
import './tabs'
|
||||
import './alpine_helpers'
|
||||
|
||||
window.Alpine = Alpine
|
||||
Alpine.start()
|
||||
@@ -50,30 +51,6 @@ let liveSocket = new LiveSocket('/live', Socket, {
|
||||
}
|
||||
})
|
||||
|
||||
window.copyTextToClipboard = async (text) => {
|
||||
// Navigator clipboard api needs a secure context (https)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} else {
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
// Move textarea out of the viewport so it's not visible
|
||||
textArea.style.position = 'absolute'
|
||||
textArea.style.left = '-999999px'
|
||||
|
||||
document.body.prepend(textArea)
|
||||
textArea.select()
|
||||
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
textArea.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' })
|
||||
window.addEventListener('phx:page-loading-start', (_info) => topbar.show(300))
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ config :pinchflat,
|
||||
yt_dlp_executable: System.find_executable("yt-dlp"),
|
||||
apprise_executable: System.find_executable("apprise"),
|
||||
yt_dlp_runner: Pinchflat.YtDlp.CommandRunner,
|
||||
apprise_runner: Pinchflat.Notifications.CommandRunner,
|
||||
apprise_runner: Pinchflat.Lifecycle.Notifications.CommandRunner,
|
||||
media_directory: "/downloads",
|
||||
# The user may or may not store metadata for their needs, but the app will always store its copy
|
||||
metadata_directory: "/config/metadata",
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ FROM ${DEV_IMAGE}
|
||||
|
||||
# Install debian packages
|
||||
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 jq \
|
||||
python3 python3-pip python3-setuptools python3-wheel python3-dev locales procps
|
||||
|
||||
# Install nodejs
|
||||
|
||||
@@ -33,6 +33,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
||||
def init(state) do
|
||||
reset_executing_jobs()
|
||||
create_blank_yt_dlp_files()
|
||||
create_blank_user_script_file()
|
||||
apply_default_settings()
|
||||
|
||||
{:ok, state}
|
||||
@@ -65,6 +66,18 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
||||
end)
|
||||
end
|
||||
|
||||
defp create_blank_user_script_file do
|
||||
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||
filepath = Path.join([base_dir, "user-scripts", "lifecycle"])
|
||||
|
||||
if !File.exists?(filepath) do
|
||||
Logger.info("Creating blank file and making it executable: #{filepath}")
|
||||
|
||||
FilesystemUtils.write_p!(filepath, "")
|
||||
File.chmod(filepath, 0o755)
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_default_settings do
|
||||
{:ok, yt_dlp_version} = yt_dlp_runner().version()
|
||||
{:ok, apprise_version} = apprise_runner().version()
|
||||
|
||||
@@ -8,6 +8,8 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Downloading.OutputPathBuilder
|
||||
|
||||
alias Pinchflat.Utils.FilesystemUtils, as: FSUtils
|
||||
|
||||
@doc """
|
||||
Builds the options for yt-dlp to download media based on the given media's profile.
|
||||
|
||||
@@ -33,12 +35,19 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
Builds the output path for yt-dlp to download media based on the given source's
|
||||
media profile. Uses the source's override output path template if it exists.
|
||||
|
||||
Accepts a %MediaItem{} or %Source{} struct. If a %Source{} struct is passed, it
|
||||
will use a default %MediaItem{} struct with the given source.
|
||||
|
||||
Returns binary()
|
||||
"""
|
||||
def build_output_path_for(%Source{} = source_with_preloads) do
|
||||
output_path_template = Sources.output_path_template(source_with_preloads)
|
||||
def build_output_path_for(%MediaItem{} = media_item_with_preloads) do
|
||||
output_path_template = Sources.output_path_template(media_item_with_preloads.source)
|
||||
|
||||
build_output_path(output_path_template, source_with_preloads)
|
||||
build_output_path(output_path_template, media_item_with_preloads)
|
||||
end
|
||||
|
||||
def build_output_path_for(%Source{} = source_with_preloads) do
|
||||
build_output_path_for(%MediaItem{source: source_with_preloads})
|
||||
end
|
||||
|
||||
defp default_options do
|
||||
@@ -154,12 +163,10 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
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
|
||||
if FSUtils.exists_and_nonempty?(filepath) do
|
||||
[filepath | acc]
|
||||
else
|
||||
acc
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -168,23 +175,29 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder 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)
|
||||
]
|
||||
end
|
||||
|
||||
defp build_output_path(string, source) do
|
||||
additional_options_map = output_options_map(source)
|
||||
defp build_output_path(string, media_item_with_preloads) do
|
||||
additional_options_map = output_options_map(media_item_with_preloads)
|
||||
{:ok, output_path} = OutputPathBuilder.build(string, additional_options_map)
|
||||
|
||||
Path.join(base_directory(), output_path)
|
||||
end
|
||||
|
||||
defp output_options_map(source) do
|
||||
defp output_options_map(media_item_with_preloads) do
|
||||
source = media_item_with_preloads.source
|
||||
|
||||
%{
|
||||
"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" => to_string(source.collection_type),
|
||||
"media_upload_date_index" =>
|
||||
media_item_with_preloads.upload_date_index
|
||||
|> to_string()
|
||||
|> String.pad_leading(2, "0")
|
||||
}
|
||||
end
|
||||
|
||||
@@ -198,7 +211,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
|> String.split(~r{\.}, include_captures: true)
|
||||
|> List.insert_at(-3, "-thumb")
|
||||
|> Enum.join()
|
||||
|> build_output_path(media_item_with_preloads.source)
|
||||
|> build_output_path(media_item_with_preloads)
|
||||
end
|
||||
|
||||
defp base_directory do
|
||||
|
||||
@@ -14,6 +14,8 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Downloading.MediaDownloader
|
||||
|
||||
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
|
||||
|
||||
@doc """
|
||||
Starts the media_item media download worker and creates a task for the media_item.
|
||||
|
||||
@@ -56,37 +58,54 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
||||
|
||||
defp download_media_and_schedule_jobs(media_item, is_redownload) do
|
||||
case MediaDownloader.download_for_media_item(media_item) do
|
||||
{:ok, 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, downloaded_media_item} ->
|
||||
{:ok, updated_media_item} =
|
||||
Media.update_media_item(downloaded_media_item, %{
|
||||
media_size_bytes: compute_media_filesize(downloaded_media_item),
|
||||
media_redownloaded_at: get_redownloaded_at(is_redownload)
|
||||
})
|
||||
|
||||
:ok = run_user_script(updated_media_item)
|
||||
|
||||
{:ok, updated_media_item}
|
||||
|
||||
{:recovered, _} ->
|
||||
{:error, :retry}
|
||||
|
||||
{:error, _message} ->
|
||||
{:error, :download_failed}
|
||||
{:error, message} ->
|
||||
action_on_error(message)
|
||||
end
|
||||
end
|
||||
|
||||
defp compute_media_filesize(media_item) do
|
||||
case File.stat(media_item.media_filepath) do
|
||||
{:ok, %{size: size}} ->
|
||||
size
|
||||
|
||||
_ ->
|
||||
nil
|
||||
{:ok, %{size: size}} -> size
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp get_redownloaded_at(is_redownload) do
|
||||
if is_redownload do
|
||||
DateTime.utc_now()
|
||||
defp get_redownloaded_at(true), do: DateTime.utc_now()
|
||||
defp get_redownloaded_at(_), do: nil
|
||||
|
||||
defp action_on_error(message) do
|
||||
# This will attempt re-download at the next indexing, but it won't be retried
|
||||
# immediately as part of job failure logic
|
||||
non_retryable_errors = ["Video unavailable"]
|
||||
|
||||
if String.contains?(to_string(message), non_retryable_errors) do
|
||||
Logger.error("yt-dlp download will not be retried: #{inspect(message)}")
|
||||
|
||||
{:ok, :non_retry}
|
||||
else
|
||||
nil
|
||||
{:error, :download_failed}
|
||||
end
|
||||
end
|
||||
|
||||
# NOTE: I like this pattern of using the default value so that I don't have to
|
||||
# define it in config.exs (and friends). Consider using this elsewhere.
|
||||
defp run_user_script(media_item) do
|
||||
runner = Application.get_env(:pinchflat, :user_script_runner, UserScriptRunner)
|
||||
|
||||
runner.run(:media_downloaded, media_item)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,13 +9,23 @@ defmodule Pinchflat.Downloading.OutputPathBuilder do
|
||||
Builds the actual final filepath from a given template. Optionally, you can pass in
|
||||
a map of additional options to be used in the template.
|
||||
|
||||
Custom options are recursively expanded _once_ so you can nest custom options
|
||||
one-deep if needed.
|
||||
|
||||
Translates liquid-style templates into yt-dlp-style templates,
|
||||
leaving yt-dlp syntax intact.
|
||||
"""
|
||||
def build(template_string, additional_template_options \\ %{}) do
|
||||
combined_options = Map.merge(custom_yt_dlp_option_map(), additional_template_options)
|
||||
|
||||
TemplateParser.parse(template_string, combined_options, &identifier_fn/2)
|
||||
expanded_options =
|
||||
Enum.map(combined_options, fn {key, value} ->
|
||||
{:ok, parse_result} = TemplateParser.parse(value, combined_options, &identifier_fn/2)
|
||||
|
||||
{key, parse_result}
|
||||
end)
|
||||
|
||||
TemplateParser.parse(template_string, Map.new(expanded_options), &identifier_fn/2)
|
||||
end
|
||||
|
||||
# The `nil` case simply wraps the identifier in yt-dlp-style syntax. This assumes that
|
||||
@@ -43,6 +53,7 @@ defmodule Pinchflat.Downloading.OutputPathBuilder do
|
||||
"upload_yyyy_mm_dd" => "%(upload_date>%Y-%m-%d)S",
|
||||
"season_from_date" => "%(upload_date>%Y)S",
|
||||
"season_episode_from_date" => "s%(upload_date>%Y)Se%(upload_date>%m%d)S",
|
||||
"season_episode_index_from_date" => "s%(upload_date>%Y)Se%(upload_date>%m%d)S{{ media_upload_date_index }}",
|
||||
"artist_name" => "%(artist,creator,uploader,uploader_id)S"
|
||||
}
|
||||
end
|
||||
|
||||
@@ -15,7 +15,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
|
||||
alias Pinchflat.Settings
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.FastIndexing.FastIndexingHelpers
|
||||
alias Pinchflat.Notifications.SourceNotifications
|
||||
alias Pinchflat.Lifecycle.Notifications.SourceNotifications
|
||||
|
||||
@doc """
|
||||
Starts the source fast indexing worker and creates a task for the source.
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
defmodule Pinchflat.Notifications.AppriseCommandRunner do
|
||||
defmodule Pinchflat.Lifecycle.Notifications.AppriseCommandRunner do
|
||||
@moduledoc """
|
||||
A behaviour for running CLI commands against a notification backend (apprise).
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
defmodule Pinchflat.Notifications.CommandRunner do
|
||||
defmodule Pinchflat.Lifecycle.Notifications.CommandRunner do
|
||||
@moduledoc """
|
||||
Runs apprise commands using the `System.cmd/3` function
|
||||
"""
|
||||
@@ -7,7 +7,7 @@ defmodule Pinchflat.Notifications.CommandRunner do
|
||||
|
||||
alias Pinchflat.Utils.CliUtils
|
||||
alias Pinchflat.Utils.FunctionUtils
|
||||
alias Pinchflat.Notifications.AppriseCommandRunner
|
||||
alias Pinchflat.Lifecycle.Notifications.AppriseCommandRunner
|
||||
|
||||
@behaviour AppriseCommandRunner
|
||||
|
||||
@@ -28,10 +28,10 @@ defmodule Pinchflat.Notifications.CommandRunner do
|
||||
default_opts = [:verbose]
|
||||
parsed_opts = CliUtils.parse_options(default_opts ++ command_opts)
|
||||
|
||||
{output, return_code} = CliUtils.wrap_cmd(backend_executable(), parsed_opts ++ endpoints)
|
||||
{output, exit_code} = CliUtils.wrap_cmd(backend_executable(), parsed_opts ++ endpoints)
|
||||
Logger.info("[apprise] response: #{output}")
|
||||
|
||||
case return_code do
|
||||
case exit_code do
|
||||
0 -> {:ok, String.trim(output)}
|
||||
_ -> {:error, String.trim(output)}
|
||||
end
|
||||
+6
-2
@@ -1,4 +1,4 @@
|
||||
defmodule Pinchflat.Notifications.SourceNotifications do
|
||||
defmodule Pinchflat.Lifecycle.Notifications.SourceNotifications do
|
||||
@moduledoc """
|
||||
Contains utilities for sending notifications about sources
|
||||
"""
|
||||
@@ -53,7 +53,11 @@ defmodule Pinchflat.Notifications.SourceNotifications do
|
||||
end
|
||||
|
||||
defp relevant_media_item_count(source) do
|
||||
pending_media_item_count(source) + downloaded_media_item_count(source)
|
||||
if source.download_media do
|
||||
pending_media_item_count(source) + downloaded_media_item_count(source)
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
defp pending_media_item_count(source) do
|
||||
@@ -0,0 +1,76 @@
|
||||
defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do
|
||||
@moduledoc """
|
||||
Runs custom user commands commands using the `System.cmd/3` function
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Pinchflat.Utils.CliUtils
|
||||
alias Pinchflat.Utils.FilesystemUtils
|
||||
alias Pinchflat.Lifecycle.UserScripts.UserScriptCommandRunner
|
||||
|
||||
@behaviour UserScriptCommandRunner
|
||||
|
||||
@event_types [
|
||||
:media_downloaded,
|
||||
:media_deleted
|
||||
]
|
||||
|
||||
@doc """
|
||||
Runs the user script command for the given event type. Passes the event
|
||||
and the encoded data to the user script command.
|
||||
|
||||
This function will succeed in almost all cases, even if the user script command
|
||||
failed - this is because I don't want bad scripts to stop the whole process.
|
||||
If something fails, it'll be logged.
|
||||
|
||||
The only things that can cause a true failure are passing in an invalid event
|
||||
type or if the passed data cannot be encoded into JSON - both indicative of
|
||||
failures in the development process.
|
||||
|
||||
Returns :ok
|
||||
"""
|
||||
@impl UserScriptCommandRunner
|
||||
def run(event_type, encodable_data) when event_type in @event_types do
|
||||
case backend_executable() do
|
||||
{:ok, :no_executable} ->
|
||||
:ok
|
||||
|
||||
{:ok, executable_path} ->
|
||||
{:ok, encoded_data} = Phoenix.json_library().encode(encodable_data)
|
||||
|
||||
{output, exit_code} =
|
||||
CliUtils.wrap_cmd(
|
||||
executable_path,
|
||||
[to_string(event_type), encoded_data],
|
||||
[],
|
||||
logging_arg_override: "[suppressed]"
|
||||
)
|
||||
|
||||
handle_output(output, exit_code)
|
||||
end
|
||||
end
|
||||
|
||||
def run(event_type, _encodable_data) do
|
||||
raise ArgumentError, "Invalid event type: #{inspect(event_type)}"
|
||||
end
|
||||
|
||||
defp handle_output(output, exit_code) do
|
||||
Logger.debug("Custom lifecycle script exit code: #{exit_code} with output: #{output}")
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp backend_executable do
|
||||
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||
filepath = Path.join([base_dir, "user-scripts", "lifecycle"])
|
||||
|
||||
if FilesystemUtils.exists_and_nonempty?(filepath) do
|
||||
{:ok, filepath}
|
||||
else
|
||||
Logger.warning("User scripts lifecyle file either not present or is empty. Skipping.")
|
||||
|
||||
{:ok, :no_executable}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
defmodule Pinchflat.Lifecycle.UserScripts.UserScriptCommandRunner do
|
||||
@moduledoc """
|
||||
A behaviour for running custom user scripts on certain events.
|
||||
|
||||
Used so we can implement Mox for testing without actually running the
|
||||
user's command.
|
||||
"""
|
||||
|
||||
@callback run(atom(), map()) :: :ok | {:error, binary()}
|
||||
end
|
||||
@@ -10,8 +10,10 @@ defmodule Pinchflat.Media do
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Media.MediaQuery
|
||||
alias Pinchflat.Metadata.MediaMetadata
|
||||
alias Pinchflat.Utils.FilesystemUtils
|
||||
alias Pinchflat.Metadata.MediaMetadata
|
||||
|
||||
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
|
||||
|
||||
@doc """
|
||||
Returns the list of media_items.
|
||||
@@ -180,6 +182,7 @@ defmodule Pinchflat.Media do
|
||||
|
||||
if delete_files do
|
||||
{:ok, _} = do_delete_media_files(media_item)
|
||||
:ok = run_user_script(:media_deleted, media_item)
|
||||
end
|
||||
|
||||
# Should delete these no matter what
|
||||
@@ -202,6 +205,7 @@ defmodule Pinchflat.Media do
|
||||
|
||||
Tasks.delete_tasks_for(media_item)
|
||||
{:ok, _} = do_delete_media_files(media_item)
|
||||
:ok = run_user_script(:media_deleted, media_item)
|
||||
|
||||
update_media_item(media_item, Map.merge(filepath_attrs, addl_attrs))
|
||||
end
|
||||
@@ -237,4 +241,10 @@ defmodule Pinchflat.Media do
|
||||
|> Enum.filter(&is_binary/1)
|
||||
|> Enum.each(&FilesystemUtils.delete_file_and_remove_empty_directories/1)
|
||||
end
|
||||
|
||||
defp run_user_script(event, media_item) do
|
||||
runner = Application.get_env(:pinchflat, :user_script_runner, UserScriptRunner)
|
||||
|
||||
runner.run(event, media_item)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,8 +7,12 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
import Ecto.Changeset
|
||||
import Pinchflat.Utils.ChangesetUtils
|
||||
|
||||
alias __MODULE__
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Sources
|
||||
alias Pinchflat.Tasks.Task
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaQuery
|
||||
alias Pinchflat.Metadata.MediaMetadata
|
||||
alias Pinchflat.Media.MediaItemsSearchIndex
|
||||
|
||||
@@ -22,6 +26,7 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
:source_id,
|
||||
:short_form_content,
|
||||
:upload_date,
|
||||
:upload_date_index,
|
||||
:duration_seconds,
|
||||
# these fields are captured only on download
|
||||
:media_downloaded_at,
|
||||
@@ -64,6 +69,7 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
field :media_downloaded_at, :utc_datetime
|
||||
field :media_redownloaded_at, :utc_datetime
|
||||
field :upload_date, :date
|
||||
field :upload_date_index, :integer, default: 0
|
||||
field :duration_seconds, :integer
|
||||
|
||||
field :media_filepath, :string
|
||||
@@ -98,6 +104,7 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
|> cast(attrs, @allowed_fields)
|
||||
|> cast_assoc(:metadata, with: &MediaMetadata.changeset/2, required: false)
|
||||
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|
||||
|> update_upload_date_index()
|
||||
|> validate_required(@required_fields)
|
||||
|> unique_constraint([:media_id, :source_id])
|
||||
end
|
||||
@@ -116,4 +123,42 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
end)
|
||||
|> Enum.into(%{})
|
||||
end
|
||||
|
||||
@doc false
|
||||
def json_exluded_fields do
|
||||
~w(__meta__ __struct__ metadata tasks media_items_search_index)a
|
||||
end
|
||||
|
||||
defp update_upload_date_index(%{changes: changes} = changeset) when is_map_key(changes, :upload_date) do
|
||||
source_id = get_field(changeset, :source_id)
|
||||
source = Sources.get_source!(source_id)
|
||||
# Channels should count down from 99, playlists should count up from 0
|
||||
# This reflects the fact that channels prepend new videos to the top of the list
|
||||
# and playlists append new videos to the bottom of the list.
|
||||
default_index = if source.collection_type == :channel, do: 99, else: 0
|
||||
aggregator = if source.collection_type == :channel, do: :min, else: :max
|
||||
change_direction = if source.collection_type == :channel, do: -1, else: 1
|
||||
|
||||
current_max =
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source_id)
|
||||
|> MediaQuery.where_uploaded_on_date(changes.upload_date)
|
||||
|> Repo.aggregate(aggregator, :upload_date_index)
|
||||
|
||||
case current_max do
|
||||
nil -> put_change(changeset, :upload_date_index, default_index)
|
||||
max -> put_change(changeset, :upload_date_index, max + change_direction)
|
||||
end
|
||||
end
|
||||
|
||||
defp update_upload_date_index(changeset), do: changeset
|
||||
|
||||
defimpl Jason.Encoder, for: MediaItem do
|
||||
def encode(value, opts) do
|
||||
value
|
||||
|> Repo.preload(:source)
|
||||
|> Map.drop(MediaItem.json_exluded_fields())
|
||||
|> Jason.Encode.map(opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,6 +25,10 @@ defmodule Pinchflat.Media.MediaQuery do
|
||||
MediaItem
|
||||
end
|
||||
|
||||
def for_source(query, source_id) when is_integer(source_id) do
|
||||
where(query, [mi], mi.source_id == ^source_id)
|
||||
end
|
||||
|
||||
def for_source(query, source) do
|
||||
where(query, [mi], mi.source_id == ^source.id)
|
||||
end
|
||||
@@ -99,6 +103,10 @@ defmodule Pinchflat.Media.MediaQuery do
|
||||
|> where([mi, source], is_nil(source.download_cutoff_date) or mi.upload_date >= source.download_cutoff_date)
|
||||
end
|
||||
|
||||
def where_uploaded_on_date(query, date) do
|
||||
where(query, [mi], mi.upload_date == ^date)
|
||||
end
|
||||
|
||||
def where_download_not_prevented(query) do
|
||||
where(query, [mi], mi.prevent_download == false)
|
||||
end
|
||||
|
||||
@@ -75,6 +75,8 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
||||
end
|
||||
|
||||
defp build_media_item_xml(source, media_item, url_base) do
|
||||
item_image_path = item_image_path(url_base, media_item)
|
||||
|
||||
"""
|
||||
<item>
|
||||
<guid isPermaLink="false">#{media_item.uuid}</guid>
|
||||
@@ -91,6 +93,10 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
||||
<itunes:author>#{safe(source.custom_name)}</itunes:author>
|
||||
<itunes:subtitle>#{safe(media_item.title)}</itunes:subtitle>
|
||||
<itunes:summary><![CDATA[#{media_item.description}]]></itunes:summary>
|
||||
|
||||
#{item_image_path && ~s(<itunes:image href="#{safe(item_image_path)}"></itunes:image>)}
|
||||
#{item_image_path && ~s(<podcast:images srcset="#{safe(item_image_path)}" />)}
|
||||
|
||||
<itunes:explicit>false</itunes:explicit>
|
||||
</item>
|
||||
"""
|
||||
@@ -117,6 +123,16 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
||||
end
|
||||
end
|
||||
|
||||
def item_image_path(url_base, media_item) do
|
||||
if media_item.thumbnail_filepath && File.exists?(media_item.thumbnail_filepath) do
|
||||
extension = Path.extname(media_item.thumbnail_filepath)
|
||||
|
||||
Path.join(url_base, "#{podcast_route(:episode_image, media_item.uuid)}#{extension}")
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_upload_date(media_item) do
|
||||
media_item.upload_date
|
||||
|> DatetimeUtils.date_to_datetime()
|
||||
|
||||
@@ -6,6 +6,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias __MODULE__
|
||||
alias Pinchflat.Sources.Source
|
||||
|
||||
@allowed_fields ~w(
|
||||
@@ -84,4 +85,17 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
||||
def ext_regex do
|
||||
~r/\.({{ ?ext ?}}|%\( ?ext ?\)[sS])$/
|
||||
end
|
||||
|
||||
@doc false
|
||||
def json_exluded_fields do
|
||||
~w(__meta__ __struct__ sources)a
|
||||
end
|
||||
|
||||
defimpl Jason.Encoder, for: MediaProfile do
|
||||
def encode(value, opts) do
|
||||
value
|
||||
|> Map.drop(MediaProfile.json_exluded_fields())
|
||||
|> Jason.Encode.map(opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,7 +15,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.FastIndexing.FastIndexingWorker
|
||||
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
||||
alias Pinchflat.Notifications.SourceNotifications
|
||||
alias Pinchflat.Lifecycle.Notifications.SourceNotifications
|
||||
|
||||
@doc """
|
||||
Starts the source slow indexing worker and creates a task for the source.
|
||||
|
||||
@@ -7,6 +7,8 @@ defmodule Pinchflat.Sources.Source do
|
||||
import Ecto.Changeset
|
||||
import Pinchflat.Utils.ChangesetUtils
|
||||
|
||||
alias __MODULE__
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Tasks.Task
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Profiles.MediaProfile
|
||||
@@ -133,4 +135,18 @@ defmodule Pinchflat.Sources.Source do
|
||||
def filepath_attributes do
|
||||
~w(nfo_filepath fanart_filepath poster_filepath banner_filepath)a
|
||||
end
|
||||
|
||||
@doc false
|
||||
def json_exluded_fields do
|
||||
~w(__meta__ __struct__ metadata tasks media_items)a
|
||||
end
|
||||
|
||||
defimpl Jason.Encoder, for: Source do
|
||||
def encode(value, opts) do
|
||||
value
|
||||
|> Repo.preload(:media_profile)
|
||||
|> Map.drop(Source.json_exluded_fields())
|
||||
|> Jason.Encode.map(opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,17 +13,22 @@ defmodule Pinchflat.Utils.CliUtils do
|
||||
commands if the job runner is cancelled.
|
||||
|
||||
Delegates to `System.cmd/3` and any options/output
|
||||
are passed through.
|
||||
are passed through. Custom options can be passed in.
|
||||
|
||||
Custom options:
|
||||
- logging_arg_override: if set, the passed value will be logged in place of
|
||||
the actual arguments passed to the command
|
||||
|
||||
Returns {binary(), integer()}
|
||||
"""
|
||||
def wrap_cmd(command, args, opts \\ []) do
|
||||
def wrap_cmd(command, args, passthrough_opts \\ [], opts \\ []) do
|
||||
wrapper_command = Path.join(:code.priv_dir(:pinchflat), "cmd_wrapper.sh")
|
||||
actual_command = [command] ++ args
|
||||
logging_arg_override = Keyword.get(opts, :logging_arg_override, Enum.join(args, " "))
|
||||
|
||||
Logger.info("[command_wrapper]: #{command} called with: #{Enum.join(args, " ")}")
|
||||
Logger.info("[command_wrapper]: #{command} called with: #{logging_arg_override}")
|
||||
|
||||
System.cmd(wrapper_command, actual_command, opts)
|
||||
System.cmd(wrapper_command, actual_command, passthrough_opts)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
||||
@@ -5,6 +5,21 @@ defmodule Pinchflat.Utils.FilesystemUtils do
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Utils.StringUtils
|
||||
|
||||
@doc """
|
||||
Checks if a file exists and has non-whitespace contents.
|
||||
|
||||
Returns boolean()
|
||||
"""
|
||||
def exists_and_nonempty?(filepath) do
|
||||
case File.read(filepath) do
|
||||
{:ok, contents} ->
|
||||
String.trim(contents) != ""
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a temporary file and returns its path. The file is empty and has the given type.
|
||||
Generates all the directories in the path if they don't exist.
|
||||
|
||||
@@ -81,16 +81,10 @@ defmodule Pinchflat.YtDlp.CommandRunner do
|
||||
Enum.reduce(filename_options_map, [], fn {opt_name, filename}, acc ->
|
||||
filepath = Path.join(base_dir, filename)
|
||||
|
||||
case File.read(filepath) do
|
||||
{:ok, file_data} ->
|
||||
if String.trim(file_data) != "" do
|
||||
[{opt_name, filepath} | acc]
|
||||
else
|
||||
acc
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
acc
|
||||
if FSUtils.exists_and_nonempty?(filepath) do
|
||||
[{opt_name, filepath} | acc]
|
||||
else
|
||||
acc
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -54,10 +54,22 @@
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm">
|
||||
Pinchflat v<%= Application.spec(:pinchflat)[:vsn] %>
|
||||
<span
|
||||
class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm"
|
||||
x-on:click={"markVersionAsSeen('#{Application.spec(:pinchflat)[:vsn]}')"}
|
||||
>
|
||||
<span>Pinchflat v<%= Application.spec(:pinchflat)[:vsn] %></span>
|
||||
<a
|
||||
href="https://github.com/kieraneglin/pinchflat/releases"
|
||||
target="_blank"
|
||||
class="bg-meta-2 text-boxdark px-1.5 rounded-full text-xs"
|
||||
x-cloak
|
||||
x-show={"!isVersionSeen('#{Application.spec(:pinchflat)[:vsn]}')"}
|
||||
>
|
||||
NEW
|
||||
</a>
|
||||
</span>
|
||||
<span class="group relative flex items-center gap-2.5 px-4 text-sm">
|
||||
<span class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm">
|
||||
yt-dlp <%= Settings.get!(:yt_dlp_version) %>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@@ -63,7 +63,11 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
||||
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'",
|
||||
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",
|
||||
season_from_date: "alias for upload_year",
|
||||
season_episode_from_date: "the upload date formatted as sYYYYeMMDD",
|
||||
season_episode_index_from_date:
|
||||
"the upload date formatted as sYYYYeMMDDII where II is an index to prevent date collisions"
|
||||
}
|
||||
end
|
||||
|
||||
@@ -94,7 +98,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
||||
end
|
||||
|
||||
defp media_center_output_template do
|
||||
"/shows/{{ source_custom_name }}/Season {{ season_from_date }}/{{ season_episode_from_date }} - {{ title }}.{{ ext }}"
|
||||
"/shows/{{ source_custom_name }}/Season {{ season_from_date }}/{{ season_episode_index_from_date }} - {{ title }}.{{ ext }}"
|
||||
end
|
||||
|
||||
defp audio_output_template do
|
||||
|
||||
@@ -2,8 +2,9 @@ defmodule PinchflatWeb.Podcasts.PodcastController do
|
||||
use PinchflatWeb, :controller
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Media.MediaQuery
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Media.MediaQuery
|
||||
alias Pinchflat.Podcasts.RssFeedBuilder
|
||||
alias Pinchflat.Podcasts.PodcastHelpers
|
||||
|
||||
@@ -40,4 +41,16 @@ defmodule PinchflatWeb.Podcasts.PodcastController do
|
||||
|> send_file(200, filepath)
|
||||
end
|
||||
end
|
||||
|
||||
def episode_image(conn, %{"uuid" => uuid}) do
|
||||
media_item = Repo.get_by!(MediaItem, uuid: uuid)
|
||||
|
||||
if media_item.thumbnail_filepath && File.exists?(media_item.thumbnail_filepath) do
|
||||
conn
|
||||
|> put_resp_content_type(MIME.from_path(media_item.thumbnail_filepath))
|
||||
|> send_file(200, media_item.thumbnail_filepath)
|
||||
else
|
||||
send_resp(conn, 404, "Image not found")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,17 +11,33 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
|
||||
|
||||
def render(%{records: []} = assigns) do
|
||||
~H"""
|
||||
<p class="text-black dark:text-white">Nothing Here!</p>
|
||||
<div class="mb-4 flex items-center">
|
||||
<button
|
||||
class="flex justify-center items-center rounded-lg bg-form-input border-2 border-strokedark h-10 w-10"
|
||||
phx-click="reload_page"
|
||||
type="button"
|
||||
>
|
||||
<.icon name="hero-arrow-path" class="text-stroke" />
|
||||
</button>
|
||||
<p class="ml-2">Nothing Here!</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<span class="mb-4 inline-block">
|
||||
Showing <%= length(@records) %> of <%= @total_record_count %>
|
||||
<span class="mb-4 flex items-center">
|
||||
<button
|
||||
class="flex justify-center items-center rounded-lg bg-form-input border-2 border-strokedark h-10 w-10"
|
||||
phx-click="reload_page"
|
||||
type="button"
|
||||
>
|
||||
<.icon name="hero-arrow-path" class="text-stroke" />
|
||||
</button>
|
||||
<span class="ml-2">Showing <%= length(@records) %> of <%= @total_record_count %></span>
|
||||
</span>
|
||||
<.table rows={@records} table_class="text-black dark:text-white">
|
||||
<.table rows={@records} table_class="text-white">
|
||||
<:col :let={media_item} label="Title">
|
||||
<.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
|
||||
<%= StringUtils.truncate(media_item.title, 50) %>
|
||||
@@ -57,6 +73,12 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
|
||||
{:noreply, assign(socket, new_assigns)}
|
||||
end
|
||||
|
||||
def handle_event("reload_page", _params, %{assigns: assigns} = socket) do
|
||||
new_assigns = fetch_pagination_attributes(assigns.base_query, assigns.page)
|
||||
|
||||
{:noreply, assign(socket, new_assigns)}
|
||||
end
|
||||
|
||||
defp fetch_pagination_attributes(base_query, page) do
|
||||
total_record_count = Repo.aggregate(base_query, :count, :id)
|
||||
total_pages = max(ceil(total_record_count / @limit), 1)
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
</:col>
|
||||
</.table>
|
||||
<% else %>
|
||||
<p class="text-black dark:text-white">Nothing Here!</p>
|
||||
<p>Nothing Here!</p>
|
||||
<% end %>
|
||||
</:tab>
|
||||
</.tabbed_layout>
|
||||
|
||||
@@ -50,6 +50,7 @@ defmodule PinchflatWeb.Router do
|
||||
|
||||
get "/sources/:uuid/feed", Podcasts.PodcastController, :rss_feed
|
||||
get "/sources/:uuid/feed_image", Podcasts.PodcastController, :feed_image
|
||||
get "/media/:uuid/episode_image", Podcasts.PodcastController, :episode_image
|
||||
|
||||
get "/media/:uuid/stream", MediaItems.MediaItemController, :stream
|
||||
end
|
||||
|
||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
|
||||
def project do
|
||||
[
|
||||
app: :pinchflat,
|
||||
version: "0.1.15",
|
||||
version: "0.1.16",
|
||||
elixir: "~> 1.16",
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
start_permanent: Mix.env() == :prod,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
defmodule Pinchflat.Repo.Migrations.AddUploadDateIndexToMediaItems do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:media_items) do
|
||||
add :upload_date_index, :integer, null: false, default: 0
|
||||
end
|
||||
|
||||
create index("media_items", [:upload_date])
|
||||
end
|
||||
end
|
||||
@@ -79,7 +79,7 @@ ARG PORT=8945
|
||||
|
||||
RUN apt-get update -y
|
||||
RUN apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
|
||||
ffmpeg curl git openssh-client nano python3 python3-pip
|
||||
ffmpeg curl git openssh-client nano python3 python3-pip jq procps
|
||||
RUN apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||
|
||||
# Download and update YT-DLP
|
||||
|
||||
@@ -53,6 +53,30 @@ defmodule Pinchflat.Boot.PreJobStartupTasksTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "create_blank_user_script_file" do
|
||||
test "creates a blank script file" do
|
||||
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||
filepath = Path.join([base_dir, "user-scripts", "lifecycle"])
|
||||
File.rm(filepath)
|
||||
|
||||
refute File.exists?(filepath)
|
||||
|
||||
PreJobStartupTasks.init(%{})
|
||||
|
||||
assert File.exists?(filepath)
|
||||
end
|
||||
|
||||
test "gives it 755 permissions" do
|
||||
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||
filepath = Path.join([base_dir, "user-scripts", "lifecycle"])
|
||||
File.rm(filepath)
|
||||
|
||||
PreJobStartupTasks.init(%{})
|
||||
|
||||
assert File.stat!(filepath).mode == 0o100755
|
||||
end
|
||||
end
|
||||
|
||||
describe "apply_default_settings" do
|
||||
test "sets yt_dlp version" do
|
||||
Settings.set(yt_dlp_version: nil)
|
||||
|
||||
@@ -33,6 +33,15 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
||||
assert {:output, "/tmp/test/media/#{media_item.source.custom_name}.%(ext)s"} in res
|
||||
end
|
||||
|
||||
test "respects custom media_item-related output path options", %{media_item: media_item} do
|
||||
media_item =
|
||||
update_media_profile_attribute(media_item, %{output_path_template: "{{ media_upload_date_index }}.%(ext)s"})
|
||||
|
||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||
|
||||
assert {:output, "/tmp/test/media/99.%(ext)s"} in res
|
||||
end
|
||||
|
||||
test "uses source's output override if present", %{media_item: media_item} do
|
||||
source = media_item.source
|
||||
{:ok, _} = Sources.update_source(source, %{output_path_template_override: "override.%(ext)s"})
|
||||
@@ -386,6 +395,12 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
||||
end
|
||||
|
||||
describe "build_output_path_for/1" do
|
||||
test "builds an output path for a media item", %{media_item: media_item} do
|
||||
path = DownloadOptionBuilder.build_output_path_for(media_item)
|
||||
|
||||
assert path == "/tmp/test/media/%(title)S.%(ext)s"
|
||||
end
|
||||
|
||||
test "builds an output path for a source", %{media_item: media_item} do
|
||||
path = DownloadOptionBuilder.build_output_path_for(media_item.source)
|
||||
|
||||
|
||||
@@ -12,9 +12,8 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
||||
setup :verify_on_exit!
|
||||
|
||||
setup do
|
||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts ->
|
||||
{:ok, ""}
|
||||
end)
|
||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
||||
|
||||
media_item =
|
||||
%{media_filepath: nil}
|
||||
@@ -106,6 +105,18 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
||||
end)
|
||||
end
|
||||
|
||||
test "does not set the job to retryable if retrying wouldn't fix the issue", %{media_item: media_item} do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||
{:error, "Something something Video unavailable something something", 1}
|
||||
end)
|
||||
|
||||
Oban.Testing.with_testing_mode(:inline, fn ->
|
||||
{:ok, job} = Oban.insert(MediaDownloadWorker.new(%{id: media_item.id, redownload?: true}))
|
||||
|
||||
assert job.state == "completed"
|
||||
end)
|
||||
end
|
||||
|
||||
test "it ensures error are returned in a 2-item tuple", %{media_item: media_item} do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:error, "error", 1} end)
|
||||
|
||||
@@ -173,6 +184,20 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
||||
assert media_item.media_redownloaded_at == nil
|
||||
end
|
||||
|
||||
test "calls the user script runner", %{media_item: media_item} do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||
{:ok, render_metadata(:media_metadata)}
|
||||
end)
|
||||
|
||||
expect(UserScriptRunnerMock, :run, fn :media_downloaded, data ->
|
||||
assert data.id == media_item.id
|
||||
|
||||
:ok
|
||||
end)
|
||||
|
||||
perform_job(MediaDownloadWorker, %{id: media_item.id})
|
||||
end
|
||||
|
||||
test "does not blow up if the record doesn't exist" do
|
||||
assert :ok = perform_job(MediaDownloadWorker, %{id: 0})
|
||||
end
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
defmodule Pinchflat.Downloading.MediaRetentionWorkerTest do
|
||||
use Pinchflat.DataCase
|
||||
|
||||
import Mox
|
||||
import Pinchflat.MediaFixtures
|
||||
import Pinchflat.SourcesFixtures
|
||||
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Downloading.MediaRetentionWorker
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
describe "perform/1" do
|
||||
setup do
|
||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "deletes media files that are past their retention date" do
|
||||
{_source, old_media_item, new_media_item} = prepare_records()
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
defmodule Pinchflat.Downloading.OutputPath.ParserTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Pinchflat.Downloading.OutputPath.Parser
|
||||
|
||||
|
||||
@@ -27,5 +27,15 @@ defmodule Pinchflat.Downloading.OutputPathBuilderTest do
|
||||
|
||||
assert res == "/videos/%(title)s.%(ext)s"
|
||||
end
|
||||
|
||||
test "recursively expands variables" do
|
||||
additional_options = %{
|
||||
"media_upload_date_index" => "99"
|
||||
}
|
||||
|
||||
assert {:ok, res} = OutputPathBuilder.build("{{ season_episode_index_from_date }}.{{ ext }}", additional_options)
|
||||
|
||||
assert res == "s%(upload_date>%Y)Se%(upload_date>%m%d)S99.%(ext)S"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
defmodule Pinchflat.Notifications.CommandRunnerTest do
|
||||
use ExUnit.Case, async: true
|
||||
defmodule Pinchflat.Lifecycle.Notifications.CommandRunnerTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Pinchflat.Notifications.CommandRunner, as: Runner
|
||||
alias Pinchflat.Lifecycle.Notifications.CommandRunner, as: Runner
|
||||
|
||||
@original_executable Application.compile_env(:pinchflat, :apprise_executable)
|
||||
|
||||
+13
-2
@@ -1,11 +1,11 @@
|
||||
defmodule Pinchflat.Notifications.SourceNotificationsTest do
|
||||
defmodule Pinchflat.Lifecycle.Notifications.SourceNotificationsTest do
|
||||
use Pinchflat.DataCase
|
||||
|
||||
import Mox
|
||||
import Pinchflat.MediaFixtures
|
||||
import Pinchflat.SourcesFixtures
|
||||
|
||||
alias Pinchflat.Notifications.SourceNotifications
|
||||
alias Pinchflat.Lifecycle.Notifications.SourceNotifications
|
||||
|
||||
@apprise_servers ["server_1", "server_2"]
|
||||
|
||||
@@ -60,6 +60,17 @@ defmodule Pinchflat.Notifications.SourceNotificationsTest do
|
||||
end)
|
||||
end
|
||||
|
||||
test "does not send a notification if the source is set to not download media" do
|
||||
source = source_fixture(%{download_media: false})
|
||||
|
||||
expect(AppriseRunnerMock, :run, 0, fn _, _ -> {:ok, ""} end)
|
||||
|
||||
SourceNotifications.wrap_new_media_notification(@apprise_servers, source, fn ->
|
||||
media_item_fixture(%{source_id: source.id, media_filepath: nil})
|
||||
media_item_fixture(%{source_id: source.id, media_filepath: "file.mp4"})
|
||||
end)
|
||||
end
|
||||
|
||||
test "returns the value of the function" do
|
||||
source = source_fixture()
|
||||
expect(AppriseRunnerMock, :run, 0, fn _, _ -> {:ok, ""} end)
|
||||
@@ -0,0 +1,79 @@
|
||||
defmodule Pinchflat.Lifecycle.UserScripts.CommandRunnerTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Pinchflat.Utils.FilesystemUtils
|
||||
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: Runner
|
||||
|
||||
setup do
|
||||
FilesystemUtils.write_p!(filepath(), "")
|
||||
File.chmod(filepath(), 0o755)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "run/2" do
|
||||
test "runs the provided lifecycle file if present" do
|
||||
# We *love* indirectly testing side effects
|
||||
tmp_dir = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||
File.write(filepath(), "#!/bin/bash\ntouch #{tmp_dir}/test_file\n")
|
||||
|
||||
refute File.exists?("#{tmp_dir}/test_file")
|
||||
assert :ok = Runner.run(:media_downloaded, %{})
|
||||
assert File.exists?("#{tmp_dir}/test_file")
|
||||
end
|
||||
|
||||
test "passes the event name to the script" do
|
||||
tmp_dir = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||
File.write(filepath(), "#!/bin/bash\necho $1 > #{tmp_dir}/event_name\n")
|
||||
|
||||
assert :ok = Runner.run(:media_downloaded, %{})
|
||||
assert File.read!("#{tmp_dir}/event_name") == "media_downloaded\n"
|
||||
end
|
||||
|
||||
test "passes the encoded data to the script" do
|
||||
tmp_dir = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||
File.write(filepath(), "#!/bin/bash\necho $2 > #{tmp_dir}/encoded_data\n")
|
||||
|
||||
assert :ok = Runner.run(:media_downloaded, %{foo: "bar"})
|
||||
assert File.read!("#{tmp_dir}/encoded_data") == "{\"foo\":\"bar\"}\n"
|
||||
end
|
||||
|
||||
test "does nothing if the lifecycle file is not present" do
|
||||
:ok = File.rm(filepath())
|
||||
|
||||
assert :ok = Runner.run(:media_downloaded, %{})
|
||||
end
|
||||
|
||||
test "does nothing if the lifecycle file is empty" do
|
||||
File.write(filepath(), "")
|
||||
|
||||
assert :ok = Runner.run(:media_downloaded, %{})
|
||||
end
|
||||
|
||||
test "returns :ok if the command exits with a non-zero status" do
|
||||
File.write(filepath(), "#!/bin/bash\nexit 1\n")
|
||||
|
||||
assert :ok = Runner.run(:media_downloaded, %{})
|
||||
end
|
||||
|
||||
test "gets upset if you pass an invalid event type" do
|
||||
assert_raise ArgumentError, "Invalid event type: :invalid_event", fn ->
|
||||
Runner.run(:invalid_event, %{})
|
||||
end
|
||||
end
|
||||
|
||||
test "gets upset if the record cannot be decoded" do
|
||||
File.write(filepath(), "#!/bin/bash")
|
||||
|
||||
assert_raise MatchError, fn ->
|
||||
Runner.run(:media_downloaded, %Ecto.Changeset{})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp filepath do
|
||||
base_dir = Application.get_env(:pinchflat, :extras_directory)
|
||||
|
||||
Path.join([base_dir, "user-scripts", "lifecycle"])
|
||||
end
|
||||
end
|
||||
@@ -29,6 +29,156 @@ defmodule Pinchflat.MediaTest do
|
||||
Repo.reload!(metadata)
|
||||
end
|
||||
end
|
||||
|
||||
test "can be JSON encoded without error" do
|
||||
media_item = media_item_fixture()
|
||||
|
||||
assert {:ok, _} = Phoenix.json_library().encode(media_item)
|
||||
end
|
||||
end
|
||||
|
||||
describe "schema when testing upload_date_index and source is a channel" do
|
||||
test "upload_date_index is set to 99 if it's the only video uploaded that day" do
|
||||
upload_date = Date.utc_today()
|
||||
source = source_fixture(%{collection_type: :channel})
|
||||
media_item = media_item_fixture(%{source_id: source.id, upload_date: upload_date})
|
||||
|
||||
assert media_item.upload_date_index == 99
|
||||
end
|
||||
|
||||
test "upload_date_index is set to 98 if it's the second video uploaded that day" do
|
||||
upload_date = Date.utc_today()
|
||||
source = source_fixture(%{collection_type: :channel})
|
||||
|
||||
media_item_one = media_item_fixture(%{source_id: source.id, upload_date: upload_date})
|
||||
media_item_two = media_item_fixture(%{source_id: source.id, upload_date: upload_date})
|
||||
|
||||
assert media_item_one.upload_date_index == 99
|
||||
assert media_item_two.upload_date_index == 98
|
||||
end
|
||||
|
||||
test "upload_date_index doesn't decrement if the video is uploaded on a different day" do
|
||||
today = Date.utc_today()
|
||||
one_day_ago = Date.add(today, -1)
|
||||
source = source_fixture(%{collection_type: :channel})
|
||||
|
||||
media_item_new = media_item_fixture(%{source_id: source.id, upload_date: today})
|
||||
media_item_old = media_item_fixture(%{source_id: source.id, upload_date: one_day_ago})
|
||||
|
||||
assert media_item_new.upload_date_index == 99
|
||||
assert media_item_old.upload_date_index == 99
|
||||
end
|
||||
|
||||
test "recomputes upload_date_index if an upload_date is changed...somehow" do
|
||||
today = Date.utc_today()
|
||||
one_day_ago = Date.add(today, -1)
|
||||
source = source_fixture(%{collection_type: :channel})
|
||||
|
||||
media_item_new = media_item_fixture(%{source_id: source.id, upload_date: today})
|
||||
media_item_old = media_item_fixture(%{source_id: source.id, upload_date: one_day_ago})
|
||||
|
||||
{:ok, updated_media_item} = Media.update_media_item(media_item_old, %{upload_date: today})
|
||||
|
||||
assert media_item_new.upload_date_index == 99
|
||||
assert updated_media_item.upload_date_index == 98
|
||||
end
|
||||
|
||||
test "upload_date_index doesn't decrement if the video is for a different source" do
|
||||
today = Date.utc_today()
|
||||
|
||||
source_one = source_fixture(%{collection_type: :channel})
|
||||
source_two = source_fixture(%{collection_type: :channel})
|
||||
|
||||
media_item_one = media_item_fixture(%{source_id: source_one.id, upload_date: today})
|
||||
media_item_two = media_item_fixture(%{source_id: source_two.id, upload_date: today})
|
||||
|
||||
assert media_item_one.upload_date_index == 99
|
||||
assert media_item_two.upload_date_index == 99
|
||||
end
|
||||
|
||||
test "upload_date_index doesn't decrement if the a video's upload_date is updated but doesn't change" do
|
||||
today = Date.utc_today()
|
||||
source = source_fixture(%{collection_type: :channel})
|
||||
|
||||
media_item_one = media_item_fixture(%{source_id: source.id, upload_date: today})
|
||||
_media_item_two = media_item_fixture(%{source_id: source.id, upload_date: today})
|
||||
|
||||
{:ok, updated_media_item} = Media.update_media_item(media_item_one, %{upload_date: today, title: "New title"})
|
||||
|
||||
assert updated_media_item.upload_date_index == 99
|
||||
end
|
||||
end
|
||||
|
||||
describe "schema when testing upload_date_index and source is a playlist" do
|
||||
test "upload_date_index is set to 0 if it's the only video uploaded that day" do
|
||||
upload_date = Date.utc_today()
|
||||
source = source_fixture(%{collection_type: :playlist})
|
||||
media_item = media_item_fixture(%{source_id: source.id, upload_date: upload_date})
|
||||
|
||||
assert media_item.upload_date_index == 0
|
||||
end
|
||||
|
||||
test "upload_date_index is set to 1 if it's the second video uploaded that day" do
|
||||
upload_date = Date.utc_today()
|
||||
source = source_fixture(%{collection_type: :playlist})
|
||||
|
||||
media_item_one = media_item_fixture(%{source_id: source.id, upload_date: upload_date})
|
||||
media_item_two = media_item_fixture(%{source_id: source.id, upload_date: upload_date})
|
||||
|
||||
assert media_item_one.upload_date_index == 0
|
||||
assert media_item_two.upload_date_index == 1
|
||||
end
|
||||
|
||||
test "upload_date_index doesn't increment if the video is uploaded on a different day" do
|
||||
today = Date.utc_today()
|
||||
one_day_ago = Date.add(today, -1)
|
||||
source = source_fixture(%{collection_type: :playlist})
|
||||
|
||||
media_item_new = media_item_fixture(%{source_id: source.id, upload_date: today})
|
||||
media_item_old = media_item_fixture(%{source_id: source.id, upload_date: one_day_ago})
|
||||
|
||||
assert media_item_new.upload_date_index == 0
|
||||
assert media_item_old.upload_date_index == 0
|
||||
end
|
||||
|
||||
test "recomputes upload_date_index if an upload_date is changed...somehow" do
|
||||
today = Date.utc_today()
|
||||
one_day_ago = Date.add(today, -1)
|
||||
source = source_fixture(%{collection_type: :playlist})
|
||||
|
||||
media_item_new = media_item_fixture(%{source_id: source.id, upload_date: today})
|
||||
media_item_old = media_item_fixture(%{source_id: source.id, upload_date: one_day_ago})
|
||||
|
||||
{:ok, updated_media_item} = Media.update_media_item(media_item_old, %{upload_date: today})
|
||||
|
||||
assert media_item_new.upload_date_index == 0
|
||||
assert updated_media_item.upload_date_index == 1
|
||||
end
|
||||
|
||||
test "upload_date_index doesn't increment if the video is for a different source" do
|
||||
today = Date.utc_today()
|
||||
|
||||
source_one = source_fixture(%{collection_type: :playlist})
|
||||
source_two = source_fixture(%{collection_type: :playlist})
|
||||
|
||||
media_item_one = media_item_fixture(%{source_id: source_one.id, upload_date: today})
|
||||
media_item_two = media_item_fixture(%{source_id: source_two.id, upload_date: today})
|
||||
|
||||
assert media_item_one.upload_date_index == 0
|
||||
assert media_item_two.upload_date_index == 0
|
||||
end
|
||||
|
||||
test "upload_date_index doesn't increment if the a video's upload_date is updated but doesn't change" do
|
||||
today = Date.utc_today()
|
||||
source = source_fixture(%{collection_type: :playlist})
|
||||
|
||||
media_item_one = media_item_fixture(%{source_id: source.id, upload_date: today})
|
||||
_media_item_two = media_item_fixture(%{source_id: source.id, upload_date: today})
|
||||
|
||||
{:ok, updated_media_item} = Media.update_media_item(media_item_one, %{upload_date: today, title: "New title"})
|
||||
|
||||
assert updated_media_item.upload_date_index == 0
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_media_items/0" do
|
||||
@@ -743,6 +893,12 @@ defmodule Pinchflat.MediaTest do
|
||||
end
|
||||
|
||||
describe "delete_media_item/2 when testing file deletion" do
|
||||
setup do
|
||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "deletes the media item's files" do
|
||||
media_item = media_item_with_attachments()
|
||||
|
||||
@@ -793,9 +949,27 @@ defmodule Pinchflat.MediaTest do
|
||||
:ok = File.rm(Path.join([root_directory, "test.txt"]))
|
||||
:ok = File.rmdir(root_directory)
|
||||
end
|
||||
|
||||
test "calls the user script runner" do
|
||||
media_item = media_item_with_attachments()
|
||||
|
||||
expect(UserScriptRunnerMock, :run, fn :media_deleted, data ->
|
||||
assert data.id == media_item.id
|
||||
|
||||
:ok
|
||||
end)
|
||||
|
||||
assert {:ok, _} = Media.delete_media_item(media_item, delete_files: true)
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_media_files/2" do
|
||||
setup do
|
||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "does not delete the media_item" do
|
||||
media_item = media_item_fixture()
|
||||
|
||||
@@ -848,6 +1022,18 @@ defmodule Pinchflat.MediaTest do
|
||||
assert {:ok, updated_media_item} = Media.delete_media_files(media_item, %{prevent_download: true})
|
||||
assert updated_media_item.prevent_download
|
||||
end
|
||||
|
||||
test "calls the user script runner" do
|
||||
media_item = media_item_with_attachments()
|
||||
|
||||
expect(UserScriptRunnerMock, :run, fn :media_deleted, data ->
|
||||
assert data.id == media_item.id
|
||||
|
||||
:ok
|
||||
end)
|
||||
|
||||
assert {:ok, _} = Media.delete_media_files(media_item)
|
||||
end
|
||||
end
|
||||
|
||||
describe "change_media_item/1" do
|
||||
|
||||
@@ -106,7 +106,7 @@ defmodule Pinchflat.Metadata.MetadataParserTest do
|
||||
|
||||
:ok = File.cp(thumbnail_filepath_fixture(), thumbnail_filepath)
|
||||
|
||||
on_exit(fn -> File.rm(thumbnail_filepath) end)
|
||||
on_exit(fn -> File.rm_rf(thumbnail_filepath) end)
|
||||
|
||||
{:ok, filepath: thumbnail_filepath}
|
||||
end
|
||||
@@ -127,7 +127,7 @@ defmodule Pinchflat.Metadata.MetadataParserTest do
|
||||
end
|
||||
|
||||
test "doesn't include thumbnail if the file doesn't exist on-disk", %{metadata: metadata, filepath: filepath} do
|
||||
File.rm(filepath)
|
||||
File.rm_rf(filepath)
|
||||
|
||||
result = Parser.parse_for_media_item(metadata)
|
||||
|
||||
@@ -156,7 +156,7 @@ defmodule Pinchflat.Metadata.MetadataParserTest do
|
||||
infojson_filepath = metadata["infojson_filename"]
|
||||
:ok = File.cp(infojson_filepath_fixture(), infojson_filepath)
|
||||
|
||||
on_exit(fn -> File.rm(infojson_filepath) end)
|
||||
on_exit(fn -> File.rm_rf(infojson_filepath) end)
|
||||
|
||||
{:ok, filepath: infojson_filepath}
|
||||
end
|
||||
@@ -168,7 +168,7 @@ defmodule Pinchflat.Metadata.MetadataParserTest do
|
||||
end
|
||||
|
||||
test "doesn't include metadata if the file doesn't exist on-disk", %{metadata: metadata, filepath: filepath} do
|
||||
File.rm(filepath)
|
||||
File.rm_rf(filepath)
|
||||
|
||||
result = Parser.parse_for_media_item(metadata)
|
||||
|
||||
|
||||
@@ -137,6 +137,34 @@ defmodule Pinchflat.Podcasts.RssFeedBuilderTest do
|
||||
assert String.contains?(item_xml, ~s(length="1234"))
|
||||
assert String.contains?(item_xml, ~s(type="video/mp4"))
|
||||
end
|
||||
|
||||
test "returns image tags if the media has a thumbnail", %{source: source} do
|
||||
media_item = media_item_with_attachments(%{source_id: source.id, media_size_bytes: 1234})
|
||||
|
||||
res = RssFeedBuilder.build(source)
|
||||
[_before, item_xml, _after] = String.split(res, ~r(</?item>))
|
||||
|
||||
assert String.contains?(
|
||||
item_xml,
|
||||
~s(<itunes:image href="http://localhost:8945/media/#{media_item.uuid}/episode_image.jpg"></itunes:image>)
|
||||
)
|
||||
|
||||
assert String.contains?(
|
||||
item_xml,
|
||||
~s(<podcast:images srcset="http://localhost:8945/media/#{media_item.uuid}/episode_image.jpg" />)
|
||||
)
|
||||
end
|
||||
|
||||
test "does not return image tags if the media does not have a thumbnail", %{source: source} do
|
||||
media_item = media_item_with_attachments(%{source_id: source.id})
|
||||
File.rm!(media_item.thumbnail_filepath)
|
||||
|
||||
res = RssFeedBuilder.build(source)
|
||||
[_before, item_xml, _after] = String.split(res, ~r(</?item>))
|
||||
|
||||
refute String.contains?(item_xml, ~s(itunes:image))
|
||||
refute String.contains?(item_xml, ~s(podcast:images))
|
||||
end
|
||||
end
|
||||
|
||||
defp format_date(date) do
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
defmodule Pinchflat.ProfilesTest do
|
||||
use Pinchflat.DataCase
|
||||
|
||||
import Mox
|
||||
import Pinchflat.MediaFixtures
|
||||
import Pinchflat.SourcesFixtures
|
||||
import Pinchflat.ProfilesFixtures
|
||||
@@ -10,6 +11,16 @@ defmodule Pinchflat.ProfilesTest do
|
||||
|
||||
@invalid_attrs %{name: nil, output_path_template: nil}
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
describe "schema" do
|
||||
test "can be JSON encoded without error" do
|
||||
profile = media_profile_fixture()
|
||||
|
||||
assert {:ok, _} = Phoenix.json_library().encode(profile)
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_media_profiles/0" do
|
||||
test "it returns all media_profiles" do
|
||||
media_profile = media_profile_fixture()
|
||||
@@ -104,6 +115,12 @@ defmodule Pinchflat.ProfilesTest do
|
||||
end
|
||||
|
||||
describe "delete_media_profile/2 when deleting files" do
|
||||
setup do
|
||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "still deletes all the needful records" do
|
||||
media_profile = media_profile_fixture()
|
||||
source = source_fixture(media_profile_id: media_profile.id)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
defmodule Pinchflat.SlowIndexing.FileFollowerServerTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias alias Pinchflat.Utils.FilesystemUtils
|
||||
alias Pinchflat.SlowIndexing.FileFollowerServer
|
||||
|
||||
@@ -32,6 +32,12 @@ defmodule Pinchflat.SourcesTest do
|
||||
Repo.reload!(metadata)
|
||||
end
|
||||
end
|
||||
|
||||
test "can be JSON encoded without error" do
|
||||
source = source_fixture()
|
||||
|
||||
assert {:ok, _} = Phoenix.json_library().encode(source)
|
||||
end
|
||||
end
|
||||
|
||||
describe "output_path_template/1" do
|
||||
@@ -612,6 +618,12 @@ defmodule Pinchflat.SourcesTest do
|
||||
end
|
||||
|
||||
describe "delete_source/2 when deleting files" do
|
||||
setup do
|
||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "deletes source and media_items" do
|
||||
source = source_fixture()
|
||||
media_item = media_item_with_attachments(%{source_id: source.id})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
defmodule Pinchflat.Utils.ChangesetUtilsTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
defmodule MockSchema do
|
||||
use Ecto.Schema
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
defmodule Pinchflat.Utils.CliUtilsTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Pinchflat.Utils.CliUtils
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
defmodule Pinchflat.Utils.DatetimeUtilsTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Pinchflat.Utils.DatetimeUtils
|
||||
|
||||
|
||||
@@ -5,6 +5,38 @@ defmodule Pinchflat.Utils.FilesystemUtilsTest do
|
||||
|
||||
alias Pinchflat.Utils.FilesystemUtils
|
||||
|
||||
describe "exists_and_nonempty?" do
|
||||
test "returns true if a file exists and has contents" do
|
||||
filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||
File.write(filepath, "{}")
|
||||
|
||||
assert FilesystemUtils.exists_and_nonempty?(filepath)
|
||||
|
||||
File.rm!(filepath)
|
||||
end
|
||||
|
||||
test "returns false if a file doesn't exist" do
|
||||
refute FilesystemUtils.exists_and_nonempty?("/nonexistent/file.json")
|
||||
end
|
||||
|
||||
test "returns false if a file exists but is empty" do
|
||||
filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||
|
||||
refute FilesystemUtils.exists_and_nonempty?(filepath)
|
||||
|
||||
File.rm!(filepath)
|
||||
end
|
||||
|
||||
test "trims the contents before checking" do
|
||||
filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||
File.write(filepath, " \n\n \r\n ")
|
||||
|
||||
refute FilesystemUtils.exists_and_nonempty?(filepath)
|
||||
|
||||
File.rm!(filepath)
|
||||
end
|
||||
end
|
||||
|
||||
describe "generate_metadata_tmpfile/1" do
|
||||
test "creates a tmpfile and returns its path" do
|
||||
res = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
defmodule Pinchflat.Utils.FunctionUtilsTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Pinchflat.Utils.FunctionUtils
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
defmodule Pinchflat.Utils.NumberUtilsTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Pinchflat.Utils.NumberUtils
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
defmodule Pinchflat.Utils.StringUtilsTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Pinchflat.Utils.StringUtils
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
defmodule Pinchflat.Utils.XmlUtilsTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Pinchflat.Utils.XmlUtils
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
defmodule Pinchflat.YtDlp.CommandRunnerTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Pinchflat.Utils.FilesystemUtils
|
||||
|
||||
@@ -75,6 +75,9 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
|
||||
|
||||
refute String.contains?(output, "--cookies")
|
||||
refute String.contains?(output, cookie_file)
|
||||
|
||||
# Cleanup
|
||||
FilesystemUtils.write_p!(cookie_file, "")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
defmodule PinchflatWeb.ErrorHTMLTest do
|
||||
use PinchflatWeb.ConnCase, async: true
|
||||
use PinchflatWeb.ConnCase, async: false
|
||||
|
||||
# Bring render_to_string/4 for testing custom views
|
||||
import Phoenix.Template
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
defmodule PinchflatWeb.ErrorJSONTest do
|
||||
use PinchflatWeb.ConnCase, async: true
|
||||
use PinchflatWeb.ConnCase, async: false
|
||||
|
||||
test "renders 404" do
|
||||
assert PinchflatWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
defmodule PinchflatWeb.MediaItemControllerTest do
|
||||
use PinchflatWeb.ConnCase
|
||||
|
||||
import Mox
|
||||
import Pinchflat.MediaFixtures
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
describe "show media" do
|
||||
setup [:create_media_item]
|
||||
|
||||
@@ -49,6 +52,7 @@ defmodule PinchflatWeb.MediaItemControllerTest do
|
||||
describe "delete media" do
|
||||
setup do
|
||||
media_item = media_item_with_attachments()
|
||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||
|
||||
%{media_item: media_item}
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
defmodule PinchflatWeb.MediaProfileControllerTest do
|
||||
use PinchflatWeb.ConnCase
|
||||
|
||||
import Mox
|
||||
import Pinchflat.MediaFixtures
|
||||
import Pinchflat.SourcesFixtures
|
||||
import Pinchflat.ProfilesFixtures
|
||||
@@ -15,6 +16,8 @@ defmodule PinchflatWeb.MediaProfileControllerTest do
|
||||
}
|
||||
@invalid_attrs %{name: nil, output_path_template: nil}
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
setup do
|
||||
Settings.set(onboarding: false)
|
||||
|
||||
@@ -136,6 +139,12 @@ defmodule PinchflatWeb.MediaProfileControllerTest do
|
||||
describe "delete media_profile when deleting the records and files" do
|
||||
setup [:create_media_profile]
|
||||
|
||||
setup do
|
||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "deletes chosen media_profile and its associations", %{conn: conn, media_profile: media_profile} do
|
||||
source = source_fixture(media_profile_id: media_profile.id)
|
||||
media_item = media_item_with_attachments(%{source_id: source.id})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
defmodule PinchflatWeb.PodcastControllerTest do
|
||||
use PinchflatWeb.ConnCase
|
||||
|
||||
import Pinchflat.MediaFixtures
|
||||
import Pinchflat.SourcesFixtures
|
||||
|
||||
describe "rss_feed" do
|
||||
@@ -35,4 +36,25 @@ defmodule PinchflatWeb.PodcastControllerTest do
|
||||
assert conn.resp_body == "Image not found"
|
||||
end
|
||||
end
|
||||
|
||||
describe "episode_image" do
|
||||
test "returns an episode image if one can be found", %{conn: conn} do
|
||||
media_item = media_item_with_attachments()
|
||||
|
||||
conn = get(conn, ~p"/media/#{media_item.uuid}/episode_image" <> ".jpg")
|
||||
|
||||
assert conn.status == 200
|
||||
assert {"content-type", "image/jpeg; charset=utf-8"} in conn.resp_headers
|
||||
assert conn.resp_body == File.read!(media_item.thumbnail_filepath)
|
||||
end
|
||||
|
||||
test "returns 404 if an image cannot be found", %{conn: conn} do
|
||||
media_item = media_item_fixture()
|
||||
|
||||
conn = get(conn, ~p"/media/#{media_item.uuid}/episode_image" <> ".jpg")
|
||||
|
||||
assert conn.status == 404
|
||||
assert conn.resp_body == "Image not found"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -145,6 +145,12 @@ defmodule PinchflatWeb.SourceControllerTest do
|
||||
describe "delete source when deleting the records and files" do
|
||||
setup [:create_source]
|
||||
|
||||
setup do
|
||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "deletes chosen source and media_items", %{conn: conn, source: source, media_item: media_item} do
|
||||
delete(conn, ~p"/sources/#{source}?delete_files=true")
|
||||
|
||||
|
||||
@@ -67,16 +67,24 @@ defmodule Pinchflat.MediaFixtures do
|
||||
end
|
||||
|
||||
def media_item_with_attachments(attrs \\ %{}) do
|
||||
stored_media_filepath =
|
||||
base_dir =
|
||||
Path.join([
|
||||
Application.get_env(:pinchflat, :media_directory),
|
||||
"#{:rand.uniform(1_000_000)}",
|
||||
"#{:rand.uniform(1_000_000)}_media.mp4"
|
||||
"#{:rand.uniform(1_000_000)}"
|
||||
])
|
||||
|
||||
stored_media_filepath = Path.join(base_dir, "#media.mp4")
|
||||
thumbnail_filepath = Path.join(base_dir, "thumbnail.jpg")
|
||||
|
||||
FilesystemUtils.cp_p!(media_filepath_fixture(), stored_media_filepath)
|
||||
FilesystemUtils.cp_p!(thumbnail_filepath_fixture(), thumbnail_filepath)
|
||||
|
||||
merged_attrs =
|
||||
Map.merge(attrs, %{
|
||||
media_filepath: stored_media_filepath,
|
||||
thumbnail_filepath: thumbnail_filepath
|
||||
})
|
||||
|
||||
merged_attrs = Map.merge(attrs, %{media_filepath: stored_media_filepath})
|
||||
media_item_fixture(merged_attrs)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
Mox.defmock(YtDlpRunnerMock, for: Pinchflat.YtDlp.YtDlpCommandRunner)
|
||||
Application.put_env(:pinchflat, :yt_dlp_runner, YtDlpRunnerMock)
|
||||
|
||||
Mox.defmock(AppriseRunnerMock, for: Pinchflat.Notifications.AppriseCommandRunner)
|
||||
Mox.defmock(AppriseRunnerMock, for: Pinchflat.Lifecycle.Notifications.AppriseCommandRunner)
|
||||
Application.put_env(:pinchflat, :apprise_runner, AppriseRunnerMock)
|
||||
|
||||
Mox.defmock(HTTPClientMock, for: Pinchflat.HTTP.HTTPBehaviour)
|
||||
Application.put_env(:pinchflat, :http_client, HTTPClientMock)
|
||||
|
||||
Mox.defmock(UserScriptRunnerMock, for: Pinchflat.Lifecycle.UserScripts.UserScriptCommandRunner)
|
||||
Application.put_env(:pinchflat, :user_script_runner, UserScriptRunnerMock)
|
||||
|
||||
ExUnit.start()
|
||||
Ecto.Adapters.SQL.Sandbox.mode(Pinchflat.Repo, :manual)
|
||||
Faker.start()
|
||||
|
||||
Reference in New Issue
Block a user