Compare commits

...

6 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
17 changed files with 271 additions and 62 deletions
@@ -152,6 +152,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
case {behaviour, categories} do case {behaviour, categories} do
{_, []} -> [] {_, []} -> []
{:remove, _} -> [sponsorblock_remove: Enum.join(categories, ",")] {:remove, _} -> [sponsorblock_remove: Enum.join(categories, ",")]
{:mark, _} -> [sponsorblock_mark: Enum.join(categories, ",")]
{:disabled, _} -> [] {:disabled, _} -> []
end end
end end
@@ -200,6 +201,9 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
source = media_item_with_preloads.source 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_custom_name" => source.custom_name,
"source_collection_id" => source.collection_id, "source_collection_id" => source.collection_id,
"source_collection_name" => source.collection_name, "source_collection_name" => source.collection_name,
+1 -1
View File
@@ -55,7 +55,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
field :embed_metadata, :boolean, default: false field :embed_metadata, :boolean, default: false
field :download_nfo, :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: [] field :sponsorblock_categories, {:array, :string}, default: []
# NOTE: these do NOT speed up indexing - the indexer still has to go # 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 # through the entire collection to determine if a media is a short or
@@ -79,21 +79,21 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
case {source.index_frequency_minutes, source.last_indexed_at} do case {source.index_frequency_minutes, source.last_indexed_at} do
{index_freq, _} when index_freq > 0 -> {index_freq, _} when index_freq > 0 ->
# If the indexing is on a schedule simply run indexing and reschedule # If the indexing is on a schedule simply run indexing and reschedule
perform_indexing_and_notification(source) perform_indexing_and_notification(source, was_forced: args["force"])
maybe_enqueue_fast_indexing_task(source) maybe_enqueue_fast_indexing_task(source)
reschedule_indexing(source) reschedule_indexing(source)
{_, nil} -> {_, nil} ->
# If the source has never been indexed, index it once # If the source has never been indexed, index it once
# even if it's not meant to reschedule # even if it's not meant to reschedule
perform_indexing_and_notification(source) perform_indexing_and_notification(source, was_forced: args["force"])
:ok :ok
_ -> _ ->
# If the source HAS been indexed and is not meant to reschedule, # If the source HAS been indexed and is not meant to reschedule,
# perform a no-op (unless forced) # perform a no-op (unless forced)
if args["force"] do if args["force"] do
perform_indexing_and_notification(source) perform_indexing_and_notification(source, was_forced: true)
end end
:ok :ok
@@ -103,11 +103,11 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale") Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
end end
defp perform_indexing_and_notification(source) do defp perform_indexing_and_notification(source, indexing_opts) do
apprise_server = Settings.get!(:apprise_server) apprise_server = Settings.get!(:apprise_server)
SourceNotifications.wrap_new_media_notification(apprise_server, source, fn -> 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)
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. Many of these methods are made to be kickoff or be consumed by workers.
""" """
use Pinchflat.Media.MediaQuery
require Logger require Logger
alias Pinchflat.Repo alias Pinchflat.Repo
@@ -14,6 +16,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.YtDlp.MediaCollection alias Pinchflat.YtDlp.MediaCollection
alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.Downloading.DownloadingHelpers alias Pinchflat.Downloading.DownloadingHelpers
alias Pinchflat.SlowIndexing.FileFollowerServer alias Pinchflat.SlowIndexing.FileFollowerServer
alias Pinchflat.Downloading.DownloadOptionBuilder alias Pinchflat.Downloading.DownloadOptionBuilder
@@ -22,13 +25,19 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
alias Pinchflat.YtDlp.Media, as: YtDlpMedia alias Pinchflat.YtDlp.Media, as: YtDlpMedia
@doc """ @doc """
Starts tasks for indexing a source's media regardless of the source's indexing Kills old indexing tasks and starts a new task to index the media collection.
frequency. It's assumed the caller will check for indexing frequency.
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{}} Returns {:ok, %Task{}}
""" """
def kickoff_indexing_task(%Source{} = source, job_args \\ %{}, job_opts \\ []) do 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, "FastIndexingWorker")
Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker", include_executing: true) Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker", include_executing: true)
@@ -52,8 +61,8 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
@doc """ @doc """
Given a media source, creates (indexes) the media by creating media_items for each 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 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! item belonging to the source. Returns a list of media items or changesets
Returns a list of media items or changesets (if the media item couldn't be created). (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. 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 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 clarity to the user experience. This has a few things to be aware of which are documented
below in the file watcher setup method. 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 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 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 job run. This should ensure that any stragglers are caught if, for some reason, they
weren't enqueued or somehow got de-queued. weren't enqueued or somehow got de-queued.
Since indexing returns all media data EVERY TIME, we that that opportunity to update Available options:
indexing metadata for media items that have already been created. - `was_forced`: Whether the indexing was forced by the user
Returns [%MediaItem{} | %Ecto.Changeset{}] 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 # The media_profile is needed to determine the quality options to _then_ determine a more
# accurate predicted filepath # accurate predicted filepath
source = Repo.preload(source, [:media_profile]) source = Repo.preload(source, [:media_profile])
# See the method definition below for more info on how file watchers work # See the method definition below for more info on how file watchers work
# (important reading if you're not familiar with it) # (important reading if you're not familiar with it)
{:ok, media_attributes} = 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 # Reload because the source may have been updated during the (long-running) indexing process
# and important settings like `download_media` may have changed. # and important settings like `download_media` may have changed.
source = Repo.reload!(source) source = Repo.reload!(source)
@@ -109,14 +128,16 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
# It attempts a graceful shutdown of the file follower after the indexing is done, # It attempts a graceful shutdown of the file follower after the indexing is done,
# but the FileFollowerServer will also stop itself if it doesn't see any activity # but the FileFollowerServer will also stop itself if it doesn't see any activity
# for a sufficiently long time. # for a sufficiently long time.
defp 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() {:ok, pid} = FileFollowerServer.start_link()
handler = fn filepath -> setup_file_follower_watcher(pid, filepath, source) end handler = fn filepath -> setup_file_follower_watcher(pid, filepath, source) end
command_opts = command_opts =
[output: DownloadOptionBuilder.build_output_path_for(source)] ++ [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] 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) 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) max(0, index_frequency_seconds - offset_seconds)
end 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 end
+13 -1
View File
@@ -45,8 +45,20 @@ defmodule Pinchflat.Utils.FilesystemUtils do
Returns binary() Returns binary()
""" """
def generate_metadata_tmpfile(type) do 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) 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, "") :ok = write_p!(filepath, "")
+7 -1
View File
@@ -39,7 +39,13 @@ defmodule Pinchflat.YtDlp.CommandRunner do
formatted_command_opts = [url] ++ CliUtils.parse_options(all_opts) formatted_command_opts = [url] ++ CliUtils.parse_options(all_opts)
case CliUtils.wrap_cmd(command, formatted_command_opts, stderr_to_stdout: true) do 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 # 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. # a huge deal, but it's still a good idea to clean up after ourselves.
# (even on error? especially on error?) # (even on error? especially on error?)
+1 -2
View File
@@ -23,7 +23,6 @@ defmodule Pinchflat.YtDlp.MediaCollection do
Returns {:ok, [map()]} | {:error, any, ...}. Returns {:ok, [map()]} | {:error, any, ...}.
""" """
def get_media_attributes_for_collection(url, command_opts \\ [], addl_opts \\ []) do 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 # `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 # 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 # available formats since we're just getting the media details
@@ -39,7 +38,7 @@ defmodule Pinchflat.YtDlp.MediaCollection do
file_listener_handler.(output_filepath) file_listener_handler.(output_filepath)
end end
case runner.run(url, action, all_command_opts, output_template, runner_opts) do case backend_runner().run(url, action, all_command_opts, output_template, runner_opts) do
{:ok, output} -> {:ok, output} ->
parsed_lines = parsed_lines =
output output
@@ -38,6 +38,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
def friendly_sponsorblock_options do def friendly_sponsorblock_options do
[ [
{"Disabled (default)", "disabled"}, {"Disabled (default)", "disabled"},
{"Mark Segments as Chapters", "mark"},
{"Remove Segments", "remove"} {"Remove Segments", "remove"}
] ]
end end
@@ -56,7 +57,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
end end
def media_center_custom_output_template_options do 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: "<code>Season YYYY/sYYYYeMMDD</code>",
season_by_year__episode_by_date_and_index: season_by_year__episode_by_date_and_index:
"same as the above but it handles dates better. <strong>This is the recommended option</strong>", "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", "<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: static_season__episode_by_date:
"<code>Season 1/s01eYYMMDD</code>. Recommended for playlists that might change or where order isn't important" "<code>Season 1/s01eYYMMDD</code>. Recommended for playlists that might change or where order isn't important"
} ]
end end
def other_custom_output_template_options do def other_custom_output_template_options do
%{ [
upload_day: nil, upload_day: nil,
upload_month: nil, upload_month: nil,
upload_year: nil, upload_year: nil,
@@ -84,8 +85,11 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
season_episode_index_from_date: season_episode_index_from_date:
"the upload date formatted as <code>sYYYYeMMDDII</code> where <code>II</code> is an index to prevent date collisions", "the upload date formatted as <code>sYYYYeMMDDII</code> where <code>II</code> is an index to prevent date collisions",
media_playlist_index: 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 end
def common_output_template_options do def common_output_template_options do
@@ -54,7 +54,7 @@
<.link <.link
href={~p"/sources/#{@source}/force_index"} href={~p"/sources/#{@source}/force_index"}
method="post" 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 Force Index
</.link> </.link>
-1
View File
@@ -25,7 +25,6 @@ defmodule PinchflatWeb.Router do
# has to match before /sources/:id # has to match before /sources/:id
get "/sources/opml", Podcasts.PodcastController, :opml_feed get "/sources/opml", Podcasts.PodcastController, :opml_feed
get "/sources/:foo/opml", Podcasts.PodcastController, :opml_feed
end end
# Routes in here _may not be_ protected by basic auth. This is necessary for # Routes in here _may not be_ protected by basic auth. This is necessary for
+1 -1
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do def project do
[ [
app: :pinchflat, app: :pinchflat,
version: "2024.12.31", version: "2025.1.3",
elixir: "~> 1.17", elixir: "~> 1.17",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
@@ -287,7 +287,19 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
assert {:sponsorblock_remove, "sponsor,intro"} in res assert {:sponsorblock_remove, "sponsor,intro"} in res
end 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 = media_item =
update_media_profile_attribute(media_item, %{ update_media_profile_attribute(media_item, %{
sponsorblock_behaviour: :remove, sponsorblock_behaviour: :remove,
@@ -296,9 +308,10 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
assert {:ok, res} = DownloadOptionBuilder.build(media_item) assert {:ok, res} = DownloadOptionBuilder.build(media_item)
refute {:sponsorblock_remove, ""} in res refute Keyword.has_key?(res, :sponsorblock_remove)
refute {:sponsorblock_remove, []} in res refute Keyword.has_key?(res, :sponsorblock_mark)
refute :sponsorblock_remove in res refute :sponsorblock_remove in res
refute :sponsorblock_mark in res
end end
test "does not include any sponsorblock options when disabled", %{media_item: media_item} do 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) assert {:ok, res} = DownloadOptionBuilder.build(media_item)
refute {:sponsorblock_remove, ""} in res refute Keyword.has_key?(res, :sponsorblock_remove)
refute {:sponsorblock_remove, []} in res refute Keyword.has_key?(res, :sponsorblock_mark)
refute :sponsorblock_remove in res refute :sponsorblock_remove in res
refute :sponsorblock_mark in res
end end
end end
@@ -1,7 +1,7 @@
defmodule Pinchflat.SlowIndexing.FileFollowerServerTest do defmodule Pinchflat.SlowIndexing.FileFollowerServerTest do
use Pinchflat.DataCase use Pinchflat.DataCase
alias alias Pinchflat.Utils.FilesystemUtils alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.SlowIndexing.FileFollowerServer alias Pinchflat.SlowIndexing.FileFollowerServer
setup do setup do
@@ -57,25 +57,51 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
:ok :ok
end end
test "it indexes the source if it should be indexed" do 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) source = source_fixture(index_frequency_minutes: 10)
perform_job(MediaCollectionIndexingWorker, %{id: source.id}) perform_job(MediaCollectionIndexingWorker, %{id: source.id})
end end
test "it indexes the source no matter what if the source has never been indexed before" do 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) source = source_fixture(index_frequency_minutes: 0, last_indexed_at: nil)
perform_job(MediaCollectionIndexingWorker, %{id: source.id}) perform_job(MediaCollectionIndexingWorker, %{id: source.id})
end end
test "it indexes the source no matter what if the 'force' arg is passed" do 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()) source = source_fixture(index_frequency_minutes: 0, last_indexed_at: DateTime.utc_now())
perform_job(MediaCollectionIndexingWorker, %{id: source.id, force: true}) perform_job(MediaCollectionIndexingWorker, %{id: source.id, force: true})
end end
test "it does not do any indexing if the source has been indexed and shouldn't be rescheduled" do 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 -> expect(YtDlpRunnerMock, :run, 0, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, ""} {:ok, ""}
end) end)
@@ -85,7 +111,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
perform_job(MediaCollectionIndexingWorker, %{id: source.id}) perform_job(MediaCollectionIndexingWorker, %{id: source.id})
end end
test "it does not reschedule if the source shouldn't be indexed" do 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) stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts -> {:ok, ""} end)
source = source_fixture(index_frequency_minutes: -1) source = source_fixture(index_frequency_minutes: -1)
@@ -94,7 +120,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
refute_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id}) refute_enqueued(worker: MediaCollectionIndexingWorker, args: %{"id" => source.id})
end end
test "it kicks off a download job for each pending media item" do 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 -> expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, source_attributes_return_fixture()} {:ok, source_attributes_return_fixture()}
end) end)
@@ -105,7 +131,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
assert length(all_enqueued(worker: MediaDownloadWorker)) == 3 assert length(all_enqueued(worker: MediaDownloadWorker)) == 3
end end
test "it starts a job for any pending media item even if it's from another run" do 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 -> expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, source_attributes_return_fixture()} {:ok, source_attributes_return_fixture()}
end) end)
@@ -117,7 +143,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
assert length(all_enqueued(worker: MediaDownloadWorker)) == 4 assert length(all_enqueued(worker: MediaDownloadWorker)) == 4
end end
test "it does not kick off a job for media items that could not be saved" do 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 -> expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, source_attributes_return_fixture()} {:ok, source_attributes_return_fixture()}
end) end)
@@ -130,7 +156,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
assert length(all_enqueued(worker: MediaDownloadWorker)) assert length(all_enqueued(worker: MediaDownloadWorker))
end end
test "it reschedules the job based on the index frequency" do test "reschedules the job based on the index frequency" do
source = source_fixture(index_frequency_minutes: 10) source = source_fixture(index_frequency_minutes: 10)
perform_job(MediaCollectionIndexingWorker, %{id: source.id}) perform_job(MediaCollectionIndexingWorker, %{id: source.id})
@@ -141,7 +167,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
) )
end end
test "it creates a task for the rescheduled job" do test "creates a task for the rescheduled job" do
source = source_fixture(index_frequency_minutes: 10) source = source_fixture(index_frequency_minutes: 10)
task_count_fetcher = fn -> task_count_fetcher = fn ->
@@ -153,7 +179,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
end) end)
end end
test "it creates a future task for fast indexing if appropriate" do test "creates a future task for fast indexing if appropriate" do
source = source_fixture(index_frequency_minutes: 10, fast_index: true) source = source_fixture(index_frequency_minutes: 10, fast_index: true)
perform_job(MediaCollectionIndexingWorker, %{id: source.id}) perform_job(MediaCollectionIndexingWorker, %{id: source.id})
@@ -164,7 +190,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
) )
end end
test "it deletes existing fast indexing tasks if a new one is created" do test "deletes existing fast indexing tasks if a new one is created" do
source = source_fixture(index_frequency_minutes: 10, fast_index: true) source = source_fixture(index_frequency_minutes: 10, fast_index: true)
{:ok, job} = Oban.insert(FastIndexingWorker.new(%{"id" => source.id})) {:ok, job} = Oban.insert(FastIndexingWorker.new(%{"id" => source.id}))
task = task_fixture(source_id: source.id, job_id: job.id) task = task_fixture(source_id: source.id, job_id: job.id)
@@ -174,14 +200,14 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end end
test "it does not create a task for fast indexing otherwise" do test "does not create a task for fast indexing otherwise" do
source = source_fixture(index_frequency_minutes: 10, fast_index: false) source = source_fixture(index_frequency_minutes: 10, fast_index: false)
perform_job(MediaCollectionIndexingWorker, %{id: source.id}) perform_job(MediaCollectionIndexingWorker, %{id: source.id})
refute_enqueued(worker: FastIndexingWorker) refute_enqueued(worker: FastIndexingWorker)
end end
test "it creates the basic media_item records" do test "creates the basic media_item records" do
expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts -> expect(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, source_attributes_return_fixture()} {:ok, source_attributes_return_fixture()}
end) end)
@@ -14,6 +14,10 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
alias Pinchflat.SlowIndexing.SlowIndexingHelpers alias Pinchflat.SlowIndexing.SlowIndexingHelpers
alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker alias Pinchflat.SlowIndexing.MediaCollectionIndexingWorker
setup do
{:ok, %{source: source_fixture()}}
end
describe "kickoff_indexing_task/3" do describe "kickoff_indexing_task/3" do
test "schedules a job" do test "schedules a job" do
source = source_fixture(index_frequency_minutes: 1) 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 assert_in_delta DateTime.diff(job.scheduled_at, DateTime.utc_now(), :second), 0, 1
end 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 test "creates and attaches a task" do
source = source_fixture(index_frequency_minutes: 1) source = source_fixture(index_frequency_minutes: 1)
@@ -123,12 +137,6 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
end end
describe "delete_indexing_tasks/2" do 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 test "deletes slow indexing tasks for the source", %{source: source} do
{:ok, job} = Oban.insert(MediaCollectionIndexingWorker.new(%{"id" => source.id})) {:ok, job} = Oban.insert(MediaCollectionIndexingWorker.new(%{"id" => source.id}))
_task = task_fixture(source_id: source.id, job_id: job.id) _task = task_fixture(source_id: source.id, job_id: job.id)
@@ -172,13 +180,13 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
end end
end end
describe "index_and_enqueue_download_for_media_items/1" do describe "index_and_enqueue_download_for_media_items/2" do
setup do setup do
stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts -> stub(YtDlpRunnerMock, :run, fn _url, :get_media_attributes_for_collection, _opts, _ot, _addl_opts ->
{:ok, source_attributes_return_fixture()} {:ok, source_attributes_return_fixture()}
end) end)
{:ok, [source: source_fixture()]} :ok
end end
test "creates a media_item record for each media ID returned", %{source: source} do test "creates a media_item record for each media ID returned", %{source: source} do
@@ -315,11 +323,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
end end
end end
describe "index_and_enqueue_download_for_media_items/1 when testing file watcher" do describe "index_and_enqueue_download_for_media_items/2 when testing file watcher" do
setup do
{:ok, [source: source_fixture()]}
end
test "creates a new media item for everything already in the file", %{source: source} 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) watcher_poll_interval = Application.get_env(:pinchflat, :file_watcher_poll_interval)
@@ -446,4 +450,62 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
assert [] = SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source) assert [] = SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source)
end end
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 end
@@ -17,6 +17,12 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
assert {:ok, _output} = Runner.run(@media_url, :foo, [], "") assert {:ok, _output} = Runner.run(@media_url, :foo, [], "")
end end
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 test "includes the media url as the first argument" do
assert {:ok, output} = Runner.run(@media_url, :foo, [:ignore_errors], "") assert {:ok, output} = Runner.run(@media_url, :foo, [:ignore_errors], "")
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
exit 101