Compare commits

...

17 Commits

Author SHA1 Message Date
Kieran Eglin d8fdba8f6d Bumped version 2025-01-03 15:40:36 -08:00
Kieran e4c186eacb Added IDs as an option for output templates (#543) 2025-01-03 10:50:43 -08:00
Kieran e150355874 Added the ability to mark chapters via sponsorblock (#542) 2025-01-03 09:53:38 -08:00
Kieran Eglin 967e21a8a1 Removed double alias 2025-01-03 09:13:58 -08:00
Kieran 9185f075ca [Enhancement] Overhaul indexing to be more efficient (#540)
* WIP - created methods for breaking on existing media

* WIP - got everything hooked up for POC

* Add some docs, tests

* Refactors

* Updated TODO
2025-01-02 15:48:18 -08:00
Kieran Eglin 09d1653f4b Removed testing route from router 2024-12-30 17:44:45 -08:00
Kieran Eglin 115f675ae3 Bumped version 2024-12-30 17:42:18 -08:00
Kieran f51b219860 [Bugfix] Improve OPML route security (#535)
* WIP - moved plugs; set up a new token-protected route plug

* Added a route_token column to settings model

* Hooked up token_protected_route plug to database

* Hooked up new OPML route to UI; turned RSS and OPML feed buttons into links

* Docs, tests

* Added a note about the phoenix bug
2024-12-30 17:40:23 -08:00
Kieran Eglin 246ca3b299 Bumped version 2024-12-29 10:14:03 -08:00
Robert Kleinschuster c9bd1ea7bd Added OPML Endpoint for podcast rss feeds (#512)
* Added OPML Endpoint for podcast rss feeds

* changed opml route and added controller test for opml endpoint

* add copy opml feed button

* add copy opml feed button - correct url

* fix html indenting

* add indentation to opml

Co-authored-by: Kieran <kieran.eglin@gmail.com>

* use convention for unused controller params

Co-authored-by: Kieran <kieran.eglin@gmail.com>

* add test for opml_sources helper function

* change opml endpoint to be more inline with the other routes

---------

Co-authored-by: robs <git@robs.social>
Co-authored-by: Kieran <kieran.eglin@gmail.com>
2024-12-20 11:47:03 -08:00
Kieran a2a70fcce2 [Bugfix] Respect cookies preference when performing download pre-check (#517)
* Updated get_downloadable_status to pass yt cookies

* Updated tests
2024-12-17 11:18:47 -08:00
Kieran Eglin 0be469dcb0 Added --warnings-as-errors to mix test but only when called by ex_check 2024-12-13 12:41:06 -08:00
Kieran 023f449dbe [Housekeeping] Pass the current action when calling the yt-dlp runner (#514)
* Updated yt-dlp runner to take an action type

* Added actions to all callers of the yt-dlp runner

* [SQUASH] updated test files to use new mocking strategy

* Removed unneeded alias
2024-12-13 12:29:05 -08:00
Robert Kleinschuster e9d365ee9e use css truncate everywhere and remove StringUtils.truncate function (#513)
Co-authored-by: robs <git@robs.social>
2024-12-13 12:23:12 -08:00
Kieran 53e106dac2 [Enhancement] Add sorting, pagination, and new attributes to sources index table (#510)
* WIP - started improving handling of sorting for sources index table

* WIP - Added UI to table to indicate sort column and direction

* Refactored toggle liveview into a livecomponent

* Added sorting for all table attrs

* Added pagination to the sources table

* Added tests for updated liveviews and live components

* Add tests for new helper methods

* Added fancy new CSS to my sources table

* Added size to sources table

* Adds relative div to ensure that sorting arrow doesn't run away

* Fixed da tests
2024-12-13 09:49:00 -08:00
Robert Kleinschuster e56f39a158 Separate tabs for pending and downloaded in media history (#508)
* Separate tabs for pending and downloaded in media history
 - closes #504

* correct query for pending state and remove unecessary assign

* correct pending where

---------

Co-authored-by: robs <git@robs.social>
2024-12-13 08:58:42 -08:00
Robert Kleinschuster 01c9afa1c3 truncate table columns via css (#507)
- closes #506

Co-authored-by: robs <git@robs.social>
2024-12-13 08:57:32 -08:00
80 changed files with 1743 additions and 692 deletions
+5 -1
View File
@@ -40,5 +40,9 @@ window.dispatchFor = (elementOrId, eventName, detail = {}) => {
const element =
typeof elementOrId === 'string' ? document.getElementById(elementOrId) : elementOrId
element.dispatchEvent(new CustomEvent(eventName, { detail }))
// This is needed to ensure the DOM has updated before dispatching the event.
// Doing so ensures that the latest DOM state is what's sent to the server
setTimeout(() => {
element.dispatchEvent(new Event(eventName, { bubbles: true, detail }))
}, 0)
}
-23
View File
@@ -47,29 +47,6 @@ let liveSocket = new LiveSocket(document.body.dataset.socketPath, Socket, {
}
})
}
},
'formless-input': {
mounted() {
const subscribedEvents = this.el.dataset.subscribe.split(' ')
const eventName = this.el.dataset.eventName || ''
const identifier = this.el.dataset.identifier || ''
subscribedEvents.forEach((domEvent) => {
this.el.addEventListener(domEvent, () => {
// This ensures that the event is pushed to the server after the input value has been updated
// so that the server has the most up-to-date value
setTimeout(() => {
this.pushEvent('formless-input', {
value: this.el.value,
id: identifier,
event: eventName,
dom_id: this.el.id,
dom_event: domEvent
})
}, 0)
})
})
}
}
}
})
@@ -152,6 +152,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
case {behaviour, categories} do
{_, []} -> []
{:remove, _} -> [sponsorblock_remove: Enum.join(categories, ",")]
{:mark, _} -> [sponsorblock_mark: Enum.join(categories, ",")]
{:disabled, _} -> []
end
end
@@ -200,6 +201,9 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
source = media_item_with_preloads.source
%{
"media_item_id" => to_string(media_item_with_preloads.id),
"source_id" => to_string(source.id),
"media_profile_id" => to_string(source.media_profile_id),
"source_custom_name" => source.custom_name,
"source_collection_id" => source.collection_id,
"source_collection_name" => source.collection_name,
@@ -113,9 +113,10 @@ defmodule Pinchflat.Downloading.MediaDownloader do
defp download_with_options(url, item_with_preloads, output_filepath, override_opts) do
{:ok, options} = DownloadOptionBuilder.build(item_with_preloads, override_opts)
runner_opts = [output_filepath: output_filepath, use_cookies: item_with_preloads.source.use_cookies]
use_cookies = item_with_preloads.source.use_cookies
runner_opts = [output_filepath: output_filepath, use_cookies: use_cookies]
case YtDlpMedia.get_downloadable_status(url) do
case YtDlpMedia.get_downloadable_status(url, use_cookies: use_cookies) do
{:ok, :downloadable} -> YtDlpMedia.download(url, options, runner_opts)
{:ok, :ignorable} -> {:error, :unsuitable_for_download}
err -> err
@@ -0,0 +1,40 @@
defmodule Pinchflat.Podcasts.OpmlFeedBuilder do
@moduledoc """
Methods for building an OPML feed for a list of sources.
"""
import Pinchflat.Utils.XmlUtils, only: [safe: 1]
alias PinchflatWeb.Router.Helpers, as: Routes
@doc """
Builds an OPML feed for a given list of sources.
Returns an XML document as a string.
"""
def build(url_base, sources) do
sources_xml =
Enum.map(
sources,
&"""
<outline type="rss" text="#{safe(&1.custom_name)}" xmlUrl="#{safe(source_route(url_base, &1))}" />
"""
)
"""
<?xml version="1.0" encoding="UTF-8"?>
<opml version="2.0">
<head>
<title>All Sources</title>
</head>
<body>
#{Enum.join(sources_xml, "\n")}
</body>
</opml>
"""
end
defp source_route(url_base, source) do
Path.join(url_base, "#{Routes.podcast_path(PinchflatWeb.Endpoint, :rss_feed, source.uuid)}.xml")
end
end
+14
View File
@@ -5,11 +5,25 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
"""
use Pinchflat.Media.MediaQuery
use Pinchflat.Sources.SourcesQuery
alias Pinchflat.Repo
alias Pinchflat.Metadata.MediaMetadata
alias Pinchflat.Metadata.SourceMetadata
@doc """
Returns a list of sources that are not marked for deletion.
Returns: [%Source{}]
"""
def opml_sources() do
SourcesQuery.new()
|> select([s], %{custom_name: s.custom_name, uuid: s.uuid})
|> where([s], is_nil(s.marked_for_deletion_at))
|> order_by(asc: :custom_name)
|> Repo.all()
end
@doc """
Returns a list of media items that have been downloaded to disk
and have been proven to still exist there.
+1 -1
View File
@@ -55,7 +55,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
field :embed_metadata, :boolean, default: false
field :download_nfo, :boolean, default: false
field :sponsorblock_behaviour, Ecto.Enum, values: [:disabled, :remove], default: :disabled
field :sponsorblock_behaviour, Ecto.Enum, values: [:disabled, :mark, :remove], default: :disabled
field :sponsorblock_categories, {:array, :string}, default: []
# NOTE: these do NOT speed up indexing - the indexer still has to go
# through the entire collection to determine if a media is a short or
+1
View File
@@ -31,6 +31,7 @@ defmodule Pinchflat.Settings.Setting do
field :apprise_version, :string
field :apprise_server, :string
field :youtube_api_key, :string
field :route_token, :string
field :video_codec_preference, :string
field :audio_codec_preference, :string
@@ -79,21 +79,21 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
case {source.index_frequency_minutes, source.last_indexed_at} do
{index_freq, _} when index_freq > 0 ->
# If the indexing is on a schedule simply run indexing and reschedule
perform_indexing_and_notification(source)
perform_indexing_and_notification(source, was_forced: args["force"])
maybe_enqueue_fast_indexing_task(source)
reschedule_indexing(source)
{_, nil} ->
# If the source has never been indexed, index it once
# even if it's not meant to reschedule
perform_indexing_and_notification(source)
perform_indexing_and_notification(source, was_forced: args["force"])
:ok
_ ->
# If the source HAS been indexed and is not meant to reschedule,
# perform a no-op (unless forced)
if args["force"] do
perform_indexing_and_notification(source)
perform_indexing_and_notification(source, was_forced: true)
end
:ok
@@ -103,11 +103,11 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
end
defp perform_indexing_and_notification(source) do
defp perform_indexing_and_notification(source, indexing_opts) 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)
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source, indexing_opts)
end)
end
@@ -5,6 +5,8 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
Many of these methods are made to be kickoff or be consumed by workers.
"""
use Pinchflat.Media.MediaQuery
require Logger
alias Pinchflat.Repo
@@ -14,6 +16,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem
alias Pinchflat.YtDlp.MediaCollection
alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.Downloading.DownloadingHelpers
alias Pinchflat.SlowIndexing.FileFollowerServer
alias Pinchflat.Downloading.DownloadOptionBuilder
@@ -22,13 +25,19 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
@doc """
Starts tasks for indexing a source's media regardless of the source's indexing
frequency. It's assumed the caller will check for indexing frequency.
Kills old indexing tasks and starts a new task to index the media collection.
The job is delayed based on the source's `index_frequency_minutes` setting unless
one of the following is true:
- The `force` option is set to true
- The source has never been indexed before
- The source has been indexed before, but the last indexing job was more than
`index_frequency_minutes` ago
Returns {:ok, %Task{}}
"""
def kickoff_indexing_task(%Source{} = source, job_args \\ %{}, job_opts \\ []) do
job_offset_seconds = calculate_job_offset_seconds(source)
job_offset_seconds = if job_args[:force], do: 0, else: calculate_job_offset_seconds(source)
Tasks.delete_pending_tasks_for(source, "FastIndexingWorker")
Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker", include_executing: true)
@@ -52,8 +61,8 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
@doc """
Given a media source, creates (indexes) the media by creating media_items for each
media ID in the source. Afterward, kicks off a download task for each pending media
item belonging to the source. You can't tell me the method name isn't descriptive!
Returns a list of media items or changesets (if the media item couldn't be created).
item belonging to the source. Returns a list of media items or changesets
(if the media item couldn't be created).
Indexing is slow and usually returns a list of all media data at once for record creation.
To help with this, we use a file follower to watch the file that yt-dlp writes to
@@ -61,23 +70,33 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
clarity to the user experience. This has a few things to be aware of which are documented
below in the file watcher setup method.
Additionally, in the case of a repeat index we create a download archive file that
contains some media IDs that we've indexed in the past. Note that this archive doesn't
contain the most recent IDs but rather a subset of IDs that are offset by some amount.
Practically, this means that we'll re-index a small handful of media that we've recently
indexed, but this is a good thing since it'll let us pick up on any recent changes to the
most recent media items.
We don't create a download archive for playlists (only channels), nor do we create one if
the indexing was forced by the user.
NOTE: downloads are only enqueued if the source is set to download media. Downloads are
also enqueued for ALL pending media items, not just the ones that were indexed in this
job run. This should ensure that any stragglers are caught if, for some reason, they
weren't enqueued or somehow got de-queued.
Since indexing returns all media data EVERY TIME, we that that opportunity to update
indexing metadata for media items that have already been created.
Available options:
- `was_forced`: Whether the indexing was forced by the user
Returns [%MediaItem{} | %Ecto.Changeset{}]
"""
def index_and_enqueue_download_for_media_items(%Source{} = source) do
def index_and_enqueue_download_for_media_items(%Source{} = source, opts \\ []) do
# The media_profile is needed to determine the quality options to _then_ determine a more
# accurate predicted filepath
source = Repo.preload(source, [:media_profile])
# See the method definition below for more info on how file watchers work
# (important reading if you're not familiar with it)
{:ok, media_attributes} = setup_file_watcher_and_kickoff_indexing(source)
{:ok, media_attributes} = setup_file_watcher_and_kickoff_indexing(source, opts)
# Reload because the source may have been updated during the (long-running) indexing process
# and important settings like `download_media` may have changed.
source = Repo.reload!(source)
@@ -109,14 +128,16 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
# It attempts a graceful shutdown of the file follower after the indexing is done,
# but the FileFollowerServer will also stop itself if it doesn't see any activity
# for a sufficiently long time.
defp setup_file_watcher_and_kickoff_indexing(source) do
defp setup_file_watcher_and_kickoff_indexing(source, opts) do
was_forced = Keyword.get(opts, :was_forced, false)
{:ok, pid} = FileFollowerServer.start_link()
handler = fn filepath -> setup_file_follower_watcher(pid, filepath, source) end
command_opts =
[output: DownloadOptionBuilder.build_output_path_for(source)] ++
DownloadOptionBuilder.build_quality_options_for(source)
DownloadOptionBuilder.build_quality_options_for(source) ++
build_download_archive_options(source, was_forced)
runner_opts = [file_listener_handler: handler, use_cookies: source.use_cookies]
result = MediaCollection.get_media_attributes_for_collection(source.original_url, command_opts, runner_opts)
@@ -166,4 +187,57 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
max(0, index_frequency_seconds - offset_seconds)
end
# The download archive file works in tandem with --break-on-existing to stop
# yt-dlp once we've hit media items we've already indexed. But we generate
# this list with a bit of an offset so we do intentionally re-scan some media
# items to pick up any recent changes (see `get_media_items_for_download_archive`).
#
# From there, we format the media IDs in the way that yt-dlp expects (ie: "<extractor> <media_id>")
# and return the filepath to the caller.
defp create_download_archive_file(source) do
tmpfile = FilesystemUtils.generate_metadata_tmpfile(:txt)
archive_contents =
source
|> get_media_items_for_download_archive()
|> Enum.map_join("\n", fn media_item -> "youtube #{media_item.media_id}" end)
case File.write(tmpfile, archive_contents) do
:ok -> tmpfile
err -> err
end
end
# Sorting by `uploaded_at` is important because we want to re-index the most recent
# media items first but there is no guarantee of any correlation between ID and uploaded_at.
#
# The offset is important because we want to re-index some media items that we've
# recently indexed to pick up on any changes. The limit is because we want this mechanism
# to work even if, for example, the video we were using as a stopping point was deleted.
# It's not a perfect system, but it should do well enough.
#
# The chosen limit and offset are arbitary, independent, and vibes-based. Feel free to
# tweak as-needed
defp get_media_items_for_download_archive(source) do
MediaQuery.new()
|> where(^MediaQuery.for_source(source))
|> order_by(desc: :uploaded_at)
|> limit(50)
|> offset(20)
|> Repo.all()
end
# The download archive isn't useful for playlists (since those are ordered arbitrarily)
# and we don't want to use it if the indexing was forced by the user. In other words,
# only create an archive for channels that are being indexed as part of their regular
# indexing schedule
defp build_download_archive_options(%Source{collection_type: :playlist}, _was_forced), do: []
defp build_download_archive_options(_source, true), do: []
defp build_download_archive_options(source, _was_forced) do
archive_file = create_download_archive_file(source)
[:break_on_existing, download_archive: archive_file]
end
end
+13 -1
View File
@@ -45,8 +45,20 @@ defmodule Pinchflat.Utils.FilesystemUtils do
Returns binary()
"""
def generate_metadata_tmpfile(type) do
filename = StringUtils.random_string(64)
# This "namespacing" is more to help with development since things get
# weird in my editor when there are thousands of files in a single directory
first_two = String.slice(filename, 0..1)
second_two = String.slice(filename, 2..3)
tmpfile_directory = Application.get_env(:pinchflat, :tmpfile_directory)
filepath = Path.join([tmpfile_directory, "#{StringUtils.random_string(64)}.#{type}"])
filepath =
Path.join([
tmpfile_directory,
first_two,
second_two,
"#{filename}.#{type}"
])
:ok = write_p!(filepath, "")
-17
View File
@@ -25,23 +25,6 @@ defmodule Pinchflat.Utils.StringUtils do
|> String.slice(0..(length - 1))
end
@doc """
Truncates a string to the given length and adds `...` if the string is longer than the given length.
Will break on a word boundary. Nothing happens if the string is shorter than the given length.
Returns binary()
"""
def truncate(string, length) do
if String.length(string) > length do
string
|> String.slice(0..(length - 1))
|> String.replace(~r/\s+\S*$/, "")
|> Kernel.<>("...")
else
string
end
end
@doc """
Wraps a string in double braces. Useful as a UI helper now that
LiveView 1.0.0 allows `{}` for interpolation so now we can't use braces
+11 -2
View File
@@ -3,6 +3,8 @@ defmodule Pinchflat.YtDlp.CommandRunner do
Runs yt-dlp commands using the `System.cmd/3` function
"""
require Logger
alias Pinchflat.Utils.CliUtils
alias Pinchflat.YtDlp.YtDlpCommandRunner
alias Pinchflat.Utils.FilesystemUtils, as: FSUtils
@@ -24,7 +26,8 @@ defmodule Pinchflat.YtDlp.CommandRunner do
Returns {:ok, binary()} | {:error, output, status}.
"""
@impl YtDlpCommandRunner
def run(url, command_opts, output_template, addl_opts \\ []) do
def run(url, action_name, command_opts, output_template, addl_opts \\ []) do
Logger.debug("Running yt-dlp command for action: #{action_name}")
# This approach lets us mock the command for testing
command = backend_executable()
@@ -36,7 +39,13 @@ defmodule Pinchflat.YtDlp.CommandRunner do
formatted_command_opts = [url] ++ CliUtils.parse_options(all_opts)
case CliUtils.wrap_cmd(command, formatted_command_opts, stderr_to_stdout: true) do
{_, 0} ->
# yt-dlp exit codes:
# 0 = Everything is successful
# 100 = yt-dlp must restart for update to complete
# 101 = Download cancelled by --max-downloads etc
# 2 = Error in user-provided options
# 1 = Any other error
{_, status} when status in [0, 101] ->
# IDEA: consider deleting the file after reading it. It's in the tmp dir, so it's not
# a huge deal, but it's still a good idea to clean up after ourselves.
# (even on error? especially on error?)
+8 -5
View File
@@ -41,7 +41,7 @@ defmodule Pinchflat.YtDlp.Media do
def download(url, command_opts \\ [], addl_opts \\ []) do
all_command_opts = [:no_simulate] ++ command_opts
with {:ok, output} <- backend_runner().run(url, all_command_opts, "after_move:%()j", addl_opts),
with {:ok, output} <- backend_runner().run(url, :download, all_command_opts, "after_move:%()j", addl_opts),
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
{:ok, parsed_json}
else
@@ -55,8 +55,11 @@ defmodule Pinchflat.YtDlp.Media do
Returns {:ok, :downloadable | :ignorable} | {:error, any}
"""
def get_downloadable_status(url) do
case backend_runner().run(url, [:simulate, :skip_download], "%(.{live_status})j") do
def get_downloadable_status(url, addl_opts \\ []) do
action = :get_downloadable_status
command_opts = [:simulate, :skip_download]
case backend_runner().run(url, action, command_opts, "%(.{live_status})j", addl_opts) do
{:ok, output} ->
output
|> Phoenix.json_library().decode!()
@@ -78,7 +81,7 @@ defmodule Pinchflat.YtDlp.Media do
# NOTE: it doesn't seem like this command actually returns anything in `after_move` since
# we aren't downloading the main media file
backend_runner().run(url, all_command_opts, "after_move:%()j", addl_opts)
backend_runner().run(url, :download_thumbnail, all_command_opts, "after_move:%()j", addl_opts)
end
@doc """
@@ -92,7 +95,7 @@ defmodule Pinchflat.YtDlp.Media do
all_command_opts = [:simulate, :skip_download] ++ command_opts
output_template = indexing_output_template()
case backend_runner().run(url, all_command_opts, output_template, addl_opts) do
case backend_runner().run(url, :get_media_attributes, all_command_opts, output_template, addl_opts) do
{:ok, output} ->
output
|> Phoenix.json_library().decode!()
+6 -4
View File
@@ -23,7 +23,6 @@ defmodule Pinchflat.YtDlp.MediaCollection do
Returns {:ok, [map()]} | {:error, any, ...}.
"""
def get_media_attributes_for_collection(url, command_opts \\ [], addl_opts \\ []) do
runner = Application.get_env(:pinchflat, :yt_dlp_runner)
# `ignore_no_formats_error` is necessary because yt-dlp will error out if
# the first video has not released yet (ie: is a premier). We don't care about
# available formats since we're just getting the media details
@@ -33,12 +32,13 @@ defmodule Pinchflat.YtDlp.MediaCollection do
output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
file_listener_handler = Keyword.get(addl_opts, :file_listener_handler, false)
runner_opts = [output_filepath: output_filepath, use_cookies: use_cookies]
action = :get_media_attributes_for_collection
if file_listener_handler do
file_listener_handler.(output_filepath)
end
case runner.run(url, all_command_opts, output_template, runner_opts) do
case backend_runner().run(url, action, all_command_opts, output_template, runner_opts) do
{:ok, output} ->
parsed_lines =
output
@@ -82,8 +82,9 @@ defmodule Pinchflat.YtDlp.MediaCollection do
all_command_opts = default_opts ++ command_opts
output_template = "%(.{channel,channel_id,playlist_id,playlist_title,filename})j"
action = :get_source_details
with {:ok, output} <- backend_runner().run(source_url, all_command_opts, output_template, addl_opts),
with {:ok, output} <- backend_runner().run(source_url, action, all_command_opts, output_template, addl_opts),
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
{:ok, format_source_details(parsed_json)}
else
@@ -121,8 +122,9 @@ defmodule Pinchflat.YtDlp.MediaCollection do
all_command_opts = [:skip_download] ++ command_opts
output_template = "playlist:%()j"
action = :get_source_metadata
with {:ok, output} <- backend_runner().run(source_url, all_command_opts, output_template, addl_opts),
with {:ok, output} <- backend_runner().run(source_url, action, all_command_opts, output_template, addl_opts),
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
{:ok, parsed_json}
else
@@ -6,7 +6,7 @@ defmodule Pinchflat.YtDlp.YtDlpCommandRunner do
yt-dlp command.
"""
@callback run(binary(), keyword(), binary()) :: {:ok, binary()} | {:error, binary(), integer()}
@callback run(binary(), keyword(), binary(), keyword()) :: {:ok, binary()} | {:error, binary(), integer()}
@callback run(binary(), atom(), keyword(), binary()) :: {:ok, binary()} | {:error, binary(), integer()}
@callback run(binary(), atom(), keyword(), binary(), keyword()) :: {:ok, binary()} | {:error, binary(), integer()}
@callback version() :: {:ok, binary()} | {:error, binary()}
end
@@ -26,7 +26,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
~H"""
<button
class={[
"text-center font-medium text-white",
"text-center font-medium text-white whitespace-nowrap",
"#{@rounding} inline-flex items-center justify-center px-8 py-4",
"#{@color}",
"hover:bg-opacity-90 lg:px-8 xl:px-10",
@@ -16,6 +16,8 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
"""
attr :rows, :list, required: true
attr :table_class, :string, default: ""
attr :sort_key, :string, default: nil
attr :sort_direction, :string, default: nil
attr :row_item, :any,
default: &Function.identity/1,
@@ -24,6 +26,7 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
slot :col, required: true do
attr :label, :string
attr :class, :string
attr :sort_key, :string
end
def table(assigns) do
@@ -31,8 +34,20 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
<table class={["w-full table-auto bg-boxdark", @table_class]}>
<thead>
<tr class="text-left bg-meta-4">
<th :for={col <- @col} class="px-4 py-4 font-medium text-white xl:pl-11">
{col[:label]}
<th
:for={col <- @col}
class={["px-4 py-4 font-medium text-white", col[:sort_key] && "cursor-pointer"]}
phx-click={col[:sort_key] && "sort_update"}
phx-value-sort_key={col[:sort_key]}
>
<div class="relative">
{col[:label]}
<.icon
:if={to_string(@sort_key) == col[:sort_key]}
name={if @sort_direction == :asc, do: "hero-chevron-up", else: "hero-chevron-down"}
class="w-3 h-3 mt-2 ml-1 absolute"
/>
</div>
</th>
</tr>
</thead>
@@ -41,7 +56,7 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
<td
:for={col <- @col}
class={[
"px-4 py-5 pl-9 xl:pl-11",
"px-4 py-5",
col[:class]
]}
>
@@ -70,9 +85,9 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
<li>
<span
class={[
"flex h-8 w-8 items-center justify-center rounded",
"pagination-prev h-8 w-8 items-center justify-center rounded",
@page_number != 1 && "cursor-pointer hover:bg-primary hover:text-white",
@page_number == 1 && "cursor-not-allowed"
@page_number <= 1 && "cursor-not-allowed"
]}
phx-click={@page_number != 1 && "page_change"}
phx-value-direction="dec"
@@ -88,9 +103,9 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
<li>
<span
class={[
"flex h-8 w-8 items-center justify-center rounded",
"pagination-next flex h-8 w-8 items-center justify-center rounded",
@page_number != @total_pages && "cursor-pointer hover:bg-primary hover:text-white",
@page_number == @total_pages && "cursor-not-allowed"
@page_number >= @total_pages && "cursor-not-allowed"
]}
phx-click={@page_number != @total_pages && "page_change"}
phx-value-direction="inc"
@@ -2,6 +2,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
@moduledoc false
use Phoenix.Component
alias Pinchflat.Utils.NumberUtils
alias PinchflatWeb.CoreComponents
@doc """
@@ -125,4 +126,24 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
{@word}{if @count == 1, do: "", else: @suffix}
"""
end
@doc """
Renders a human-readable byte size
"""
attr :byte_size, :integer, required: true
def readable_filesize(assigns) do
{num, suffix} = NumberUtils.human_byte_size(assigns.byte_size, precision: 2)
assigns =
Map.merge(assigns, %{
num: num,
suffix: suffix
})
~H"""
<.localized_number number={@num} /> {@suffix}
"""
end
end
@@ -1,6 +1,6 @@
<div class="mb-6 flex gap-3 flex-row items-center">
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
Editing "{StringUtils.truncate(@media_item.title, 35)}"
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4 truncate">
Editing "{@media_item.title}"
</h2>
</div>
@@ -1,10 +1,10 @@
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
<div class="flex gap-3 items-center">
<div class="flex gap-3 items-center min-w-0">
<.link href={~p"/sources/#{@media_item.source_id}"}>
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
{StringUtils.truncate(@media_item.title, 35)}
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4 truncate">
{@media_item.title}
</h2>
</div>
@@ -38,6 +38,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
def friendly_sponsorblock_options do
[
{"Disabled (default)", "disabled"},
{"Mark Segments as Chapters", "mark"},
{"Remove Segments", "remove"}
]
end
@@ -56,7 +57,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
end
def media_center_custom_output_template_options do
%{
[
season_by_year__episode_by_date: "<code>Season YYYY/sYYYYeMMDD</code>",
season_by_year__episode_by_date_and_index:
"same as the above but it handles dates better. <strong>This is the recommended option</strong>",
@@ -64,11 +65,11 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
"<code>Season 1/s01eXX</code> where <code>XX</code> is the video's position in the playlist. Only recommended for playlists (not channels) that don't change",
static_season__episode_by_date:
"<code>Season 1/s01eYYMMDD</code>. Recommended for playlists that might change or where order isn't important"
}
]
end
def other_custom_output_template_options do
%{
[
upload_day: nil,
upload_month: nil,
upload_year: nil,
@@ -84,8 +85,11 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
season_episode_index_from_date:
"the upload date formatted as <code>sYYYYeMMDDII</code> where <code>II</code> is an index to prevent date collisions",
media_playlist_index:
"the place of the media item in the playlist. Do not use with channels. May not work if the playlist is updated"
}
"the place of the media item in the playlist. Do not use with channels. May not work if the playlist is updated",
media_item_id: "the ID of the media item in Pinchflat's database",
source_id: "the ID of the source in Pinchflat's database",
media_profile_id: "the ID of the media profile in Pinchflat's database"
]
end
def common_output_template_options do
@@ -2,7 +2,7 @@
<.link href={~p"/media_profiles"}>
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4 truncate">
Editing "{@media_profile.name}"
</h2>
</div>
@@ -14,7 +14,7 @@
<div class="max-w-full overflow-x-auto">
<div class="flex flex-col gap-10 min-w-max">
<.table rows={@media_profiles} table_class="text-black dark:text-white">
<:col :let={media_profile} label="Name">
<:col :let={media_profile} label="Name" class="truncate max-w-xs">
<.subtle_link href={~p"/media_profiles/#{media_profile.id}"}>
{media_profile.name}
</.subtle_link>
@@ -1,9 +1,9 @@
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
<div class="flex items-center">
<div class="flex items-center min-w-0">
<.link href={~p"/media_profiles"}>
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-2">
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-2 truncate">
{@media_profile.name}
</h2>
</div>
@@ -1,23 +1,5 @@
defmodule PinchflatWeb.Pages.PageHTML do
use PinchflatWeb, :html
alias Pinchflat.Utils.NumberUtils
embed_templates "page_html/*"
attr :media_filesize, :integer, required: true
def readable_media_filesize(assigns) do
{num, suffix} = NumberUtils.human_byte_size(assigns.media_filesize, precision: 2)
assigns =
Map.merge(assigns, %{
num: num,
suffix: suffix
})
~H"""
<.localized_number number={@num} /> {@suffix}
"""
end
end
@@ -28,9 +28,9 @@ defmodule Pinchflat.Pages.HistoryTableLive do
</span>
<div class="max-w-full overflow-x-auto">
<.table rows={@records} table_class="text-white">
<:col :let={media_item} label="Title">
<:col :let={media_item} label="Title" class="truncate max-w-xs">
<.subtle_link href={~p"/sources/#{media_item.source_id}/media/#{media_item}"}>
{StringUtils.truncate(media_item.title, 35)}
{media_item.title}
</.subtle_link>
</:col>
<:col :let={media_item} label="Upload Date">
@@ -42,9 +42,9 @@ defmodule Pinchflat.Pages.HistoryTableLive do
<:col :let={media_item} label="Downloaded At">
{format_datetime(media_item.media_downloaded_at)}
</:col>
<:col :let={media_item} label="Source">
<:col :let={media_item} label="Source" class="truncate max-w-xs">
<.subtle_link href={~p"/sources/#{media_item.source_id}"}>
{StringUtils.truncate(media_item.source.custom_name, 35)}
{media_item.source.custom_name}
</.subtle_link>
</:col>
</.table>
@@ -56,9 +56,9 @@ defmodule Pinchflat.Pages.HistoryTableLive do
"""
end
def mount(_params, _session, socket) do
def mount(_params, session, socket) do
page = 1
base_query = generate_base_query()
base_query = generate_base_query(session["media_state"])
pagination_attrs = fetch_pagination_attributes(base_query, page)
{:ok, assign(socket, Map.merge(pagination_attrs, %{base_query: base_query}))}
@@ -97,10 +97,17 @@ defmodule Pinchflat.Pages.HistoryTableLive do
|> Repo.preload(:source)
end
defp generate_base_query do
defp generate_base_query("pending") do
MediaQuery.new()
|> MediaQuery.require_assoc(:media_profile)
|> where(^dynamic(^MediaQuery.downloaded() or ^MediaQuery.pending()))
|> where(^dynamic(^MediaQuery.pending()))
|> order_by(desc: :id)
end
defp generate_base_query("downloaded") do
MediaQuery.new()
|> MediaQuery.require_assoc(:media_profile)
|> where(^dynamic(^MediaQuery.downloaded()))
|> order_by(desc: :id)
end
@@ -33,7 +33,7 @@
<span class="flex flex-col items-center py-2">
<span class="text-md font-medium">Library Size</span>
<h4 class="text-title-md font-bold text-white">
<.readable_media_filesize media_filesize={@media_item_size} />
<.readable_filesize byte_size={@media_item_size} />
</h4>
</span>
</div>
@@ -41,9 +41,22 @@
<div class="rounded-sm border shadow-default border-strokedark bg-boxdark mt-4 p-5">
<span class="text-2xl font-medium mb-4">Media History</span>
<section class="mt-6">
{live_render(@conn, Pinchflat.Pages.HistoryTableLive)}
</section>
<.tabbed_layout>
<:tab title="Downloaded" id="downloaded">
{live_render(
@conn,
Pinchflat.Pages.HistoryTableLive,
session: %{"media_state" => "downloaded"}
)}
</:tab>
<:tab title="Pending" id="pending">
{live_render(
@conn,
Pinchflat.Pages.HistoryTableLive,
session: %{"media_state" => "pending"}
)}
</:tab>
</.tabbed_layout>
</div>
<div class="rounded-sm border shadow-default border-strokedark bg-boxdark mt-4 p-5">
@@ -21,9 +21,9 @@ defmodule Pinchflat.Pages.JobTableLive do
<:col :let={task} label="Task">
{worker_to_task_name(task.job.worker)}
</:col>
<:col :let={task} label="Subject">
<:col :let={task} label="Subject" class="truncate max-w-xs">
<.subtle_link href={task_to_link(task)}>
{StringUtils.truncate(task_to_record_name(task), 35)}
{task_to_record_name(task)}
</.subtle_link>
</:col>
<:col :let={task} label="Attempt No.">
@@ -6,8 +6,19 @@ defmodule PinchflatWeb.Podcasts.PodcastController do
alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem
alias Pinchflat.Podcasts.RssFeedBuilder
alias Pinchflat.Podcasts.OpmlFeedBuilder
alias Pinchflat.Podcasts.PodcastHelpers
def opml_feed(conn, _params) do
url_base = url(conn, ~p"/")
xml = OpmlFeedBuilder.build(url_base, PodcastHelpers.opml_sources())
conn
|> put_resp_content_type("application/opml+xml")
|> put_resp_header("content-disposition", "inline")
|> send_resp(200, xml)
end
def rss_feed(conn, %{"uuid" => uuid}) do
source = Repo.get_by!(Source, uuid: uuid)
url_base = url(conn, ~p"/")
@@ -1,6 +1,6 @@
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
<h2 class="text-title-md2 font-bold text-black dark:text-white">
Results for "{StringUtils.truncate(@search_term, 50)}"
<h2 class="text-title-md2 font-bold text-black dark:text-white truncate">
Results for "{@search_term}"
</h2>
</div>
@@ -9,9 +9,9 @@
<div class="flex flex-col gap-10 dark:text-white">
<%= if match?([_|_], @search_results) do %>
<.table rows={@search_results} table_class="text-black dark:text-white">
<:col :let={result} label="Title">
<:col :let={result} label="Title" class="truncate max-w-xs">
<.subtle_link href={~p"/sources/#{result.source_id}/media/#{result.id}"}>
{StringUtils.truncate(result.title, 35)}
{result.title}
</.subtle_link>
</:col>
<:col :let={result} label="Excerpt">
@@ -40,9 +40,15 @@ defmodule PinchflatWeb.Sources.SourceHTML do
end
def rss_feed_url(conn, source) do
# NOTE: The reason for this concatenation is to avoid what appears to be a bug in Phoenix
# See: https://github.com/phoenixframework/phoenix/issues/6033
url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml"
end
def opml_feed_url(conn) do
url(conn, ~p"/sources/opml.xml?#{[route_token: Settings.get!(:route_token)]}")
end
def output_path_template_override_placeholders(media_profiles) do
media_profiles
|> Enum.map(&{&1.id, &1.output_path_template})
@@ -1,18 +1,16 @@
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
<:option>
<span
x-data="{ copied: false }"
x-on:click={"
copyWithCallbacks(
'#{rss_feed_url(@conn, @source)}',
() => copied = true,
() => copied = false
)
"}
>
<.link href={rss_feed_url(@conn, @source)} x-data="{ copied: false }" x-on:click={~s"
$event.preventDefault();
copyWithCallbacks(
'#{rss_feed_url(@conn, @source)}',
() => copied = true,
() => copied = false
)
"}>
Copy RSS Feed
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
</span>
</.link>
</:option>
<:option>
<span x-data="{ copied: false }" x-on:click={~s"
@@ -56,7 +54,7 @@
<.link
href={~p"/sources/#{@source}/force_index"}
method="post"
data-confirm="Are you sure you want to force an index of this source? This isn't normally needed."
data-confirm="Are you sure you want index all content from this source? This isn't normally needed."
>
Force Index
</.link>
@@ -2,7 +2,7 @@
<.link href={~p"/sources"}>
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4 truncate">
Editing "{@source.custom_name}"
</h2>
</div>
@@ -1,6 +1,17 @@
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
<h2 class="text-title-md2 font-bold text-black dark:text-white">Sources</h2>
<nav>
<nav class="flex items-center justify-between gap-6">
<.link href={opml_feed_url(@conn)} x-data="{ copied: false }" x-on:click={~s"
$event.preventDefault();
copyWithCallbacks(
'#{opml_feed_url(@conn)}',
() => copied = true,
() => copied = false
)
"}>
Copy OPML <span class="hidden sm:inline">Feed</span>
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
</.link>
<.link href={~p"/sources/new"}>
<.button color="bg-primary" rounding="rounded-lg">
<span class="font-bold mx-2">+</span> New <span class="hidden sm:inline pl-1">Source</span>
@@ -11,8 +22,12 @@
<div class="rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark">
<div class="max-w-full overflow-x-auto">
<div class="flex flex-col gap-10 min-w-max">
{live_render(@conn, PinchflatWeb.Sources.IndexTableLive)}
</div>
{live_render(@conn, PinchflatWeb.Sources.SourceLive.IndexTableLive,
session: %{
"initial_sort_key" => :custom_name,
"initial_sort_direction" => :asc,
"results_per_page" => 10
}
)}
</div>
</div>
@@ -11,9 +11,9 @@ defmodule PinchflatWeb.Sources.IndexTableLive do
def render(assigns) do
~H"""
<.table rows={@sources} table_class="text-white">
<:col :let={source} label="Name">
<:col :let={source} label="Name" class="truncate max-w-xs">
<.subtle_link href={~p"/sources/#{source.id}"}>
{StringUtils.truncate(source.custom_name || source.collection_name, 35)}
{source.custom_name || source.collection_name}
</.subtle_link>
</:col>
<:col :let={source} label="Pending">
@@ -46,9 +46,9 @@ defmodule PinchflatWeb.Sources.MediaItemTableLive do
</div>
</header>
<.table rows={@records} table_class="text-white">
<:col :let={media_item} label="Title">
<:col :let={media_item} label="Title" class="truncate max-w-xs">
<.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
{StringUtils.truncate(media_item.title, 50)}
{media_item.title}
</.subtle_link>
</:col>
<:col :let={media_item} :if={@media_state == "other"} label="Manually Ignored?">
@@ -1,9 +1,9 @@
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
<div class="flex gap-3 items-center">
<div class="flex gap-3 items-center min-w-0">
<.link href={~p"/sources"}>
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4 truncate">
{@source.custom_name}
</h2>
</div>
@@ -0,0 +1,108 @@
defmodule PinchflatWeb.Sources.SourceLive.IndexTableLive do
use PinchflatWeb, :live_view
use Pinchflat.Media.MediaQuery
use Pinchflat.Sources.SourcesQuery
import PinchflatWeb.Helpers.SortingHelpers
import PinchflatWeb.Helpers.PaginationHelpers
alias Pinchflat.Repo
alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem
def mount(_params, session, socket) do
limit = session["results_per_page"]
initial_params =
Map.merge(
%{
sort_key: session["initial_sort_key"],
sort_direction: session["initial_sort_direction"]
},
get_pagination_attributes(sources_query(), 1, limit)
)
socket
|> assign(initial_params)
|> set_sources()
|> then(&{:ok, &1})
end
def handle_event("page_change", %{"direction" => direction}, %{assigns: assigns} = socket) do
new_page = update_page_number(assigns.page, direction, assigns.total_pages)
socket
|> assign(get_pagination_attributes(sources_query(), new_page, assigns.limit))
|> set_sources()
|> then(&{:noreply, &1})
end
def handle_event("sort_update", %{"sort_key" => sort_key}, %{assigns: assigns} = socket) do
new_sort_key = String.to_existing_atom(sort_key)
new_params = %{
sort_key: new_sort_key,
sort_direction: get_sort_direction(assigns.sort_key, new_sort_key, assigns.sort_direction)
}
socket
|> assign(new_params)
|> set_sources()
|> then(&{:noreply, &1})
end
defp sort_attr(:pending_count), do: dynamic([s, mp, dl, pe], pe.pending_count)
defp sort_attr(:downloaded_count), do: dynamic([s, mp, dl], dl.downloaded_count)
defp sort_attr(:media_size_bytes), do: dynamic([s, mp, dl], dl.media_size_bytes)
defp sort_attr(:media_profile_name), do: dynamic([s, mp], mp.name)
defp sort_attr(:custom_name), do: dynamic([s], s.custom_name)
defp sort_attr(:enabled), do: dynamic([s], s.enabled)
defp set_sources(%{assigns: assigns} = socket) do
sources =
sources_query()
|> order_by(^[{assigns.sort_direction, sort_attr(assigns.sort_key)}, asc: :id])
|> limit(^assigns.limit)
|> offset(^assigns.offset)
|> Repo.all()
assign(socket, %{sources: sources})
end
defp sources_query do
downloaded_subquery =
from(
m in MediaItem,
select: %{downloaded_count: count(m.id), source_id: m.source_id, media_size_bytes: sum(m.media_size_bytes)},
where: ^MediaQuery.downloaded(),
group_by: m.source_id
)
pending_subquery =
from(
m in MediaItem,
inner_join: s in assoc(m, :source),
inner_join: mp in assoc(s, :media_profile),
select: %{pending_count: count(m.id), source_id: m.source_id},
where: ^MediaQuery.pending(),
group_by: m.source_id
)
from s in Source,
as: :source,
inner_join: mp in assoc(s, :media_profile),
left_join: d in subquery(downloaded_subquery),
on: d.source_id == s.id,
left_join: p in subquery(pending_subquery),
on: p.source_id == s.id,
on: d.source_id == s.id,
where: is_nil(s.marked_for_deletion_at) and is_nil(mp.marked_for_deletion_at),
preload: [media_profile: mp],
select: map(s, ^Source.__schema__(:fields)),
select_merge: %{
downloaded_count: coalesce(d.downloaded_count, 0),
pending_count: coalesce(p.pending_count, 0),
media_size_bytes: coalesce(d.media_size_bytes, 0)
}
end
end
@@ -0,0 +1,41 @@
<div class="flex flex-col min-w-max">
<.table rows={@sources} table_class="text-white" sort_key={@sort_key} sort_direction={@sort_direction}>
<:col :let={source} label="Name" sort_key="custom_name" class="truncate max-w-xs">
<.subtle_link href={~p"/sources/#{source.id}"}>
{source.custom_name}
</.subtle_link>
</:col>
<:col :let={source} label="Pending" sort_key="pending_count">
<.subtle_link href={~p"/sources/#{source.id}/#tab-pending"}>
<.localized_number number={source.pending_count} />
</.subtle_link>
</:col>
<:col :let={source} label="Downloaded" sort_key="downloaded_count">
<.subtle_link href={~p"/sources/#{source.id}/#tab-downloaded"}>
<.localized_number number={source.downloaded_count} />
</.subtle_link>
</:col>
<:col :let={source} label="Size" sort_key="media_size_bytes">
<.readable_filesize byte_size={source.media_size_bytes} />
</:col>
<:col :let={source} label="Media Profile" sort_key="media_profile_name" class="truncate max-w-xs">
<.subtle_link href={~p"/media_profiles/#{source.media_profile_id}"}>
{source.media_profile.name}
</.subtle_link>
</:col>
<:col :let={source} label="Enabled?" sort_key="enabled">
<.live_component
module={PinchflatWeb.Sources.SourceLive.SourceEnableToggle}
source={source}
id={"source_#{source.id}_enabled"}
/>
</:col>
<:col :let={source} label="" class="flex place-content-evenly">
<.icon_link href={~p"/sources/#{source.id}/edit"} icon="hero-pencil-square" class="mx-1" />
</:col>
</.table>
<section class="flex justify-center my-5">
<.live_pagination_controls page_number={@page} total_pages={@total_pages} />
</section>
</div>
@@ -0,0 +1,35 @@
defmodule PinchflatWeb.Sources.SourceLive.SourceEnableToggle do
use PinchflatWeb, :live_component
alias Pinchflat.Sources
alias Pinchflat.Sources.Source
def render(assigns) do
~H"""
<div>
<.form :let={f} for={@form} phx-change="update" phx-target={@myself} class="enabled_toggle_form">
<.input id={"source_#{@source_id}_enabled_input"} field={f[:enabled]} type="toggle" />
</.form>
</div>
"""
end
def update(assigns, socket) do
initial_data = %{
source_id: assigns.source.id,
form: Sources.change_source(%Source{}, assigns.source)
}
socket
|> assign(initial_data)
|> then(&{:ok, &1})
end
def handle_event("update", %{"source" => source_params}, %{assigns: assigns} = socket) do
assigns.source_id
|> Sources.get_source!()
|> Sources.update_source(source_params)
{:noreply, socket}
end
end
+7
View File
@@ -73,6 +73,13 @@ defmodule PinchflatWeb.Endpoint do
Phoenix.Controller.put_router_url(conn, new_base_url)
end
# Some podcast clients require file extensions, and others still will _add_
# file extensions to XML files if they don't have them. This plug removes
# the extension from the path so that the correct route is matched, regardless
# of the provided extension.
#
# This has the downside of in-app generated verified routes not working with
# extensions so this behaviour may change in the future.
defp strip_trailing_extension(%{path_info: []} = conn, _opts), do: conn
defp strip_trailing_extension(conn, _opts) do
@@ -0,0 +1,45 @@
defmodule PinchflatWeb.Helpers.PaginationHelpers do
@moduledoc """
Methods for working with pagination, usually in the context of LiveViews or LiveComponents.
These methods are fairly simple, but they're commonly repeated across different Live entities
"""
alias Pinchflat.Repo
alias Pinchflat.Utils.NumberUtils
@doc """
Given a query, a page number, and a number of records per page, returns a map of pagination attributes.
Returns map()
"""
def get_pagination_attributes(query, page, records_per_page) do
total_record_count = Repo.aggregate(query, :count, :id)
total_pages = max(ceil(total_record_count / records_per_page), 1)
clamped_page = NumberUtils.clamp(page, 1, total_pages)
%{
page: clamped_page,
total_pages: total_pages,
total_record_count: total_record_count,
limit: records_per_page,
offset: (clamped_page - 1) * records_per_page
}
end
@doc """
Given a current page number, a direction to move in, and the total number of pages, returns the updated page number.
The updated page number is clamped to the range [1, total_pages].
Returns integer()
"""
def update_page_number(current_page, direction, total_pages) do
updated_page =
case to_string(direction) do
"inc" -> current_page + 1
"dec" -> current_page - 1
end
NumberUtils.clamp(updated_page, 1, total_pages)
end
end
@@ -0,0 +1,20 @@
defmodule PinchflatWeb.Helpers.SortingHelpers do
@moduledoc """
Methods for working with sorting, usually in the context of LiveViews or LiveComponents.
These methods are fairly simple, but they're commonly repeated across different Live entities
"""
@doc """
Given the old sort attribute, the new sort attribute, and the old sort direction, returns the new sort direction.
Returns :asc | :desc
"""
def get_sort_direction(old_sort_attr, new_sort_attr, old_sort_direction) do
case {new_sort_attr, old_sort_direction} do
{^old_sort_attr, :desc} -> :asc
{^old_sort_attr, _} -> :desc
_ -> :asc
end
end
end
+66
View File
@@ -0,0 +1,66 @@
defmodule PinchflatWeb.Plugs do
@moduledoc """
Custom plugs for PinchflatWeb.
"""
use PinchflatWeb, :router
alias Pinchflat.Settings
@doc """
If the `expose_feed_endpoints` setting is true, this plug does nothing. Otherwise, it calls `basic_auth/2`.
"""
def maybe_basic_auth(conn, opts) do
if Application.get_env(:pinchflat, :expose_feed_endpoints) do
conn
else
basic_auth(conn, opts)
end
end
@doc """
If the `basic_auth_username` and `basic_auth_password` settings are set, this plug calls `Plug.BasicAuth.basic_auth/3`.
"""
def basic_auth(conn, _opts) do
username = Application.get_env(:pinchflat, :basic_auth_username)
password = Application.get_env(:pinchflat, :basic_auth_password)
if credential_set?(username) && credential_set?(password) do
Plug.BasicAuth.basic_auth(conn, username: username, password: password, realm: "Pinchflat")
else
conn
end
end
@doc """
Removes the `x-frame-options` header from the response to allow the page to be embedded in an iframe.
"""
def allow_iframe_embed(conn, _opts) do
delete_resp_header(conn, "x-frame-options")
end
@doc """
If the `route_token` query parameter matches the `route_token` setting, this plug does nothing.
Otherwise, it sends a 401 response.
"""
def token_protected_route(%{query_params: %{"route_token" => route_token}} = conn, _opts) do
if Settings.get!(:route_token) == route_token do
conn
else
send_unauthorized(conn)
end
end
def token_protected_route(conn, _opts) do
send_unauthorized(conn)
end
defp credential_set?(credential) do
credential && credential != ""
end
defp send_unauthorized(conn) do
conn
|> send_resp(:unauthorized, "Unauthorized")
|> halt()
end
end
+18 -41
View File
@@ -1,5 +1,6 @@
defmodule PinchflatWeb.Router do
use PinchflatWeb, :router
import PinchflatWeb.Plugs
import Phoenix.LiveDashboard.Router
# IMPORTANT: `strip_trailing_extension` in endpoint.ex removes
@@ -19,8 +20,23 @@ defmodule PinchflatWeb.Router do
plug :accepts, ["json"]
end
pipeline :feeds do
plug :maybe_basic_auth
scope "/", PinchflatWeb do
pipe_through [:maybe_basic_auth, :token_protected_route]
# has to match before /sources/:id
get "/sources/opml", Podcasts.PodcastController, :opml_feed
end
# Routes in here _may not be_ protected by basic auth. This is necessary for
# media streaming to work for RSS podcast feeds.
scope "/", PinchflatWeb do
pipe_through :maybe_basic_auth
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
scope "/", PinchflatWeb do
@@ -48,18 +64,6 @@ defmodule PinchflatWeb.Router do
end
end
# Routes in here _may not be_ protected by basic auth. This is necessary for
# media streaming to work for RSS podcast feeds.
scope "/", PinchflatWeb do
pipe_through :feeds
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
# No auth or CSRF protection for the health check endpoint
scope "/", PinchflatWeb do
pipe_through :api
@@ -74,31 +78,4 @@ defmodule PinchflatWeb.Router do
metrics: PinchflatWeb.Telemetry,
ecto_repos: [Pinchflat.Repo]
end
defp maybe_basic_auth(conn, opts) do
if Application.get_env(:pinchflat, :expose_feed_endpoints) do
conn
else
basic_auth(conn, opts)
end
end
defp basic_auth(conn, _opts) do
username = Application.get_env(:pinchflat, :basic_auth_username)
password = Application.get_env(:pinchflat, :basic_auth_password)
if credential_set?(username) && credential_set?(password) do
Plug.BasicAuth.basic_auth(conn, username: username, password: password, realm: "Pinchflat")
else
conn
end
end
defp credential_set?(credential) do
credential && credential != ""
end
defp allow_iframe_embed(conn, _opts) do
delete_resp_header(conn, "x-frame-options")
end
end
+1 -1
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do
[
app: :pinchflat,
version: "2024.12.10",
version: "2025.1.3",
elixir: "~> 1.17",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

After

Width:  |  Height:  |  Size: 442 KiB

@@ -0,0 +1,11 @@
defmodule Pinchflat.Repo.Migrations.AddRouteTokenToSettings do
use Ecto.Migration
def change do
alter table(:settings) do
add :route_token, :string, null: false, default: "tmp-token"
end
execute "UPDATE settings SET route_token = gen_random_uuid();", "SELECT 1;"
end
end
@@ -287,7 +287,19 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
assert {:sponsorblock_remove, "sponsor,intro"} in res
end
test "does not include :sponsorblock_remove option without categories", %{media_item: media_item} do
test "includes :sponsorblock_mark option when specified", %{media_item: media_item} do
media_item =
update_media_profile_attribute(media_item, %{
sponsorblock_behaviour: :mark,
sponsorblock_categories: ["sponsor", "intro"]
})
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
assert {:sponsorblock_mark, "sponsor,intro"} in res
end
test "does not include any sponsorblock option without categories", %{media_item: media_item} do
media_item =
update_media_profile_attribute(media_item, %{
sponsorblock_behaviour: :remove,
@@ -296,9 +308,10 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
refute {:sponsorblock_remove, ""} in res
refute {:sponsorblock_remove, []} in res
refute Keyword.has_key?(res, :sponsorblock_remove)
refute Keyword.has_key?(res, :sponsorblock_mark)
refute :sponsorblock_remove in res
refute :sponsorblock_mark in res
end
test "does not include any sponsorblock options when disabled", %{media_item: media_item} do
@@ -307,9 +320,10 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
refute {:sponsorblock_remove, ""} in res
refute {:sponsorblock_remove, []} in res
refute Keyword.has_key?(res, :sponsorblock_remove)
refute Keyword.has_key?(res, :sponsorblock_mark)
refute :sponsorblock_remove in res
refute :sponsorblock_mark in res
end
end
@@ -9,8 +9,12 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
alias Pinchflat.Downloading.MediaDownloadWorker
setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "{}"} end)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
stub(YtDlpRunnerMock, :run, fn
_url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download_thumbnail, _opts, _ot, _addl -> {:ok, ""}
_url, :download, _opts, _ot, _addl -> {:ok, render_metadata(:media_metadata)}
end)
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> {:ok, "", 0} end)
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
@@ -53,13 +57,17 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end
describe "perform/1" do
test "it saves attributes to the media_item", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
setup do
stub(YtDlpRunnerMock, :run, fn
_url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download, _opts, _ot, _addl -> {:ok, render_metadata(:media_metadata)}
_url, :download_thumbnail, _opts, _ot, _addl -> {:ok, ""}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
:ok
end
test "it saves attributes to the media_item", %{media_item: media_item} do
assert media_item.media_filepath == nil
perform_job(MediaDownloadWorker, %{id: media_item.id})
media_item = Repo.reload(media_item)
@@ -68,12 +76,6 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end
test "it saves the metadata to the media_item", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
assert media_item.metadata == nil
perform_job(MediaDownloadWorker, %{id: media_item.id})
assert Repo.reload(media_item).metadata != nil
@@ -87,7 +89,10 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end
test "it sets the job to retryable if the download fails", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:error, "error"} end)
expect(YtDlpRunnerMock, :run, 2, fn
_url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download, _opts, _ot, _addl -> {:error, "error"}
end)
Oban.Testing.with_testing_mode(:inline, fn ->
{:ok, job} = Oban.insert(MediaDownloadWorker.new(%{id: media_item.id}))
@@ -97,8 +102,9 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end
test "sets the job to retryable if the download failed and was retried", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:error, "Unable to communicate with SponsorBlock", 1}
expect(YtDlpRunnerMock, :run, 2, fn
_url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download, _opts, _ot, _addl -> {:error, "Unable to communicate with SponsorBlock", 1}
end)
Oban.Testing.with_testing_mode(:inline, fn ->
@@ -109,8 +115,9 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
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}
expect(YtDlpRunnerMock, :run, 2, fn
_url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download, _opts, _ot, _addl -> {:error, "Something something Video unavailable something something", 1}
end)
Oban.Testing.with_testing_mode(:inline, fn ->
@@ -121,13 +128,16 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
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)
expect(YtDlpRunnerMock, :run, 2, fn
_url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download, _opts, _ot, _addl -> {:error, "error", 1}
end)
assert {:error, :download_failed} = perform_job(MediaDownloadWorker, %{id: media_item.id})
end
test "it does not download if the source is set to not download", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot, _addl -> :ok end)
expect(YtDlpRunnerMock, :run, 0, fn _url, :download, _opts, _ot, _addl -> :ok end)
Sources.update_source(media_item.source, %{download_media: false})
@@ -135,7 +145,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end
test "does not download if the media item is set to not download", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot, _addl -> :ok end)
expect(YtDlpRunnerMock, :run, 0, fn _url, :download, _opts, _ot, _addl -> :ok end)
Media.update_media_item(media_item, %{prevent_download: true})
@@ -143,15 +153,20 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end
test "it saves the file's size to the database", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl ->
metadata = render_parsed_metadata(:media_metadata)
FilesystemUtils.write_p!(metadata["filepath"], "test")
expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
{:ok, Phoenix.json_library().encode!(metadata)}
_url, :download, _opts, _ot, _addl ->
metadata = render_parsed_metadata(:media_metadata)
FilesystemUtils.write_p!(metadata["filepath"], "test")
{:ok, Phoenix.json_library().encode!(metadata)}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
perform_job(MediaDownloadWorker, %{id: media_item.id})
media_item = Repo.reload(media_item)
@@ -159,10 +174,6 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end
test "does not set redownloaded_at by default", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
end)
perform_job(MediaDownloadWorker, %{id: media_item.id})
media_item = Repo.reload(media_item)
@@ -174,22 +185,27 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end
test "sets the no_force_overwrites runner option", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 1, fn _url, opts, _ot, _addl ->
assert :no_force_overwrites in opts
refute :force_overwrites in opts
expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
{:ok, render_metadata(:media_metadata)}
_url, :download, opts, _ot, _addl ->
assert :no_force_overwrites in opts
refute :force_overwrites in opts
{:ok, render_metadata(:media_metadata)}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
perform_job(MediaDownloadWorker, %{id: media_item.id})
end
end
describe "perform/1 when testing non-downloadable media" do
test "does not retry the job if the media is currently not downloadable", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
stub(YtDlpRunnerMock, :run, fn _url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "is_live"})}
end)
@@ -203,8 +219,6 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
describe "perform/1 when testing forced downloads" do
test "ignores 'prevent_download' if forced", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> :ok end)
Sources.update_source(media_item.source, %{download_media: false})
Media.update_media_item(media_item, %{prevent_download: true})
@@ -212,27 +226,26 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end
test "sets force_overwrites runner option", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 1, fn _url, opts, _ot, _addl ->
assert :force_overwrites in opts
refute :no_force_overwrites in opts
expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
{:ok, render_metadata(:media_metadata)}
_url, :download, opts, _ot, _addl ->
assert :force_overwrites in opts
refute :no_force_overwrites in opts
{:ok, render_metadata(:media_metadata)}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
end
end
describe "perform/1 when testing re-downloads" do
test "sets redownloaded_at on the media_item", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
perform_job(MediaDownloadWorker, %{id: media_item.id, quality_upgrade?: true})
media_item = Repo.reload(media_item)
@@ -240,29 +253,39 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end
test "sets force_overwrites runner option", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 1, fn _url, opts, _ot, _addl ->
assert :force_overwrites in opts
refute :no_force_overwrites in opts
expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
{:ok, render_metadata(:media_metadata)}
_url, :download, opts, _ot, _addl ->
assert :force_overwrites in opts
refute :no_force_overwrites in opts
{:ok, render_metadata(:media_metadata)}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
end
test "deletes old files if the media item has been updated" do
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl ->
tmp_media_item = media_item_with_attachments()
metadata = render_parsed_metadata(:media_metadata)
metadata = Map.put(metadata, "filepath", tmp_media_item.media_filepath)
expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
{:ok, Phoenix.json_library().encode!(metadata)}
_url, :download, _opts, _ot, _addl ->
tmp_media_item = media_item_with_attachments()
metadata = render_parsed_metadata(:media_metadata)
metadata = Map.put(metadata, "filepath", tmp_media_item.media_filepath)
{:ok, Phoenix.json_library().encode!(metadata)}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
old_media_item = media_item_with_attachments()
perform_job(MediaDownloadWorker, %{id: old_media_item.id, force: true})
updated_media_item = Repo.reload(old_media_item)
@@ -274,14 +297,6 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end
describe "perform/1 when testing user script callbacks" do
setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
end)
:ok
end
test "calls the media_pre_download user script runner", %{media_item: media_item} do
expect(UserScriptRunnerMock, :run, fn :media_pre_download, data ->
assert data.id == media_item.id
@@ -16,31 +16,36 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
)
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "{}"} end)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_args -> {:ok, ""} end)
{:ok, %{media_item: media_item}}
end
describe "download_for_media_item/3" do
test "calls the backend runner", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 1, fn url, _opts, ot, addl ->
assert url == media_item.original_url
assert ot == "after_move:%()j"
assert [{:output_filepath, filepath} | _] = addl
assert is_binary(filepath)
expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
{:ok, render_metadata(:media_metadata)}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
url, :download, _opts, ot, addl ->
assert url == media_item.original_url
assert ot == "after_move:%()j"
assert [{:output_filepath, filepath} | _] = addl
assert is_binary(filepath)
{:ok, render_metadata(:media_metadata)}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl_args -> {:ok, ""} end)
assert {:ok, _} = MediaDownloader.download_for_media_item(media_item)
end
test "saves the metadata filepath to the database", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download_thumbnail, _opts, _ot, _addl -> {:ok, ""}
_url, :download, _opts, _ot, _addl -> {:ok, render_metadata(:media_metadata)}
end)
assert is_nil(media_item.metadata)
@@ -51,7 +56,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
end
test "errors for non-downloadable media are passed through", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
expect(YtDlpRunnerMock, :run, fn _url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "is_live"})}
end)
@@ -59,16 +64,18 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
end
test "non-recoverable errors are passed through", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:error, :some_error, 1}
expect(YtDlpRunnerMock, :run, 2, fn
_url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download, _opts, _ot, _addl -> {:error, :some_error, 1}
end)
assert {:error, :some_error} = MediaDownloader.download_for_media_item(media_item)
end
test "unknown errors are passed through", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:error, :some_error}
expect(YtDlpRunnerMock, :run, 2, fn
_url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download, _opts, _ot, _addl -> {:error, :some_error}
end)
assert {:error, message} = MediaDownloader.download_for_media_item(media_item)
@@ -78,29 +85,33 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
describe "download_for_media_item/3 when testing non-downloadable media" do
test "calls the download runner if the media is currently downloadable", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "was_live"})}
end)
expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "was_live"})}
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
_url, :download, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
assert {:ok, _} = MediaDownloader.download_for_media_item(media_item)
end
test "does not call the download runner if the media is not downloadable", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "is_live"})}
expect(YtDlpRunnerMock, :run, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "is_live"})}
end)
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
expect(YtDlpRunnerMock, :run, 0, fn _url, :download, _opts, _ot, _addl -> {:ok, ""} end)
assert {:error, :unsuitable_for_download} = MediaDownloader.download_for_media_item(media_item)
end
test "returns unexpected errors from the download status determination method", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:error, :what_tha} end)
expect(YtDlpRunnerMock, :run, fn _url, :get_downloadable_status, _opts, _ot, _addl -> {:error, :what_tha} end)
assert {:error, "Unknown error: {:error, :what_tha}"} = MediaDownloader.download_for_media_item(media_item)
end
@@ -108,15 +119,20 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
describe "download_for_media_item/3 when testing override options" do
test "includes override opts if specified", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 1, fn _url, opts, _ot, _addl ->
refute :force_overwrites in opts
assert :no_force_overwrites in opts
expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
{:ok, render_metadata(:media_metadata)}
_url, :download, opts, _ot, _addl ->
refute :force_overwrites in opts
assert :no_force_overwrites in opts
{:ok, render_metadata(:media_metadata)}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
override_opts = [overwrite_behaviour: :no_force_overwrites]
assert {:ok, _} = MediaDownloader.download_for_media_item(media_item, override_opts)
@@ -125,12 +141,18 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
describe "download_for_media_item/3 when testing cookie usage" do
test "sets use_cookies if the source uses cookies" do
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, addl ->
assert {:use_cookies, true} in addl
{:ok, render_metadata(:media_metadata)}
end)
expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, addl ->
assert {:use_cookies, true} in addl
{:ok, "{}"}
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl_args -> {:ok, ""} end)
_url, :download, _opts, _ot, addl ->
assert {:use_cookies, true} in addl
{:ok, render_metadata(:media_metadata)}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
source = source_fixture(%{use_cookies: true})
media_item = media_item_fixture(%{source_id: source.id})
@@ -139,12 +161,17 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
end
test "does not set use_cookies if the source does not use cookies" do
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, addl ->
assert {:use_cookies, false} in addl
{:ok, render_metadata(:media_metadata)}
end)
expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl_args -> {:ok, ""} end)
_url, :download, _opts, _ot, addl ->
assert {:use_cookies, false} in addl
{:ok, render_metadata(:media_metadata)}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
source = source_fixture(%{use_cookies: false})
media_item = media_item_fixture(%{source_id: source.id})
@@ -157,8 +184,12 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
test "returns a recovered tuple on recoverable errors", %{media_item: media_item} do
message = "Unable to communicate with SponsorBlock"
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:error, message, 1}
expect(YtDlpRunnerMock, :run, 2, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
_url, :download, _opts, _ot, _addl ->
{:error, message, 1}
end)
assert {:recovered, ^message} = MediaDownloader.download_for_media_item(media_item)
@@ -167,15 +198,20 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
test "attempts to update the media item on recoverable errors", %{media_item: media_item} do
message = "Unable to communicate with SponsorBlock"
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, addl ->
[{:output_filepath, filepath} | _] = addl
File.write(filepath, render_metadata(:media_metadata))
expect(YtDlpRunnerMock, :run, 3, fn
_url, :download, _opts, _ot, addl ->
[{:output_filepath, filepath} | _] = addl
File.write(filepath, render_metadata(:media_metadata))
{:error, message, 1}
{:error, message, 1}
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot, _addl_args -> {:ok, ""} end)
assert {:recovered, ^message} = MediaDownloader.download_for_media_item(media_item)
media_item = Repo.reload(media_item)
@@ -186,8 +222,10 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
describe "download_for_media_item/3 when testing media_item attributes" do
setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
stub(YtDlpRunnerMock, :run, fn
_url, :download, _opts, _ot, _addl -> {:ok, render_metadata(:media_metadata)}
_url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download_thumbnail, _opts, _ot, _addl -> {:ok, ""}
end)
:ok
@@ -234,20 +272,27 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
end
test "extracts the thumbnail_filepath", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot, _addl ->
metadata = render_parsed_metadata(:media_metadata)
expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
thumbnail_filepath =
metadata["thumbnails"]
|> Enum.reverse()
|> Enum.find_value(fn attrs -> attrs["filepath"] end)
|> String.split(~r{\.}, include_captures: true)
|> List.insert_at(-3, "-thumb")
|> Enum.join()
_url, :download, _opts, _ot, _addl ->
metadata = render_parsed_metadata(:media_metadata)
:ok = File.cp(thumbnail_filepath_fixture(), thumbnail_filepath)
thumbnail_filepath =
metadata["thumbnails"]
|> Enum.reverse()
|> Enum.find_value(fn attrs -> attrs["filepath"] end)
|> String.split(~r{\.}, include_captures: true)
|> List.insert_at(-3, "-thumb")
|> Enum.join()
{:ok, Phoenix.json_library().encode!(metadata)}
:ok = File.cp(thumbnail_filepath_fixture(), thumbnail_filepath)
{:ok, Phoenix.json_library().encode!(metadata)}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
assert media_item.thumbnail_filepath == nil
@@ -258,13 +303,19 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
end
test "extracts the metadata_filepath", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot, _addl ->
metadata = render_parsed_metadata(:media_metadata)
expect(YtDlpRunnerMock, :run, 3, fn
_url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, "{}"}
infojson_filepath = metadata["infojson_filename"]
:ok = File.cp(infojson_filepath_fixture(), infojson_filepath)
_url, :download, _opts, _ot, _addl ->
metadata = render_parsed_metadata(:media_metadata)
infojson_filepath = metadata["infojson_filename"]
:ok = File.cp(infojson_filepath_fixture(), infojson_filepath)
{:ok, Phoenix.json_library().encode!(metadata)}
{:ok, Phoenix.json_library().encode!(metadata)}
_url, :download_thumbnail, _opts, _ot, _addl ->
{:ok, ""}
end)
assert media_item.metadata_filepath == nil
@@ -277,8 +328,10 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
describe "download_for_media_item/3 when testing NFO generation" do
setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
stub(YtDlpRunnerMock, :run, fn
_url, :download, _opts, _ot, _addl -> {:ok, render_metadata(:media_metadata)}
_url, :get_downloadable_status, _opts, _ot, _addl -> {:ok, "{}"}
_url, :download_thumbnail, _opts, _ot, _addl -> {:ok, ""}
end)
:ok
@@ -14,7 +14,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
alias Pinchflat.FastIndexing.FastIndexingHelpers
setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, _addl ->
{:ok, media_attributes_return_fixture()}
end)
@@ -83,7 +83,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
test "passes the source's download options to the yt-dlp runner", %{source: source} do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, _addl_opts ->
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, opts, _ot, _addl_opts ->
assert {:output, "/tmp/test/media/%(title)S.%(ext)S"} in opts
assert {:remux_video, "mp4"} in opts
{:ok, media_attributes_return_fixture()}
@@ -95,7 +95,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
test "sets use_cookies if the source uses cookies" do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl ->
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, addl ->
assert {:use_cookies, true} in addl
{:ok, media_attributes_return_fixture()}
@@ -109,7 +109,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
test "does not set use_cookies if the source does not use cookies" do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl ->
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, addl ->
assert {:use_cookies, false} in addl
{:ok, media_attributes_return_fixture()}
@@ -126,7 +126,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
profile = media_profile_fixture(%{shorts_behaviour: :exclude})
source = source_fixture(%{media_profile_id: profile.id})
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, _addl ->
output =
Phoenix.json_library().encode!(%{
id: "video2",
@@ -150,7 +150,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
test "does not blow up if a media item cannot be created", %{source: source} do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, _addl ->
{:ok, "{}"}
end)
@@ -160,7 +160,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
test "does not blow up if a media item causes a yt-dlp error", %{source: source} do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, _addl ->
{:error, "message", 1}
end)
@@ -84,7 +84,10 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorkerTest do
source = source_fixture(fast_index: true)
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, render_metadata(:media_metadata)} end)
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
end)
expect(AppriseRunnerMock, :run, fn servers, opts ->
assert "server_1" = servers
@@ -110,7 +113,11 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorkerTest do
source = source_fixture(fast_index: true, download_media: false)
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, render_metadata(:media_metadata)} end)
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
end)
expect(AppriseRunnerMock, :run, 0, fn _servers, _opts -> {:ok, ""} end)
perform_job(FastIndexingWorker, %{id: source.id})
@@ -120,7 +127,11 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorkerTest do
source = source_fixture(fast_index: true, title_filter_regex: "foobar")
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, render_metadata(:media_metadata)} end)
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
end)
expect(AppriseRunnerMock, :run, 0, fn _servers, _opts -> {:ok, ""} end)
perform_job(FastIndexingWorker, %{id: source.id})
+3 -3
View File
@@ -757,7 +757,7 @@ defmodule Pinchflat.MediaTest do
end
test "does delete the media item's metadata files" do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
stub(YtDlpRunnerMock, :run, fn _url, :download_thumbnail, _opts, _ot, _addl -> {:ok, ""} end)
media_item = Repo.preload(media_item_with_attachments(), [:metadata, :source])
update_attrs = %{
@@ -789,7 +789,7 @@ defmodule Pinchflat.MediaTest do
end
test "deletes the media item's metadata files" do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
stub(YtDlpRunnerMock, :run, fn _url, :download_thumbnail, _opts, _ot, _addl -> {:ok, ""} end)
media_item = Repo.preload(media_item_with_attachments(), [:metadata, :source])
update_attrs = %{
@@ -875,7 +875,7 @@ defmodule Pinchflat.MediaTest do
end
test "does not delete the media item's metadata files" do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
stub(YtDlpRunnerMock, :run, fn _url, :download_thumbnail, _opts, _ot, _addl -> {:ok, ""} end)
media_item = Repo.preload(media_item_with_attachments(), [:metadata, :source])
update_attrs = %{
@@ -62,7 +62,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
describe "download_and_store_thumbnail_for/2" do
test "returns the filepath", %{media_item: media_item} do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, ""} end)
stub(YtDlpRunnerMock, :run, fn _url, :download_thumbnail, _opts, _ot, _addl -> {:ok, ""} end)
filepath = Helpers.download_and_store_thumbnail_for(media_item)
@@ -70,7 +70,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
end
test "calls yt-dlp with the expected options", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn url, opts, ot, _addl ->
expect(YtDlpRunnerMock, :run, fn url, :download_thumbnail, opts, ot, _addl ->
assert url == media_item.original_url
assert ot == "after_move:%()j"
@@ -89,7 +89,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
end
test "sets use_cookies if the source uses cookies" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl ->
expect(YtDlpRunnerMock, :run, fn _url, :download_thumbnail, _opts, _ot, addl ->
assert {:use_cookies, true} in addl
{:ok, ""}
end)
@@ -101,7 +101,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
end
test "does not set use_cookies if the source does not use cookies" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl ->
expect(YtDlpRunnerMock, :run, fn _url, :download_thumbnail, _opts, _ot, addl ->
assert {:use_cookies, false} in addl
{:ok, ""}
end)
@@ -113,7 +113,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
end
test "returns nil if yt-dlp fails", %{media_item: media_item} do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:error, "error"} end)
stub(YtDlpRunnerMock, :run, fn _url, :download_thumbnail, _opts, _ot, _addl -> {:error, "error"} end)
filepath = Helpers.download_and_store_thumbnail_for(media_item)
@@ -8,9 +8,6 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
alias Pinchflat.Metadata.MetadataFileHelpers
alias Pinchflat.Metadata.SourceMetadataStorageWorker
@source_details_ot "%(.{channel,channel_id,playlist_id,playlist_title,filename})j"
@metadata_ot "playlist:%()j"
describe "kickoff_with_task/1" do
test "enqueues a new worker for the source" do
source = source_fixture()
@@ -32,8 +29,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
describe "perform/1" do
test "won't call itself in an infinite loop" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
_url, _opts, ot, _addl when ot == @metadata_ot -> {:ok, "{}"}
_url, :get_source_details, _opts, _ot, _addl -> {:ok, source_details_return_fixture()}
_url, :get_source_metadata, _opts, _ot, _addl -> {:ok, "{}"}
end)
source = source_fixture()
@@ -51,8 +48,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
describe "perform/1 when testing attribute updates" do
test "the source description is saved" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
_url, _opts, ot, _addl when ot == @metadata_ot -> {:ok, render_metadata(:channel_source_metadata)}
_url, :get_source_details, _opts, _ot, _addl -> {:ok, source_details_return_fixture()}
_url, :get_source_metadata, _opts, _ot, _addl -> {:ok, render_metadata(:channel_source_metadata)}
end)
source = source_fixture(%{description: nil})
@@ -68,8 +65,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
describe "perform/1 when testing metadata storage" do
test "sets metadata location for source" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
_url, _opts, ot, _addl when ot == @metadata_ot -> {:ok, "{}"}
_url, :get_source_details, _opts, _ot, _addl -> {:ok, source_details_return_fixture()}
_url, :get_source_metadata, _opts, _ot, _addl -> {:ok, "{}"}
end)
source = Repo.preload(source_fixture(), :metadata)
@@ -87,8 +84,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
file_contents = Phoenix.json_library().encode!(%{"title" => "test"})
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
_url, _opts, ot, _addl when ot == @metadata_ot -> {:ok, file_contents}
_url, :get_source_details, _opts, _ot, _addl -> {:ok, source_details_return_fixture()}
_url, :get_source_metadata, _opts, _ot, _addl -> {:ok, file_contents}
end)
perform_job(SourceMetadataStorageWorker, %{id: source.id})
@@ -100,8 +97,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "sets metadata image location for source" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
_url, _opts, ot, _addl when ot == @metadata_ot -> {:ok, render_metadata(:channel_source_metadata)}
_url, :get_source_details, _opts, _ot, _addl -> {:ok, source_details_return_fixture()}
_url, :get_source_metadata, _opts, _ot, _addl -> {:ok, render_metadata(:channel_source_metadata)}
end)
source = source_fixture()
@@ -118,8 +115,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "stores metadata images for source" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
_url, _opts, ot, _addl when ot == @metadata_ot -> {:ok, render_metadata(:channel_source_metadata)}
_url, :get_source_details, _opts, _ot, _addl -> {:ok, source_details_return_fixture()}
_url, :get_source_metadata, _opts, _ot, _addl -> {:ok, render_metadata(:channel_source_metadata)}
end)
source = source_fixture()
@@ -138,12 +135,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
describe "perform/1 when testing source image downloading" do
test "downloads and stores source images" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot ->
_url, :get_source_details, _opts, _ot, _addl ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})}
_url, opts, ot, _addl when ot == @metadata_ot ->
_url, :get_source_metadata, opts, _ot, _addl ->
assert {:convert_thumbnails, "jpg"} in opts
{:ok, render_metadata(:channel_source_metadata)}
@@ -168,10 +165,10 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "calls one set of yt-dlp metadata opts for channels" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot ->
_url, :get_source_details, _opts, _ot, _addl ->
{:ok, source_details_return_fixture()}
_url, opts, ot, _addl when ot == @metadata_ot ->
_url, :get_source_metadata, opts, _ot, _addl ->
assert {:playlist_items, 0} in opts
assert :write_all_thumbnails in opts
@@ -186,10 +183,10 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "calls another set of yt-dlp metadata opts for playlists" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot ->
_url, :get_source_details, _opts, _ot, _addl ->
{:ok, source_details_return_fixture()}
_url, opts, ot, _addl when ot == @metadata_ot ->
_url, :get_source_metadata, opts, _ot, _addl ->
assert {:playlist_items, 1} in opts
assert :write_thumbnail in opts
@@ -204,12 +201,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "does not store source images if the profile is not set to" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot ->
_url, :get_source_details, _opts, _ot, _addl ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot, _addl when ot == @metadata_ot ->
_url, :get_source_metadata, _opts, _ot, _addl ->
{:ok, render_metadata(:channel_source_metadata)}
end)
@@ -226,12 +223,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "does not store source images if the series directory cannot be determined" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot ->
_url, :get_source_details, _opts, _ot, _addl ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot, _addl when ot == @metadata_ot ->
_url, :get_source_metadata, _opts, _ot, _addl ->
{:ok, render_metadata(:channel_source_metadata)}
end)
@@ -248,10 +245,10 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "sets use_cookies if the source uses cookies" do
expect(YtDlpRunnerMock, :run, 2, fn
_url, _opts, ot, _addl when ot == @source_details_ot ->
_url, :get_source_details, _opts, _ot, _addl ->
{:ok, source_details_return_fixture()}
_url, _opts, ot, addl when ot == @metadata_ot ->
_url, :get_source_metadata, _opts, _ot, addl ->
assert {:use_cookies, true} in addl
{:ok, render_metadata(:channel_source_metadata)}
end)
@@ -264,10 +261,10 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "does not set use_cookies if the source does not use cookies" do
expect(YtDlpRunnerMock, :run, 2, fn
_url, _opts, ot, _addl when ot == @source_details_ot ->
_url, :get_source_details, _opts, _ot, _addl ->
{:ok, source_details_return_fixture()}
_url, _opts, ot, addl when ot == @metadata_ot ->
_url, :get_source_metadata, _opts, _ot, addl ->
assert {:use_cookies, false} in addl
{:ok, render_metadata(:channel_source_metadata)}
end)
@@ -282,12 +279,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
describe "perform/1 when determining the series_directory" do
test "sets the series directory based on the returned media filepath" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot ->
_url, :get_source_details, _opts, _ot, _addl ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot, _addl when ot == @metadata_ot ->
_url, :get_source_metadata, _opts, _ot, _addl ->
{:ok, "{}"}
end)
@@ -300,12 +297,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "does not set the series directory if it cannot be determined" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot ->
_url, :get_source_details, _opts, _ot, _addl ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot, _addl when ot == @metadata_ot ->
_url, :get_source_metadata, _opts, _ot, _addl ->
{:ok, "{}"}
end)
@@ -318,11 +315,11 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "sets use_cookies if the source is set to use cookies" do
expect(YtDlpRunnerMock, :run, 2, fn
_url, _opts, ot, addl when ot == @source_details_ot ->
_url, :get_source_details, _opts, _ot, addl ->
assert {:use_cookies, true} in addl
{:ok, source_details_return_fixture()}
_url, _opts, ot, _addl when ot == @metadata_ot ->
_url, :get_source_metadata, _opts, _ot, _addl ->
{:ok, "{}"}
end)
@@ -332,11 +329,11 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "does not set use_cookies if the source is not set to use cookies" do
expect(YtDlpRunnerMock, :run, 2, fn
_url, _opts, ot, addl when ot == @source_details_ot ->
_url, :get_source_details, _opts, _ot, addl ->
assert {:use_cookies, false} in addl
{:ok, source_details_return_fixture()}
_url, _opts, ot, _addl when ot == @metadata_ot ->
_url, :get_source_metadata, _opts, _ot, _addl ->
{:ok, "{}"}
end)
@@ -348,12 +345,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
describe "perform/1 when storing the series NFO" do
test "stores the NFO if specified" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot ->
_url, :get_source_details, _opts, _ot, _addl ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot, _addl when ot == @metadata_ot ->
_url, :get_source_metadata, _opts, _ot, _addl ->
{:ok, "{}"}
end)
@@ -371,12 +368,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "does not store the NFO if not specified" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot ->
_url, :get_source_details, _opts, _ot, _addl ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot, _addl when ot == @metadata_ot ->
_url, :get_source_metadata, _opts, _ot, _addl ->
{:ok, "{}"}
end)
@@ -390,12 +387,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "does not store the NFO if the series directory cannot be determined" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot, _addl when ot == @source_details_ot ->
_url, :get_source_details, _opts, _ot, _addl ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot, _addl when ot == @metadata_ot ->
_url, :get_source_metadata, _opts, _ot, _addl ->
{:ok, "{}"}
end)
@@ -0,0 +1,34 @@
defmodule Pinchflat.Podcasts.OpmlFeedBuilderTest do
use Pinchflat.DataCase
import Pinchflat.SourcesFixtures
alias Pinchflat.Podcasts.OpmlFeedBuilder
setup do
source = source_fixture()
{:ok, source: source}
end
describe "build/2" do
test "returns an XML document", %{source: source} do
res = OpmlFeedBuilder.build("http://example.com", [source])
assert String.contains?(res, ~s(<?xml version="1.0" encoding="UTF-8"?>))
end
test "escapes illegal characters" do
source = source_fixture(%{custom_name: "A & B"})
res = OpmlFeedBuilder.build("http://example.com", [source])
assert String.contains?(res, ~s(A &amp; B))
end
test "build podcast link with URL base", %{source: source} do
res = OpmlFeedBuilder.build("http://example.com", [source])
assert String.contains?(res, ~s(http://example.com/sources/#{source.uuid}/feed.xml))
end
end
end
@@ -6,6 +6,16 @@ defmodule Pinchflat.Podcasts.PodcastHelpersTest do
alias Pinchflat.Podcasts.PodcastHelpers
describe "opml_sources" do
test "returns sources not marked for deletion" do
source = source_fixture()
source_fixture(%{marked_for_deletion_at: DateTime.utc_now()})
assert [found_source] = PodcastHelpers.opml_sources()
assert found_source.custom_name == source.custom_name
assert found_source.uuid == source.uuid
end
end
describe "persisted_media_items_for/2" do
test "returns media items with files that exist on-disk" do
source = source_fixture()
@@ -1,7 +1,7 @@
defmodule Pinchflat.SlowIndexing.FileFollowerServerTest do
use Pinchflat.DataCase
alias alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.SlowIndexing.FileFollowerServer
setup do
@@ -50,45 +50,69 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
describe "perform/1" do
setup do
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts -> {:ok, ""} end)
stub(AppriseRunnerMock, :run, fn _, _ -> {:ok, ""} end)
:ok
end
test "it indexes the source if it should be indexed" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
test "indexes the source if it should be indexed" do
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, ""}
end)
source = source_fixture(index_frequency_minutes: 10)
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
end
test "it indexes the source no matter what if the source has never been indexed before" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
test "indexes the source no matter what if the source has never been indexed before" do
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, ""}
end)
source = source_fixture(index_frequency_minutes: 0, last_indexed_at: nil)
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
end
test "it indexes the source no matter what if the 'force' arg is passed" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
test "indexes the source no matter what if the 'force' arg is passed" do
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, ""}
end)
source = source_fixture(index_frequency_minutes: 0, last_indexed_at: DateTime.utc_now())
perform_job(MediaCollectionIndexingWorker, %{id: source.id, force: true})
end
test "it does not do any indexing if the source has been indexed and shouldn't be rescheduled" do
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
test "doesn't use a download archive if the index has been forced" do
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, opts, _ot, _addl_opts ->
refute :break_on_existing in opts
refute Keyword.has_key?(opts, :download_archive)
{:ok, ""}
end)
source =
source_fixture(collection_type: :channel, index_frequency_minutes: 0, last_indexed_at: DateTime.utc_now())
perform_job(MediaCollectionIndexingWorker, %{id: source.id, force: true})
end
test "does not do any indexing if the source has been indexed and shouldn't be rescheduled" do
expect(YtDlpRunnerMock, :run, 0, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, ""}
end)
source = source_fixture(index_frequency_minutes: -1, last_indexed_at: DateTime.utc_now())
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
end
test "it does not reschedule if the source shouldn't be indexed" do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
test "does not reschedule if the source shouldn't be indexed" do
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts -> {:ok, ""} end)
source = source_fixture(index_frequency_minutes: -1)
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
@@ -96,8 +120,8 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
refute_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
end
test "it kicks off a download job for each pending media item" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts ->
test "kicks off a download job for each pending media item" do
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, source_attributes_return_fixture()}
end)
@@ -107,8 +131,8 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
assert length(all_enqueued(worker: MediaDownloadWorker)) == 3
end
test "it starts a job for any pending media item even if it's from another run" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts ->
test "starts a job for any pending media item even if it's from another run" do
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, source_attributes_return_fixture()}
end)
@@ -119,8 +143,8 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
assert length(all_enqueued(worker: MediaDownloadWorker)) == 4
end
test "it does not kick off a job for media items that could not be saved" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts ->
test "does not kick off a job for media items that could not be saved" do
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, source_attributes_return_fixture()}
end)
@@ -132,9 +156,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
assert length(all_enqueued(worker: MediaDownloadWorker))
end
test "it reschedules the job based on the index frequency" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
test "reschedules the job based on the index frequency" do
source = source_fixture(index_frequency_minutes: 10)
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
@@ -145,9 +167,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
)
end
test "it creates a task for the rescheduled job" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
test "creates a task for the rescheduled job" do
source = source_fixture(index_frequency_minutes: 10)
task_count_fetcher = fn ->
@@ -159,9 +179,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
end)
end
test "it creates a future task for fast indexing if appropriate" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
test "creates a future task for fast indexing if appropriate" do
source = source_fixture(index_frequency_minutes: 10, fast_index: true)
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
@@ -172,9 +190,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
)
end
test "it deletes existing fast indexing tasks if a new one is created" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
test "deletes existing fast indexing tasks if a new one is created" do
source = source_fixture(index_frequency_minutes: 10, fast_index: true)
{:ok, job} = Oban.insert(FastIndexingWorker.new(%{"id" => source.id}))
task = task_fixture(source_id: source.id, job_id: job.id)
@@ -184,17 +200,17 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end
test "it does not create a task for fast indexing otherwise" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
test "does not create a task for fast indexing otherwise" do
source = source_fixture(index_frequency_minutes: 10, fast_index: false)
perform_job(MediaCollectionIndexingWorker, %{id: source.id})
refute_enqueued(worker: FastIndexingWorker)
end
test "it creates the basic media_item records" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, source_attributes_return_fixture()} end)
test "creates the basic media_item records" do
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, source_attributes_return_fixture()}
end)
source = source_fixture(index_frequency_minutes: 10)
@@ -225,7 +241,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
test "sends a notification if new media was found" do
source = source_fixture()
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts ->
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, source_attributes_return_fixture()}
end)
@@ -14,6 +14,10 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker
setup do
{:ok, %{source: source_fixture()}}
end
describe "kickoff_indexing_task/3" do
test "schedules a job" do
source = source_fixture(index_frequency_minutes: 1)
@@ -53,6 +57,16 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
assert_in_delta DateTime.diff(job.scheduled_at, DateTime.utc_now(), :second), 0, 1
end
test "schedules a job immediately if the user is forcing an index" do
source = source_fixture(index_frequency_minutes: 30, last_indexed_at: now_minus(5, :minutes))
assert {:ok, _} = SlowIndexingHelpers.kickoff_indexing_task(source, %{force: true})
[job] = all_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
assert_in_delta DateTime.diff(job.scheduled_at, DateTime.utc_now(), :second), 0, 1
end
test "creates and attaches a task" do
source = source_fixture(index_frequency_minutes: 1)
@@ -123,12 +137,6 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
end
describe "delete_indexing_tasks/2" do
setup do
source = source_fixture()
{:ok, %{source: source}}
end
test "deletes slow indexing tasks for the source", %{source: source} do
{:ok, job} = Oban.insert(MediaCollectionIndexingWorker.new(%{"id" => source.id}))
_task = task_fixture(source_id: source.id, job_id: job.id)
@@ -172,13 +180,13 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
end
end
describe "index_and_enqueue_download_for_media_items/1" do
describe "index_and_enqueue_download_for_media_items/2" do
setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts ->
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, source_attributes_return_fixture()}
end)
{:ok, [source: source_fixture()]}
:ok
end
test "creates a media_item record for each media ID returned", %{source: source} do
@@ -259,7 +267,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
end
test "doesn't blow up if a media item cannot be coerced into a struct", %{source: source} do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts ->
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
response =
Phoenix.json_library().encode!(%{
id: "video3",
@@ -283,7 +291,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
end
test "passes the source's download options to the yt-dlp runner", %{source: source} do
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, _addl_opts ->
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, opts, _ot, _addl_opts ->
assert {:output, "/tmp/test/media/%(title)S.%(ext)S"} in opts
assert {:remux_video, "mp4"} in opts
{:ok, source_attributes_return_fixture()}
@@ -293,7 +301,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
end
test "sets use_cookies if the source uses cookies" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts ->
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, addl_opts ->
assert {:use_cookies, true} in addl_opts
{:ok, source_attributes_return_fixture()}
end)
@@ -304,7 +312,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
end
test "doesn't set use_cookies if the source doesn't use cookies" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts ->
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, addl_opts ->
assert {:use_cookies, false} in addl_opts
{:ok, source_attributes_return_fixture()}
end)
@@ -315,15 +323,11 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
end
end
describe "index_and_enqueue_download_for_media_items/1 when testing file watcher" do
setup do
{:ok, [source: source_fixture()]}
end
describe "index_and_enqueue_download_for_media_items/2 when testing file watcher" do
test "creates a new media item for everything already in the file", %{source: source} do
watcher_poll_interval = Application.get_env(:pinchflat, :file_watcher_poll_interval)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts ->
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, addl_opts ->
filepath = Keyword.get(addl_opts, :output_filepath)
File.write(filepath, source_attributes_return_fixture())
@@ -342,7 +346,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
test "enqueues a download for everything already in the file", %{source: source} do
watcher_poll_interval = Application.get_env(:pinchflat, :file_watcher_poll_interval)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts ->
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, addl_opts ->
filepath = Keyword.get(addl_opts, :output_filepath)
File.write(filepath, source_attributes_return_fixture())
@@ -362,7 +366,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
watcher_poll_interval = Application.get_env(:pinchflat, :file_watcher_poll_interval)
source = source_fixture(download_media: false)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts ->
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, addl_opts ->
filepath = Keyword.get(addl_opts, :output_filepath)
File.write(filepath, source_attributes_return_fixture())
@@ -382,7 +386,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
profile = media_profile_fixture(%{shorts_behaviour: :exclude})
source = source_fixture(%{media_profile_id: profile.id})
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts ->
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, addl_opts ->
filepath = Keyword.get(addl_opts, :output_filepath)
contents =
@@ -413,7 +417,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
test "does not enqueue multiple download jobs for the same media items", %{source: source} do
watcher_poll_interval = Application.get_env(:pinchflat, :file_watcher_poll_interval)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts ->
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, addl_opts ->
filepath = Keyword.get(addl_opts, :output_filepath)
File.write(filepath, source_attributes_return_fixture())
@@ -432,7 +436,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
test "does not blow up if the file returns invalid json", %{source: source} do
watcher_poll_interval = Application.get_env(:pinchflat, :file_watcher_poll_interval)
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts ->
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, addl_opts ->
filepath = Keyword.get(addl_opts, :output_filepath)
File.write(filepath, "INVALID")
@@ -446,4 +450,62 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
assert [] = SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
end
end
describe "index_and_enqueue_download_for_media_items when testing the download archive" do
test "a download archive is used if the source is a channel", %{source: source} do
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, opts, _ot, _addl_opts ->
assert :break_on_existing in opts
assert Keyword.has_key?(opts, :download_archive)
{:ok, source_attributes_return_fixture()}
end)
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
end
test "a download archive is not used if the source is not a channel" do
source = source_fixture(%{collection_type: :playlist})
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, opts, _ot, _addl_opts ->
refute :break_on_existing in opts
refute Keyword.has_key?(opts, :download_archive)
{:ok, source_attributes_return_fixture()}
end)
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
end
test "a download archive is not used if the index has been forced to run" do
source = source_fixture(%{collection_type: :channel})
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, opts, _ot, _addl_opts ->
refute :break_on_existing in opts
refute Keyword.has_key?(opts, :download_archive)
{:ok, source_attributes_return_fixture()}
end)
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source, was_forced: true)
end
test "the download archive is formatted correctly and contains the right video", %{source: source} do
media_items =
1..21
|> Enum.map(fn n ->
media_item_fixture(%{source_id: source.id, uploaded_at: now_minus(n, :days)})
end)
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, opts, _ot, _addl_opts ->
archive_file = Keyword.get(opts, :download_archive)
last_media_item = List.last(media_items)
assert File.read!(archive_file) == "youtube #{last_media_item.media_id}"
{:ok, source_attributes_return_fixture()}
end)
SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
end
end
end
+27 -27
View File
@@ -85,7 +85,7 @@ defmodule Pinchflat.SourcesTest do
describe "create_source/2" do
test "automatically sets the UUID" do
expect(YtDlpRunnerMock, :run, &channel_mock/4)
expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
@@ -97,7 +97,7 @@ defmodule Pinchflat.SourcesTest do
end
test "UUID is not writable by the user" do
expect(YtDlpRunnerMock, :run, &channel_mock/4)
expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
@@ -110,7 +110,7 @@ defmodule Pinchflat.SourcesTest do
end
test "creates a source and adds name + ID from runner response for channels" do
expect(YtDlpRunnerMock, :run, &channel_mock/4)
expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
@@ -123,7 +123,7 @@ defmodule Pinchflat.SourcesTest do
end
test "creates a source and adds name + ID for playlists" do
expect(YtDlpRunnerMock, :run, &playlist_mock/4)
expect(YtDlpRunnerMock, :run, &playlist_mock/5)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
@@ -136,7 +136,7 @@ defmodule Pinchflat.SourcesTest do
end
test "adds an error if the runner fails" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:error, "some error", 1} end)
expect(YtDlpRunnerMock, :run, fn _url, :get_source_details, _opts, _ot, _addl -> {:error, "some error", 1} end)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
@@ -148,7 +148,7 @@ defmodule Pinchflat.SourcesTest do
end
test "adds an error if the runner succeeds but the result was invalid JSON" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:ok, "Not JSON"} end)
expect(YtDlpRunnerMock, :run, fn _url, :get_source_details, _opts, _ot, _addl -> {:ok, "Not JSON"} end)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
@@ -160,7 +160,7 @@ defmodule Pinchflat.SourcesTest do
end
test "you can specify a custom custom_name" do
expect(YtDlpRunnerMock, :run, &channel_mock/4)
expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
@@ -174,7 +174,7 @@ defmodule Pinchflat.SourcesTest do
end
test "friendly name is pulled from collection_name if not specified" do
expect(YtDlpRunnerMock, :run, &channel_mock/4)
expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
@@ -187,7 +187,7 @@ defmodule Pinchflat.SourcesTest do
end
test "creation enforces uniqueness of collection_id scoped to the media_profile and title regex" do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot, _addl ->
expect(YtDlpRunnerMock, :run, 2, fn _url, :get_source_details, _opts, _ot, _addl ->
{:ok,
Phoenix.json_library().encode!(%{
channel: "some channel name",
@@ -208,7 +208,7 @@ defmodule Pinchflat.SourcesTest do
end
test "creation lets you duplicate collection_ids and profiles as long as the regex is different" do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot, _addl ->
expect(YtDlpRunnerMock, :run, 2, fn _url, :get_source_details, _opts, _ot, _addl ->
{:ok,
Phoenix.json_library().encode!(%{
channel: "some channel name",
@@ -232,7 +232,7 @@ defmodule Pinchflat.SourcesTest do
end
test "creation lets you duplicate collection_ids as long as the media profile is different" do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot, _addl ->
expect(YtDlpRunnerMock, :run, 2, fn _url, :get_source_details, _opts, _ot, _addl ->
{:ok,
Phoenix.json_library().encode!(%{
channel: "some channel name",
@@ -256,8 +256,8 @@ defmodule Pinchflat.SourcesTest do
end
test "collection_type is inferred from source details" do
expect(YtDlpRunnerMock, :run, &channel_mock/4)
expect(YtDlpRunnerMock, :run, &playlist_mock/4)
expect(YtDlpRunnerMock, :run, &channel_mock/5)
expect(YtDlpRunnerMock, :run, &playlist_mock/5)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
@@ -276,13 +276,13 @@ defmodule Pinchflat.SourcesTest do
end
test "creation with invalid data fails fast and does not call the runner" do
expect(YtDlpRunnerMock, :run, 0, &channel_mock/4)
expect(YtDlpRunnerMock, :run, 0, &channel_mock/5)
assert {:error, %Ecto.Changeset{}} = Sources.create_source(@invalid_source_attrs)
end
test "creation will schedule the indexing task" do
expect(YtDlpRunnerMock, :run, &channel_mock/4)
expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
@@ -295,7 +295,7 @@ defmodule Pinchflat.SourcesTest do
end
test "creation schedules an index test even if the index frequency is 0" do
expect(YtDlpRunnerMock, :run, &channel_mock/4)
expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
@@ -309,7 +309,7 @@ defmodule Pinchflat.SourcesTest do
end
test "fast_index forces the index frequency to be a default value" do
expect(YtDlpRunnerMock, :run, &channel_mock/4)
expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
@@ -324,7 +324,7 @@ defmodule Pinchflat.SourcesTest do
end
test "disabling fast index will not change the index frequency" do
expect(YtDlpRunnerMock, :run, &channel_mock/4)
expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
@@ -339,7 +339,7 @@ defmodule Pinchflat.SourcesTest do
end
test "creating will kickoff a metadata storage worker" do
expect(YtDlpRunnerMock, :run, &channel_mock/4)
expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
@@ -356,7 +356,7 @@ defmodule Pinchflat.SourcesTest do
describe "create_source/2 when testing options" do
test "run_post_commit_tasks: false won't enqueue post-commit tasks" do
expect(YtDlpRunnerMock, :run, &channel_mock/4)
expect(YtDlpRunnerMock, :run, &channel_mock/5)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
@@ -380,7 +380,7 @@ defmodule Pinchflat.SourcesTest do
end
test "updates with invalid data fails fast and does not call the runner" do
expect(YtDlpRunnerMock, :run, 0, &channel_mock/4)
expect(YtDlpRunnerMock, :run, 0, &channel_mock/5)
source = source_fixture()
@@ -388,7 +388,7 @@ defmodule Pinchflat.SourcesTest do
end
test "updating the original_url will re-fetch the source details for channels" do
expect(YtDlpRunnerMock, :run, &channel_mock/4)
expect(YtDlpRunnerMock, :run, &channel_mock/5)
source = source_fixture()
update_attrs = %{original_url: "https://www.youtube.com/channel/abc123"}
@@ -399,7 +399,7 @@ defmodule Pinchflat.SourcesTest do
end
test "updating the original_url will re-fetch the source details for playlists" do
expect(YtDlpRunnerMock, :run, &playlist_mock/4)
expect(YtDlpRunnerMock, :run, &playlist_mock/5)
source = source_fixture()
update_attrs = %{original_url: "https://www.youtube.com/playlist?list=abc123"}
@@ -410,7 +410,7 @@ defmodule Pinchflat.SourcesTest do
end
test "not updating the original_url will not re-fetch the source details" do
expect(YtDlpRunnerMock, :run, 0, &channel_mock/4)
expect(YtDlpRunnerMock, :run, 0, &channel_mock/5)
source = source_fixture()
update_attrs = %{name: "some updated name"}
@@ -428,7 +428,7 @@ defmodule Pinchflat.SourcesTest do
end
test "updating will kickoff a metadata storage worker if the original_url changes" do
expect(YtDlpRunnerMock, :run, &playlist_mock/4)
expect(YtDlpRunnerMock, :run, &playlist_mock/5)
source = source_fixture()
update_attrs = %{original_url: "https://www.youtube.com/channel/cba321"}
@@ -901,7 +901,7 @@ defmodule Pinchflat.SourcesTest do
end
end
defp playlist_mock(_url, _opts, _ot, _addl) do
defp playlist_mock(_url, :get_source_details, _opts, _ot, _addl) do
{
:ok,
Phoenix.json_library().encode!(%{
@@ -913,7 +913,7 @@ defmodule Pinchflat.SourcesTest do
}
end
defp channel_mock(_url, _opts, _ot, _addl) do
defp channel_mock(_url, :get_source_details, _opts, _ot, _addl) do
channel_id = "some_channel_id_#{:rand.uniform(1_000_000)}"
{
@@ -28,20 +28,6 @@ defmodule Pinchflat.Utils.StringUtilsTest do
end
end
describe "truncate/2" do
test "truncates a string to the given length and adds ..." do
assert StringUtils.truncate("hello world", 5) == "hello..."
end
test "breaks on a word boundary" do
assert StringUtils.truncate("hello world", 7) == "hello..."
end
test "does not truncate a string shorter than the given length" do
assert StringUtils.truncate("hello", 10) == "hello"
end
end
describe "double_brace/1" do
test "wraps a string in double braces" do
assert StringUtils.double_brace("hello") == "{{ hello }}"
+22 -16
View File
@@ -13,30 +13,36 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
end
describe "run/4" do
test "it returns the output and status when the command succeeds" do
assert {:ok, _output} = Runner.run(@media_url, [], "")
test "returns the output and status when the command succeeds" do
assert {:ok, _output} = Runner.run(@media_url, :foo, [], "")
end
test "it includes the media url as the first argument" do
assert {:ok, output} = Runner.run(@media_url, [:ignore_errors], "")
test "considers a 101 exit code as being successful" do
wrap_executable("/app/test/support/scripts/yt-dlp-mocks/101_exit_code.sh", fn ->
assert {:ok, _output} = Runner.run(@media_url, :foo, [], "")
end)
end
test "includes the media url as the first argument" do
assert {:ok, output} = Runner.run(@media_url, :foo, [:ignore_errors], "")
assert String.contains?(output, "#{@media_url} --ignore-errors")
end
test "it automatically includes the --print-to-file flag" do
assert {:ok, output} = Runner.run(@media_url, [], "%(id)s")
test "automatically includes the --print-to-file flag" do
assert {:ok, output} = Runner.run(@media_url, :foo, [], "%(id)s")
assert String.contains?(output, "--print-to-file %(id)s /tmp/")
end
test "it returns the output and status when the command fails" do
test "returns the output and status when the command fails" do
wrap_executable("/bin/false", fn ->
assert {:error, "", 1} = Runner.run(@media_url, [], "")
assert {:error, "", 1} = Runner.run(@media_url, :foo, [], "")
end)
end
test "optionally lets you specify an output_filepath" do
assert {:ok, output} = Runner.run(@media_url, [], "%(id)s", output_filepath: "/tmp/yt-dlp-output.json")
assert {:ok, output} = Runner.run(@media_url, :foo, [], "%(id)s", output_filepath: "/tmp/yt-dlp-output.json")
assert String.contains?(output, "--print-to-file %(id)s /tmp/yt-dlp-output.json")
end
@@ -54,7 +60,7 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
test "includes cookie options when cookies.txt exists and enabled", %{cookie_file: cookie_file} do
FilesystemUtils.write_p!(cookie_file, "cookie data")
assert {:ok, output} = Runner.run(@media_url, [], "", use_cookies: true)
assert {:ok, output} = Runner.run(@media_url, :foo, [], "", use_cookies: true)
assert String.contains?(output, "--cookies #{cookie_file}")
end
@@ -62,7 +68,7 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
test "doesn't include cookie options when cookies.txt exists but disabled", %{cookie_file: cookie_file} do
FilesystemUtils.write_p!(cookie_file, "cookie data")
assert {:ok, output} = Runner.run(@media_url, [], "", use_cookies: false)
assert {:ok, output} = Runner.run(@media_url, :foo, [], "", use_cookies: false)
refute String.contains?(output, "--cookies #{cookie_file}")
end
@@ -70,7 +76,7 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
test "doesn't include cookie options when cookies.txt blank", %{cookie_file: cookie_file} do
FilesystemUtils.write_p!(cookie_file, " \n \n ")
assert {:ok, output} = Runner.run(@media_url, [], "", use_cookies: true)
assert {:ok, output} = Runner.run(@media_url, :foo, [], "", use_cookies: true)
refute String.contains?(output, "--cookies")
refute String.contains?(output, cookie_file)
@@ -79,7 +85,7 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
test "doesn't include cookie options when cookies.txt doesn't exist", %{cookie_file: cookie_file} do
File.rm(cookie_file)
assert {:ok, output} = Runner.run(@media_url, [], "")
assert {:ok, output} = Runner.run(@media_url, :foo, [], "")
refute String.contains?(output, "--cookies")
refute String.contains?(output, cookie_file)
@@ -91,19 +97,19 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
describe "run/4 when testing global options" do
test "creates windows-safe filenames" do
assert {:ok, output} = Runner.run(@media_url, [], "")
assert {:ok, output} = Runner.run(@media_url, :foo, [], "")
assert String.contains?(output, "--windows-filenames")
end
test "runs quietly" do
assert {:ok, output} = Runner.run(@media_url, [], "")
assert {:ok, output} = Runner.run(@media_url, :foo, [], "")
assert String.contains?(output, "--quiet")
end
test "sets the cache directory" do
assert {:ok, output} = Runner.run(@media_url, [], "")
assert {:ok, output} = Runner.run(@media_url, :foo, [], "")
assert String.contains?(output, "--cache-dir /tmp/test/tmpfiles/yt-dlp-cache")
end
+26 -19
View File
@@ -10,7 +10,7 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
describe "get_media_attributes_for_collection/2" do
test "returns a list of video attributes with no blank elements" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts ->
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, source_attributes_return_fixture() <> "\n\n"}
end)
@@ -19,7 +19,7 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
end
test "passes the expected default args" do
expect(YtDlpRunnerMock, :run, fn _url, opts, ot, _addl_opts ->
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, opts, ot, _addl_opts ->
assert opts == [:simulate, :skip_download, :ignore_no_formats_error, :no_warnings]
assert ot == Media.indexing_output_template()
@@ -30,13 +30,15 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
end
test "returns the error straight through when the command fails" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:error, "Big issue", 1} end)
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:error, "Big issue", 1}
end)
assert {:error, "Big issue", 1} = MediaCollection.get_media_attributes_for_collection(@channel_url)
end
test "passes long additional command options" do
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, _addl_opts ->
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, opts, _ot, _addl_opts ->
assert :foo in opts
{:ok, ""}
@@ -46,7 +48,7 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
end
test "passes additional args to runner" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl_opts ->
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, addl_opts ->
assert [{:output_filepath, filepath} | _] = addl_opts
assert {:use_cookies, false} in addl_opts
assert String.ends_with?(filepath, ".json")
@@ -58,7 +60,10 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
end
test "supports an optional file_listener_handler that gets passed a filename" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, ""} end)
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, ""}
end)
current_self = self()
handler = fn filename ->
@@ -73,7 +78,7 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
end
test "gracefully handles partially failed responses" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts ->
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, "INVALID\n\n" <> source_attributes_return_fixture() <> "\nINVALID\n"}
end)
@@ -84,7 +89,7 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
describe "get_source_details/1" do
test "returns a map with data on success" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts ->
expect(YtDlpRunnerMock, :run, fn _url, :get_source_details, _opts, _ot, _addl_opts ->
Phoenix.json_library().encode(%{
channel: "PinchflatTestChannel",
channel_id: "UCQH2",
@@ -104,7 +109,7 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
end
test "passes the expected args to the runner" do
expect(YtDlpRunnerMock, :run, fn @channel_url, opts, ot, _addl_opts ->
expect(YtDlpRunnerMock, :run, fn @channel_url, :get_source_details, opts, ot, _addl_opts ->
assert opts == [:simulate, :skip_download, :ignore_no_formats_error, playlist_end: 1]
assert ot == "%(.{channel,channel_id,playlist_id,playlist_title,filename})j"
@@ -115,7 +120,7 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
end
test "passes custom args to the runner" do
expect(YtDlpRunnerMock, :run, fn @channel_url, opts, _ot, _addl_opts ->
expect(YtDlpRunnerMock, :run, fn @channel_url, :get_source_details, opts, _ot, _addl_opts ->
assert {:foo, :bar} in opts
{:ok, "{}"}
@@ -125,7 +130,7 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
end
test "passes additional args to the runner" do
expect(YtDlpRunnerMock, :run, fn @channel_url, _opts, _ot, addl_opts ->
expect(YtDlpRunnerMock, :run, fn @channel_url, :get_source_details, _opts, _ot, addl_opts ->
assert {:use_cookies, true} in addl_opts
{:ok, "{}"}
@@ -135,13 +140,13 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
end
test "returns an error if the runner returns an error" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:error, "Big issue", 1} end)
expect(YtDlpRunnerMock, :run, fn _url, :get_source_details, _opts, _ot, _addl_opts -> {:error, "Big issue", 1} end)
assert {:error, "Big issue", 1} = MediaCollection.get_source_details(@channel_url)
end
test "returns an error if the output is not JSON" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, "Not JSON"} end)
expect(YtDlpRunnerMock, :run, fn _url, :get_source_details, _opts, _ot, _addl_opts -> {:ok, "Not JSON"} end)
assert {:error, "Error decoding JSON response"} = MediaCollection.get_source_details(@channel_url)
end
@@ -149,7 +154,7 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
describe "get_source_metadata/1" do
test "returns a map with data on success" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts ->
expect(YtDlpRunnerMock, :run, fn _url, :get_source_metadata, _opts, _ot, _addl_opts ->
Phoenix.json_library().encode(%{channel: "PinchflatTestChannel"})
end)
@@ -159,7 +164,7 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
end
test "passes the expected args to the backend runner" do
expect(YtDlpRunnerMock, :run, fn @channel_url, opts, ot, _addl_opts ->
expect(YtDlpRunnerMock, :run, fn @channel_url, :get_source_metadata, opts, ot, _addl_opts ->
assert opts == [:skip_download, playlist_items: 0]
assert ot == "playlist:%()j"
@@ -170,7 +175,7 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
end
test "passes additional args to the runner" do
expect(YtDlpRunnerMock, :run, fn @channel_url, _opts, _ot, addl_opts ->
expect(YtDlpRunnerMock, :run, fn @channel_url, :get_source_metadata, _opts, _ot, addl_opts ->
assert {:use_cookies, true} in addl_opts
{:ok, "{}"}
@@ -180,7 +185,7 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
end
test "passes custom args to the runner" do
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, _addl_opts ->
expect(YtDlpRunnerMock, :run, fn _url, :get_source_metadata, opts, _ot, _addl_opts ->
assert opts == [:skip_download, playlist_items: 1, real_opt: :yup]
{:ok, "{}"}
@@ -196,13 +201,15 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
end
test "returns an error if the runner returns an error" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:error, "Big issue", 1} end)
expect(YtDlpRunnerMock, :run, fn _url, :get_source_metadata, _opts, _ot, _addl_opts ->
{:error, "Big issue", 1}
end)
assert {:error, "Big issue", 1} = MediaCollection.get_source_metadata(@channel_url, playlist_items: 0)
end
test "returns an error if the output is not JSON" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> {:ok, "Not JSON"} end)
expect(YtDlpRunnerMock, :run, fn _url, :get_source_metadata, _opts, _ot, _addl_opts -> {:ok, "Not JSON"} end)
assert {:error, %Jason.DecodeError{}} = MediaCollection.get_source_metadata(@channel_url, playlist_items: 0)
end
+31 -21
View File
@@ -9,7 +9,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
describe "download/3" do
test "calls the backend runner with the expected arguments" do
expect(YtDlpRunnerMock, :run, fn @media_url, opts, ot, addl ->
expect(YtDlpRunnerMock, :run, fn @media_url, :download, opts, ot, addl ->
assert [:no_simulate] = opts
assert "after_move:%()j" = ot
assert addl == []
@@ -21,7 +21,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
end
test "passes along custom command args" do
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, _addl ->
expect(YtDlpRunnerMock, :run, fn _url, :download, opts, _ot, _addl ->
assert [:no_simulate, :custom_arg] = opts
{:ok, "{}"}
@@ -31,7 +31,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
end
test "passes along additional options" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl ->
expect(YtDlpRunnerMock, :run, fn _url, :download, _opts, _ot, addl ->
assert [addl_arg: true] = addl
{:ok, "{}"}
@@ -41,7 +41,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
end
test "parses and returns the generated file as JSON" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
expect(YtDlpRunnerMock, :run, fn _url, :download, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
end)
@@ -50,7 +50,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
end
test "returns errors" do
expect(YtDlpRunnerMock, :run, fn _url, _opt, _ot, _addl ->
expect(YtDlpRunnerMock, :run, fn _url, :download, _opt, _ot, _addl ->
{:error, "something"}
end)
@@ -60,7 +60,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
describe "get_downloadable_status/1" do
test "returns :downloadable if the media was never live" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
expect(YtDlpRunnerMock, :run, fn _url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "not_live"})}
end)
@@ -68,7 +68,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
end
test "returns :downloadable if the media was live and has been processed" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
expect(YtDlpRunnerMock, :run, fn _url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "was_live"})}
end)
@@ -76,7 +76,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
end
test "returns :downloadable if the media's live_status is nil" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
expect(YtDlpRunnerMock, :run, fn _url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => nil})}
end)
@@ -84,7 +84,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
end
test "returns :ignorable if the media is currently live" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
expect(YtDlpRunnerMock, :run, fn _url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "is_live"})}
end)
@@ -92,7 +92,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
end
test "returns :ignorable if the media is scheduled to be live" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
expect(YtDlpRunnerMock, :run, fn _url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "is_upcoming"})}
end)
@@ -100,7 +100,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
end
test "returns :ignorable if the media was live but hasn't been processed" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
expect(YtDlpRunnerMock, :run, fn _url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "post_live"})}
end)
@@ -108,17 +108,27 @@ defmodule Pinchflat.YtDlp.MediaTest do
end
test "returns an error if the downloadable status can't be determined" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
expect(YtDlpRunnerMock, :run, fn _url, :get_downloadable_status, _opts, _ot, _addl ->
{:ok, Phoenix.json_library().encode!(%{"live_status" => "what_tha"})}
end)
assert {:error, "Unknown live status: what_tha"} = Media.get_downloadable_status(@media_url)
end
test "optionally accepts additional args" do
expect(YtDlpRunnerMock, :run, fn _url, :get_downloadable_status, _opts, _ot, addl ->
assert [addl_arg: true] = addl
{:ok, Phoenix.json_library().encode!(%{"live_status" => "not_live"})}
end)
assert {:ok, :downloadable} = Media.get_downloadable_status(@media_url, addl_arg: true)
end
end
describe "download_thumbnail/2" do
test "calls the backend runner with the expected arguments" do
expect(YtDlpRunnerMock, :run, fn @media_url, opts, ot, _addl ->
expect(YtDlpRunnerMock, :run, fn @media_url, :download_thumbnail, opts, ot, _addl ->
assert opts == [:no_simulate, :skip_download, :write_thumbnail, {:convert_thumbnail, "jpg"}]
assert ot == "after_move:%()j"
@@ -129,7 +139,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
end
test "passes along custom command args" do
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, _addl ->
expect(YtDlpRunnerMock, :run, fn _url, :download_thumbnail, opts, _ot, _addl ->
assert :custom_arg in opts
{:ok, "{}"}
@@ -139,7 +149,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
end
test "passes along additional options" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl ->
expect(YtDlpRunnerMock, :run, fn _url, :download_thumbnail, _opts, _ot, addl ->
assert [addl_arg: true] = addl
{:ok, "{}"}
@@ -149,7 +159,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
end
test "returns errors" do
expect(YtDlpRunnerMock, :run, fn _url, _opt, _ot, _addl ->
expect(YtDlpRunnerMock, :run, fn _url, :download_thumbnail, _opt, _ot, _addl ->
{:error, "something"}
end)
@@ -159,7 +169,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
describe "get_media_attributes/1" do
test "returns a list of video attributes" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, _addl ->
{:ok, media_attributes_return_fixture()}
end)
@@ -168,7 +178,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
end
test "it passes the expected default args" do
expect(YtDlpRunnerMock, :run, fn _url, opts, ot, _addl ->
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, opts, ot, _addl ->
assert opts == [:simulate, :skip_download]
assert ot == Media.indexing_output_template()
@@ -179,7 +189,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
end
test "passes along additional command options" do
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, _addl ->
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, opts, _ot, _addl ->
assert [:simulate, :skip_download, :custom_arg] = opts
{:ok, media_attributes_return_fixture()}
end)
@@ -188,7 +198,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
end
test "passes along additional options" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, addl ->
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, addl ->
assert [addl_arg: true] = addl
{:ok, media_attributes_return_fixture()}
end)
@@ -197,7 +207,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
end
test "returns the error straight through when the command fails" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> {:error, "Big issue", 1} end)
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes, _opts, _ot, _addl -> {:error, "Big issue", 1} end)
assert {:error, "Big issue", 1} = Media.get_media_attributes(@media_url)
end
@@ -6,7 +6,6 @@ defmodule PinchflatWeb.Pages.JobTableLiveTest do
import Pinchflat.MediaFixtures
import Pinchflat.SourcesFixtures
alias Pinchflat.Utils.StringUtils
alias Pinchflat.Pages.JobTableLive
alias Pinchflat.Downloading.MediaDownloadWorker
alias Pinchflat.FastIndexing.FastIndexingWorker
@@ -48,7 +47,7 @@ defmodule PinchflatWeb.Pages.JobTableLiveTest do
{_source, media_item, _task, _job} = create_media_item_job()
{:ok, _view, html} = live_isolated(conn, JobTableLive, session: %{})
assert html =~ StringUtils.truncate(media_item.title, 35)
assert html =~ media_item.title
end
test "shows a media item link", %{conn: conn} do
@@ -62,7 +61,7 @@ defmodule PinchflatWeb.Pages.JobTableLiveTest do
{source, _task, _job} = create_source_job()
{:ok, _view, html} = live_isolated(conn, JobTableLive, session: %{})
assert html =~ StringUtils.truncate(source.custom_name, 35)
assert html =~ source.custom_name
end
test "shows a source link", %{conn: conn} do
@@ -4,6 +4,37 @@ defmodule PinchflatWeb.PodcastControllerTest do
import Pinchflat.MediaFixtures
import Pinchflat.SourcesFixtures
alias Pinchflat.Settings
describe "opml_feed" do
test "renders the XML document", %{conn: conn} do
source = source_fixture()
route_token = Settings.get!(:route_token)
conn = get(conn, ~p"/sources/opml.xml?#{[route_token: route_token]}")
assert conn.status == 200
assert {"content-type", "application/opml+xml; charset=utf-8"} in conn.resp_headers
assert {"content-disposition", "inline"} in conn.resp_headers
assert conn.resp_body =~ ~s"http://www.example.com/sources/#{source.uuid}/feed.xml"
assert conn.resp_body =~ "text=\"#{source.custom_name}\""
end
test "returns 401 if the route token is incorrect", %{conn: conn} do
conn = get(conn, ~p"/sources/opml.xml?route_token=incorrect")
assert conn.status == 401
assert conn.resp_body == "Unauthorized"
end
test "returns 401 if the route token is missing", %{conn: conn} do
conn = get(conn, ~p"/sources/opml.xml")
assert conn.status == 401
assert conn.resp_body == "Unauthorized"
end
end
describe "rss_feed" do
test "renders the XML document", %{conn: conn} do
source = source_fixture()
@@ -66,7 +66,7 @@ defmodule PinchflatWeb.SourceControllerTest do
describe "create source" do
test "redirects to show when data is valid", %{conn: conn, create_attrs: create_attrs} do
expect(YtDlpRunnerMock, :run, 1, &runner_function_mock/4)
expect(YtDlpRunnerMock, :run, 1, &runner_function_mock/5)
conn = post(conn, ~p"/sources", source: create_attrs)
assert %{id: id} = redirected_params(conn)
@@ -82,7 +82,7 @@ defmodule PinchflatWeb.SourceControllerTest do
end
test "redirects to onboarding when onboarding", %{conn: conn, create_attrs: create_attrs} do
expect(YtDlpRunnerMock, :run, 1, &runner_function_mock/4)
expect(YtDlpRunnerMock, :run, 1, &runner_function_mock/5)
Settings.set(onboarding: true)
conn = post(conn, ~p"/sources", source: create_attrs)
@@ -111,7 +111,7 @@ defmodule PinchflatWeb.SourceControllerTest do
setup [:create_source]
test "redirects when data is valid", %{conn: conn, source: source, update_attrs: update_attrs} do
expect(YtDlpRunnerMock, :run, 1, &runner_function_mock/4)
expect(YtDlpRunnerMock, :run, 1, &runner_function_mock/5)
conn = put(conn, ~p"/sources/#{source}", source: update_attrs)
assert redirected_to(conn) == ~p"/sources/#{source}"
@@ -276,7 +276,7 @@ defmodule PinchflatWeb.SourceControllerTest do
%{source: source, media_item: media_item}
end
defp runner_function_mock(_url, _opts, _ot, _addl) do
defp runner_function_mock(_url, :get_source_details, _opts, _ot, _addl) do
{
:ok,
Phoenix.json_library().encode!(%{
@@ -1,4 +1,4 @@
defmodule PinchflatWeb.Sources.IndexTableLiveTest do
defmodule PinchflatWeb.Sources.SourceLive.IndexTableLiveTest do
use PinchflatWeb.ConnCase
import Phoenix.LiveViewTest
@@ -6,13 +6,13 @@ defmodule PinchflatWeb.Sources.IndexTableLiveTest do
import Pinchflat.ProfilesFixtures
alias Pinchflat.Sources.Source
alias PinchflatWeb.Sources.IndexTableLive
alias PinchflatWeb.Sources.SourceLive.IndexTableLive
describe "initial rendering" do
test "lists all sources", %{conn: conn} do
source = source_fixture()
{:ok, _view, html} = live_isolated(conn, IndexTableLive)
{:ok, _view, html} = live_isolated(conn, IndexTableLive, session: create_session())
assert html =~ source.custom_name
end
@@ -20,7 +20,7 @@ defmodule PinchflatWeb.Sources.IndexTableLiveTest do
test "omits sources that have marked_for_deletion_at set", %{conn: conn} do
source = source_fixture(marked_for_deletion_at: DateTime.utc_now())
{:ok, _view, html} = live_isolated(conn, IndexTableLive)
{:ok, _view, html} = live_isolated(conn, IndexTableLive, session: create_session())
refute html =~ source.custom_name
end
@@ -29,27 +29,102 @@ defmodule PinchflatWeb.Sources.IndexTableLiveTest do
media_profile = media_profile_fixture(marked_for_deletion_at: DateTime.utc_now())
source = source_fixture(media_profile_id: media_profile.id)
{:ok, _view, html} = live_isolated(conn, IndexTableLive)
{:ok, _view, html} = live_isolated(conn, IndexTableLive, session: create_session())
refute html =~ source.custom_name
end
end
describe "when a source is enabled or disabled" do
describe "when testing sorting" do
test "sorts by the custom_name by default", %{conn: conn} do
source1 = source_fixture(custom_name: "Source_B")
source2 = source_fixture(custom_name: "Source_A")
{:ok, view, _html} = live_isolated(conn, IndexTableLive, session: create_session())
assert render_element(view, "tbody tr:first-child") =~ source2.custom_name
assert render_element(view, "tbody tr:last-child") =~ source1.custom_name
end
test "clicking the row will change the sort direction", %{conn: conn} do
source1 = source_fixture(custom_name: "Source_B")
source2 = source_fixture(custom_name: "Source_A")
{:ok, view, _html} = live_isolated(conn, IndexTableLive, session: create_session())
# Click the row to change the sort direction
click_element(view, "th", "Name")
assert render_element(view, "tbody tr:first-child") =~ source1.custom_name
assert render_element(view, "tbody tr:last-child") =~ source2.custom_name
end
test "clicking a different row will sort by that attribute", %{conn: conn} do
source1 = source_fixture(custom_name: "Source_A", enabled: true)
source2 = source_fixture(custom_name: "Source_A", enabled: false)
{:ok, view, _html} = live_isolated(conn, IndexTableLive, session: create_session())
# Click the row to change the sort field
click_element(view, "th", "Enabled?")
assert render_element(view, "tbody tr:first-child") =~ source2.custom_name
assert render_element(view, "tbody tr:last-child") =~ source1.custom_name
# Click the row to again change the sort direcation
click_element(view, "th", "Enabled?")
assert render_element(view, "tbody tr:first-child") =~ source1.custom_name
assert render_element(view, "tbody tr:last-child") =~ source2.custom_name
end
end
describe "when testing pagination" do
test "moving to the next page loads new records", %{conn: conn} do
source1 = source_fixture(custom_name: "Source_A")
source2 = source_fixture(custom_name: "Source_B")
session = Map.merge(create_session(), %{"results_per_page" => 1})
{:ok, view, _html} = live_isolated(conn, IndexTableLive, session: session)
assert render_element(view, "tbody") =~ source1.custom_name
refute render_element(view, "tbody") =~ source2.custom_name
click_element(view, "span.pagination-next")
refute render_element(view, "tbody") =~ source1.custom_name
assert render_element(view, "tbody") =~ source2.custom_name
end
end
describe "when testing the enable toggle" do
test "updates the source's enabled status", %{conn: conn} do
source = source_fixture(enabled: true)
{:ok, view, _html} = live_isolated(conn, IndexTableLive)
{:ok, view, _html} = live_isolated(conn, IndexTableLive, session: create_session())
params = %{
"event" => "toggle_enabled",
"id" => source.id,
"value" => "false"
}
# Send an event to the server directly
render_change(view, "formless-input", params)
view
|> element(".enabled_toggle_form")
|> render_change(%{source: %{"enabled" => false}})
assert %{enabled: false} = Repo.get!(Source, source.id)
end
end
defp click_element(view, selector, text_filter \\ nil) do
view
|> element(selector, text_filter)
|> render_click()
end
defp render_element(view, selector) do
view
|> element(selector)
|> render()
end
defp create_session do
%{
"initial_sort_key" => :custom_name,
"initial_sort_direction" => :asc,
"results_per_page" => 10
}
end
end
@@ -0,0 +1,26 @@
defmodule PinchflatWeb.Sources.SourceLive.SourceEnableToggleTest do
use PinchflatWeb.ConnCase
import Phoenix.LiveViewTest
alias PinchflatWeb.Sources.SourceLive.SourceEnableToggle
describe "initial rendering" do
test "renders a toggle in the on position if the source is enabled" do
source = %{id: 1, enabled: true}
html = render_component(SourceEnableToggle, %{id: :foo, source: source})
# This is checking the Alpine attrs which is a good-enough proxy for the toggle position
assert html =~ "{ enabled: true }"
end
test "renders a toggle in the off position if the source is disabled" do
source = %{id: 1, enabled: false}
html = render_component(SourceEnableToggle, %{id: :foo, source: source})
assert html =~ "{ enabled: false }"
end
end
end
@@ -0,0 +1,96 @@
defmodule PinchflatWeb.Helpers.PaginationHelpersTest do
use Pinchflat.DataCase
import Pinchflat.SourcesFixtures
alias Pinchflat.Sources.Source
alias PinchflatWeb.Helpers.PaginationHelpers
describe "get_pagination_attributes/3" do
test "returns the correct pagination attributes" do
source_fixture()
query = from(s in Source, select: s.id)
page = 1
records_per_page = 10
pagination_attributes = PaginationHelpers.get_pagination_attributes(query, page, records_per_page)
assert pagination_attributes.page == 1
assert pagination_attributes.total_pages == 1
assert pagination_attributes.total_record_count == 1
assert pagination_attributes.limit == 10
assert pagination_attributes.offset == 0
end
test "returns the correct pagination attributes when there are multiple pages" do
source_fixture()
source_fixture()
query = from(s in Source, select: s.id)
page = 1
records_per_page = 1
pagination_attributes = PaginationHelpers.get_pagination_attributes(query, page, records_per_page)
assert pagination_attributes.page == 1
assert pagination_attributes.total_pages == 2
assert pagination_attributes.total_record_count == 2
assert pagination_attributes.limit == 1
assert pagination_attributes.offset == 0
end
test "returns the correct attributes when on a page other than the first" do
source_fixture()
source_fixture()
query = from(s in Source, select: s.id)
page = 2
records_per_page = 1
pagination_attributes = PaginationHelpers.get_pagination_attributes(query, page, records_per_page)
assert pagination_attributes.page == 2
assert pagination_attributes.total_pages == 2
assert pagination_attributes.total_record_count == 2
assert pagination_attributes.limit == 1
assert pagination_attributes.offset == 1
end
end
describe "update_page_number/3" do
test "increments the page number" do
current_page = 1
total_pages = 2
updated_page = PaginationHelpers.update_page_number(current_page, :inc, total_pages)
assert updated_page == 2
end
test "decrements the page number" do
current_page = 2
total_pages = 2
updated_page = PaginationHelpers.update_page_number(current_page, :dec, total_pages)
assert updated_page == 1
end
test "doesn't overflow the page number" do
current_page = 2
total_pages = 2
updated_page = PaginationHelpers.update_page_number(current_page, :inc, total_pages)
assert updated_page == 2
end
test "doesn't underflow the page number" do
current_page = 1
total_pages = 2
updated_page = PaginationHelpers.update_page_number(current_page, :dec, total_pages)
assert updated_page == 1
end
end
end
@@ -0,0 +1,31 @@
defmodule PinchflatWeb.Helpers.SortingHelpersTest do
use Pinchflat.DataCase
alias PinchflatWeb.Helpers.SortingHelpers
describe "get_sort_direction/3" do
test "returns the correct sort direction when the new sort attribute is the same as the old sort attribute" do
old_sort_attr = "name"
new_sort_attr = "name"
old_sort_direction = :desc
assert SortingHelpers.get_sort_direction(old_sort_attr, new_sort_attr, old_sort_direction) == :asc
end
test "returns the correct sort direction when the new sort attribute is the same as the old sort attribute in the other direction" do
old_sort_attr = "name"
new_sort_attr = "name"
old_sort_direction = :asc
assert SortingHelpers.get_sort_direction(old_sort_attr, new_sort_attr, old_sort_direction) == :desc
end
test "returns the correct sort direction when the new sort attribute is different from the old sort attribute" do
old_sort_attr = "name"
new_sort_attr = "date"
old_sort_direction = :asc
assert SortingHelpers.get_sort_direction(old_sort_attr, new_sort_attr, old_sort_direction) == :asc
end
end
end
+166
View File
@@ -0,0 +1,166 @@
defmodule PinchflatWeb.PlugsTest do
use PinchflatWeb.ConnCase
alias PinchflatWeb.Plugs
alias Pinchflat.Settings
describe "maybe_basic_auth/2" do
setup do
old_username = Application.get_env(:pinchflat, :basic_auth_username)
old_password = Application.get_env(:pinchflat, :basic_auth_password)
old_expose_feed_endpoints = Application.get_env(:pinchflat, :expose_feed_endpoints)
on_exit(fn ->
Application.put_env(:pinchflat, :basic_auth_username, old_username)
Application.put_env(:pinchflat, :basic_auth_password, old_password)
Application.put_env(:pinchflat, :expose_feed_endpoints, old_expose_feed_endpoints)
end)
:ok
end
test "uses basic auth when expose_feed_endpoints is false" do
Application.put_env(:pinchflat, :basic_auth_username, "user")
Application.put_env(:pinchflat, :basic_auth_password, "pass")
Application.put_env(:pinchflat, :expose_feed_endpoints, false)
conn = Plugs.maybe_basic_auth(build_conn(), [])
assert conn.status == 401
assert {"www-authenticate", "Basic realm=\"Pinchflat\""} in conn.resp_headers
end
test "supplying the correct username and password allows access" do
Application.put_env(:pinchflat, :basic_auth_username, "user")
Application.put_env(:pinchflat, :basic_auth_password, "pass")
Application.put_env(:pinchflat, :expose_feed_endpoints, false)
encoded_auth = Plug.BasicAuth.encode_basic_auth("user", "pass")
conn =
build_conn()
|> put_req_header("authorization", encoded_auth)
|> Plugs.maybe_basic_auth([])
# nil here means the response is unset, but that's good. It just means we're moving to the next stage
assert conn.status == nil
end
test "does not use basic auth when expose_feed_endpoints is true" do
Application.put_env(:pinchflat, :basic_auth_username, "user")
Application.put_env(:pinchflat, :basic_auth_password, "pass")
Application.put_env(:pinchflat, :expose_feed_endpoints, true)
conn = Plugs.maybe_basic_auth(build_conn(), [])
assert conn.status == nil
end
test "does not use basic auth when username/password aren't set" do
Application.put_env(:pinchflat, :basic_auth_username, nil)
Application.put_env(:pinchflat, :basic_auth_password, nil)
Application.put_env(:pinchflat, :expose_feed_endpoints, false)
conn = Plugs.maybe_basic_auth(build_conn(), [])
# nil here means the response is unset, but that's good. It just means we're moving to the next stage
assert conn.status == nil
end
end
describe "basic_auth/2" do
setup do
old_username = Application.get_env(:pinchflat, :basic_auth_username)
old_password = Application.get_env(:pinchflat, :basic_auth_password)
on_exit(fn ->
Application.put_env(:pinchflat, :basic_auth_username, old_username)
Application.put_env(:pinchflat, :basic_auth_password, old_password)
end)
:ok
end
test "uses basic auth when both username and password are set", %{conn: conn} do
Application.put_env(:pinchflat, :basic_auth_username, "user")
Application.put_env(:pinchflat, :basic_auth_password, "pass")
conn = Plugs.basic_auth(conn, [])
assert conn.status == 401
assert {"www-authenticate", "Basic realm=\"Pinchflat\""} in conn.resp_headers
end
test "providing the username and password allows access", %{conn: conn} do
Application.put_env(:pinchflat, :basic_auth_username, "user")
Application.put_env(:pinchflat, :basic_auth_password, "pass")
conn =
conn
|> put_req_header("authorization", Plug.BasicAuth.encode_basic_auth("user", "pass"))
|> Plugs.basic_auth([])
# nil here means the response is unset, but that's good. It just means we're moving to the next stage
assert conn.status == nil
end
test "does not use basic auth when either username or password is not set", %{conn: conn} do
Application.put_env(:pinchflat, :basic_auth_username, nil)
Application.put_env(:pinchflat, :basic_auth_password, "pass")
conn = Plugs.basic_auth(conn, [])
assert conn.status == nil
end
test "treats empty strings as not being set when using basic auth", %{conn: conn} do
Application.put_env(:pinchflat, :basic_auth_username, "")
Application.put_env(:pinchflat, :basic_auth_password, "pass")
conn = Plugs.basic_auth(conn, [])
assert conn.status == nil
end
end
describe "allow_iframe_embed/2" do
test "deletes the x-frame-options header", %{conn: conn} do
conn = put_resp_header(conn, "x-frame-options", "DENY")
assert ["DENY"] = get_resp_header(conn, "x-frame-options")
conn = Plugs.allow_iframe_embed(conn, [])
assert [] = get_resp_header(conn, "x-frame-options")
end
end
describe "token_protected_route/2" do
test "allows access when the route token is correct", %{conn: conn} do
route_token = Settings.get!(:route_token)
conn = %{conn | query_params: %{"route_token" => route_token}}
conn = Plugs.token_protected_route(conn, [])
# nil here means the response is unset, but that's good. It just means we're moving to the next stage
assert conn.status == nil
end
test "does not allow access when the route token is incorrect", %{conn: conn} do
conn = %{conn | query_params: %{"route_token" => "incorrect"}}
conn = Plugs.token_protected_route(conn, [])
assert conn.status == 401
assert conn.resp_body == "Unauthorized"
end
test "does not allow access when the route token is missing", %{conn: conn} do
conn = %{conn | query_params: %{}}
conn = Plugs.token_protected_route(conn, [])
assert conn.status == 401
assert conn.resp_body == "Unauthorized"
end
end
end
-108
View File
@@ -1,108 +0,0 @@
defmodule PinchflatWeb.RoutingTest do
use PinchflatWeb.ConnCase
import Pinchflat.SourcesFixtures
describe "basic_auth plug" do
setup do
old_username = Application.get_env(:pinchflat, :basic_auth_username)
old_password = Application.get_env(:pinchflat, :basic_auth_password)
on_exit(fn ->
Application.put_env(:pinchflat, :basic_auth_username, old_username)
Application.put_env(:pinchflat, :basic_auth_password, old_password)
end)
:ok
end
test "it uses basic auth when both username and password are set", %{conn: conn} do
Application.put_env(:pinchflat, :basic_auth_username, "user")
Application.put_env(:pinchflat, :basic_auth_password, "pass")
conn = get(conn, "/")
assert conn.status == 401
assert {"www-authenticate", "Basic realm=\"Pinchflat\""} in conn.resp_headers
end
test "providing the username and password allows access", %{conn: conn} do
Application.put_env(:pinchflat, :basic_auth_username, "user")
Application.put_env(:pinchflat, :basic_auth_password, "pass")
conn =
conn
|> put_req_header("authorization", Plug.BasicAuth.encode_basic_auth("user", "pass"))
|> get("/")
assert conn.status == 200
end
test "it does not use basic auth when either username or password is not set", %{conn: conn} do
Application.put_env(:pinchflat, :basic_auth_username, nil)
Application.put_env(:pinchflat, :basic_auth_password, "pass")
conn = get(conn, "/")
assert conn.status == 200
end
test "it treats empty strings as not being set when using basic auth", %{conn: conn} do
Application.put_env(:pinchflat, :basic_auth_username, "")
Application.put_env(:pinchflat, :basic_auth_password, "pass")
conn = get(conn, "/")
assert conn.status == 200
end
end
describe "maybe_basic_auth plug" do
setup do
old_username = Application.get_env(:pinchflat, :basic_auth_username)
old_password = Application.get_env(:pinchflat, :basic_auth_password)
old_expose_feed_endpoints = Application.get_env(:pinchflat, :expose_feed_endpoints)
source = source_fixture()
on_exit(fn ->
Application.put_env(:pinchflat, :basic_auth_username, old_username)
Application.put_env(:pinchflat, :basic_auth_password, old_password)
Application.put_env(:pinchflat, :expose_feed_endpoints, old_expose_feed_endpoints)
end)
{:ok, source: source}
end
test "uses basic auth when expose_feed_endpoints is false", %{source: source} do
Application.put_env(:pinchflat, :basic_auth_username, "user")
Application.put_env(:pinchflat, :basic_auth_password, "pass")
Application.put_env(:pinchflat, :expose_feed_endpoints, false)
conn = get(build_conn(), "/sources/#{source.uuid}/feed")
assert conn.status == 401
assert {"www-authenticate", "Basic realm=\"Pinchflat\""} in conn.resp_headers
end
test "does not use basic auth when expose_feed_endpoints is true", %{source: source} do
Application.put_env(:pinchflat, :basic_auth_username, "user")
Application.put_env(:pinchflat, :basic_auth_password, "pass")
Application.put_env(:pinchflat, :expose_feed_endpoints, true)
conn = get(build_conn(), "/sources/#{source.uuid}/feed")
assert conn.status == 200
end
test "does not use basic auth when username/password aren't set", %{source: source} do
Application.put_env(:pinchflat, :basic_auth_username, nil)
Application.put_env(:pinchflat, :basic_auth_password, nil)
Application.put_env(:pinchflat, :expose_feed_endpoints, false)
conn = get(build_conn(), "/sources/#{source.uuid}/feed")
assert conn.status == 200
end
end
end
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
exit 101
+2
View File
@@ -10,6 +10,8 @@ Application.put_env(:pinchflat, :http_client, HTTPClientMock)
Mox.defmock(UserScriptRunnerMock, for: Pinchflat.Lifecycle.UserScripts.UserScriptCommandRunner)
Application.put_env(:pinchflat, :user_script_runner, UserScriptRunnerMock)
if System.get_env("EX_CHECK"), do: Code.put_compiler_option(:warnings_as_errors, true)
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(Pinchflat.Repo, :manual)
Faker.start()
+2 -1
View File
@@ -17,7 +17,8 @@
{:formatter, env: %{"MIX_ENV" => "test"}},
{:sobelow, "mix sobelow --config"},
{:prettier_formatting, "yarn run lint:check", fix: "yarn run lint:fix"},
{:npm_test, false}
{:npm_test, false},
{:ex_unit, env: %{"MIX_ENV" => "test", "EX_CHECK" => "1"}}
## curated tools may be disabled (e.g. the check for compilation warnings)
# {:compiler, false},
+2
View File
@@ -12,3 +12,5 @@ sed -i "s/version: \"$VERSION\"/version: \"$DATE\"/g" mix.exs
# Run checks to ensure it's a valid mix.exs file
mix check
echo "Version bumped successfully to $DATE"