From b633d0f75c37a28b097b4d949ae494e84aaeaa36 Mon Sep 17 00:00:00 2001 From: Kieran Date: Fri, 15 Mar 2024 17:44:58 -0700 Subject: [PATCH] Misc refactors 2024-03-14 (#89) * Adds method to improve cleanup of empty directories * resolved bug where source metadata worker could call itself in an infinite loop * Refactored file deletion for media items * Removed useless filesystem data worker * Updated task listing fns to take a record directly * Refactored the way I call workers * Improved some tests --- .credo.exs | 2 +- lib/pinchflat/boot/pre_job_startup_tasks.ex | 18 +++- .../downloading/download_option_builder.ex | 1 - .../downloading/downloading_helpers.ex | 6 +- .../downloading/media_download_worker.ex | 38 ++++++--- lib/pinchflat/downloading/media_downloader.ex | 2 + .../fast_indexing/fast_indexing_helpers.ex | 29 +------ .../fast_indexing/fast_indexing_worker.ex | 18 ++-- .../fast_indexing/media_indexing_worker.ex | 15 +++- .../filesystem/filesystem_data_worker.ex | 31 ------- .../filesystem/filesystem_helpers.ex | 32 +++++++ lib/pinchflat/media/media.ex | 84 ++++++------------- .../source_metadata_storage_worker.ex | 17 ++-- .../media_collection_indexing_worker.ex | 13 ++- .../slow_indexing/slow_indexing_helpers.ex | 9 +- lib/pinchflat/sources/sources.ex | 24 ++---- lib/pinchflat/tasks/tasks.ex | 41 ++++----- lib/pinchflat/yt_dlp/command_runner.ex | 3 +- lib/pinchflat/yt_dlp/media_collection.ex | 20 +++-- .../controllers/sources/source_controller.ex | 2 +- mix.exs | 10 +++ .../boot/data_backfill_worker_test.exs | 8 +- .../download_option_builder_test.exs | 19 +++-- .../downloading/downloading_helpers_test.exs | 8 +- .../media_download_worker_test.exs | 38 ++++++--- .../fast_indexing_helpers_test.exs | 33 +------- .../fast_indexing_worker_test.exs | 27 ++++++ .../media_indexing_worker_test.exs | 15 +++- .../filesystem_data_worker_test.exs | 19 ----- .../filesystem/filesystem_helpers_test.exs | 51 +++++++++++ test/pinchflat/media_test.exs | 71 +++++----------- .../source_metadata_storage_worker_test.exs | 23 +++++ .../media_collection_indexing_worker_test.exs | 2 +- .../slow_indexing_helpers_test.exs | 19 ++++- test/pinchflat/sources_test.exs | 64 -------------- test/pinchflat/tasks_test.exs | 42 ++++++---- .../yt_dlp/media_collection_test.exs | 9 ++ 37 files changed, 447 insertions(+), 416 deletions(-) delete mode 100644 lib/pinchflat/filesystem/filesystem_data_worker.ex delete mode 100644 test/pinchflat/filesystem/filesystem_data_worker_test.exs diff --git a/.credo.exs b/.credo.exs index 8b4586a..78fe1aa 100644 --- a/.credo.exs +++ b/.credo.exs @@ -128,7 +128,7 @@ {Credo.Check.Refactor.MatchInCondition, []}, {Credo.Check.Refactor.NegatedConditionsInUnless, []}, {Credo.Check.Refactor.NegatedConditionsWithElse, []}, - {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.Nesting, [max_nesting: 4]}, {Credo.Check.Refactor.RedundantWithClauseResult, []}, {Credo.Check.Refactor.RejectReject, []}, {Credo.Check.Refactor.UnlessWithElse, []}, diff --git a/lib/pinchflat/boot/pre_job_startup_tasks.ex b/lib/pinchflat/boot/pre_job_startup_tasks.ex index 73847b9..c3e5e9b 100644 --- a/lib/pinchflat/boot/pre_job_startup_tasks.ex +++ b/lib/pinchflat/boot/pre_job_startup_tasks.ex @@ -14,6 +14,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do alias Pinchflat.Repo alias Pinchflat.Settings + alias Pinchflat.Filesystem.FilesystemHelpers def start_link(opts \\ []) do GenServer.start_link(__MODULE__, %{}, opts) @@ -31,6 +32,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do @impl true def init(state) do apply_default_settings() + ensure_directories_are_writeable() rename_old_job_workers() {:ok, state} @@ -41,6 +43,21 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do Settings.fetch!(:pro_enabled, false) end + defp ensure_directories_are_writeable do + directories = [ + Application.get_env(:pinchflat, :media_directory), + Application.get_env(:pinchflat, :tmpfile_directory), + Application.get_env(:pinchflat, :metadata_directory) + ] + + Enum.each(directories, fn dir -> + file = Path.join([dir, ".keep"]) + + # This will fail if the directory is not writeable, stopping boot + FilesystemHelpers.write_p!(file, "") + end) + end + # As part of a large refactor, I ended up moving a bunch of workers around. This # is a problem because the workers are stored in the database and the runner # will try to run the OLD jobs. This is also why these tasks run before the job @@ -52,7 +69,6 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do rename_map = [ ["Pinchflat.Workers.MediaIndexingWorker", "Pinchflat.FastIndexing.MediaIndexingWorker"], ["Pinchflat.Workers.MediaDownloadWorker", "Pinchflat.Downloading.MediaDownloadWorker"], - ["Pinchflat.Workers.FilesystemDataWorker", "Pinchflat.Filesystem.FilesystemDataWorker"], ["Pinchflat.Workers.FastIndexingWorker", "Pinchflat.FastIndexing.FastIndexingWorker"], ["Pinchflat.Workers.MediaCollectionIndexingWorker", "Pinchflat.SlowIndexing.MediaCollectionIndexingWorker"], ["Pinchflat.Workers.DataBackfillWorker", "Pinchflat.Boot.DataBackfillWorker"] diff --git a/lib/pinchflat/downloading/download_option_builder.ex b/lib/pinchflat/downloading/download_option_builder.ex index 2c4a77e..49a688c 100644 --- a/lib/pinchflat/downloading/download_option_builder.ex +++ b/lib/pinchflat/downloading/download_option_builder.ex @@ -99,7 +99,6 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do :"480p" -> [format_sort: "res:480,#{video_codec_options}"] :"720p" -> [format_sort: "res:720,#{video_codec_options}"] :"1080p" -> [format_sort: "res:1080,#{video_codec_options}"] - :"1440p" -> [format_sort: "res:1440,#{video_codec_options}"] :"2160p" -> [format_sort: "res:2160,#{video_codec_options}"] end end diff --git a/lib/pinchflat/downloading/downloading_helpers.ex b/lib/pinchflat/downloading/downloading_helpers.ex index 9217548..1e61545 100644 --- a/lib/pinchflat/downloading/downloading_helpers.ex +++ b/lib/pinchflat/downloading/downloading_helpers.ex @@ -26,11 +26,7 @@ defmodule Pinchflat.Downloading.DownloadingHelpers do def enqueue_pending_download_tasks(%Source{download_media: true} = source) do source |> Media.list_pending_media_items_for() - |> Enum.each(fn media_item -> - %{id: media_item.id} - |> MediaDownloadWorker.new() - |> Tasks.create_job_with_task(media_item) - end) + |> Enum.each(&MediaDownloadWorker.kickoff_with_task/1) end def enqueue_pending_download_tasks(%Source{download_media: false}) do diff --git a/lib/pinchflat/downloading/media_download_worker.ex b/lib/pinchflat/downloading/media_download_worker.ex index d41a8b3..386c1f3 100644 --- a/lib/pinchflat/downloading/media_download_worker.ex +++ b/lib/pinchflat/downloading/media_download_worker.ex @@ -6,19 +6,30 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]], tags: ["media_item", "media_fetching"] + alias __MODULE__ + alias Pinchflat.Tasks alias Pinchflat.Repo alias Pinchflat.Media - alias Pinchflat.Tasks alias Pinchflat.Downloading.MediaDownloader - alias Pinchflat.Filesystem.FilesystemDataWorker - @impl Oban.Worker + @doc """ + Starts the media_item media download worker and creates a task for the media_item. + + Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}} + """ + def kickoff_with_task(media_item, opts \\ []) do + %{id: media_item.id} + |> MediaDownloadWorker.new(opts) + |> Tasks.create_job_with_task(media_item) + end + @doc """ For a given media item, download the media alongside any options. Does not download media if its source is set to not download media. Returns :ok | {:ok, %MediaItem{}} | {:error, any, ...any} """ + @impl Oban.Worker def perform(%Oban.Job{args: %{"id" => media_item_id}}) do media_item = media_item_id @@ -35,22 +46,23 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do defp download_media_and_schedule_jobs(media_item) do case MediaDownloader.download_for_media_item(media_item) do - {:ok, _} -> - schedule_filesystem_data_worker(media_item) - {:ok, media_item} + {:ok, updated_media_item} -> + compute_and_save_media_filesize(updated_media_item) + + {:ok, updated_media_item} err -> err end end - defp schedule_filesystem_data_worker(media_item) do - %{id: media_item.id} - |> FilesystemDataWorker.new() - |> Tasks.create_job_with_task(media_item) - |> case do - {:ok, task} -> {:ok, task} - {:error, :duplicate_job} -> {:ok, :job_exists} + defp compute_and_save_media_filesize(media_item) do + case File.stat(media_item.media_filepath) do + {:ok, %{size: size}} -> + Media.update_media_item(media_item, %{media_size_bytes: size}) + + _ -> + :ok end end end diff --git a/lib/pinchflat/downloading/media_downloader.ex b/lib/pinchflat/downloading/media_downloader.ex index a552f87..cf87514 100644 --- a/lib/pinchflat/downloading/media_downloader.ex +++ b/lib/pinchflat/downloading/media_downloader.ex @@ -38,6 +38,8 @@ defmodule Pinchflat.Downloading.MediaDownloader do media_downloaded_at: DateTime.utc_now(), nfo_filepath: determine_nfo_filepath(item_with_preloads, parsed_json), metadata: %{ + # IDEA: might be worth kicking off a job for this since thumbnail fetching + # could fail and I want to handle that in isolation metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, parsed_json), thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_item, parsed_json) } diff --git a/lib/pinchflat/fast_indexing/fast_indexing_helpers.ex b/lib/pinchflat/fast_indexing/fast_indexing_helpers.ex index 6355df7..d1d72da 100644 --- a/lib/pinchflat/fast_indexing/fast_indexing_helpers.ex +++ b/lib/pinchflat/fast_indexing/fast_indexing_helpers.ex @@ -6,34 +6,13 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do """ alias Pinchflat.Media - alias Pinchflat.Tasks alias Pinchflat.Sources.Source alias Pinchflat.FastIndexing.YoutubeRss - alias Pinchflat.FastIndexing.FastIndexingWorker alias Pinchflat.Downloading.MediaDownloadWorker alias Pinchflat.FastIndexing.MediaIndexingWorker alias Pinchflat.YtDlp.Media, as: YtDlpMedia - @doc """ - Starts tasks for running a fast indexing task for a source's media - regardless of the source's fast_index state. It's assumed the - caller will check for fast_index. - - This is used for running fast index tasks on update. On creation, the - fast index is enqueued after the slow index is complete. - - Returns {:ok, %Task{}}. - """ - def kickoff_fast_indexing_task(%Source{} = source) do - Tasks.delete_pending_tasks_for(source, "FastIndexingWorker") - - %{id: source.id} - # Schedule this one immediately, but future ones will be on an interval - |> FastIndexingWorker.new() - |> Tasks.create_job_with_task(source) - end - @doc """ Fetches new media IDs from a source's YouTube RSS feed and kicks off indexing tasks for any new media items. See comments in `MediaIndexingWorker` for more info on the @@ -54,9 +33,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do Enum.each(new_media_ids, fn media_id -> url = "https://www.youtube.com/watch?v=#{media_id}" - %{id: source.id, media_url: url} - |> MediaIndexingWorker.new() - |> Tasks.create_job_with_task(source) + MediaIndexingWorker.kickoff_with_task(source, url) end) end @@ -74,9 +51,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do case maybe_media_item do {:ok, media_item} -> if source.download_media && Media.pending_download?(media_item) do - %{id: media_item.id} - |> MediaDownloadWorker.new() - |> Tasks.create_job_with_task(media_item) + MediaDownloadWorker.kickoff_with_task(media_item) end {:ok, media_item} diff --git a/lib/pinchflat/fast_indexing/fast_indexing_worker.ex b/lib/pinchflat/fast_indexing/fast_indexing_worker.ex index d0a0e9a..5e16a68 100644 --- a/lib/pinchflat/fast_indexing/fast_indexing_worker.ex +++ b/lib/pinchflat/fast_indexing/fast_indexing_worker.ex @@ -12,7 +12,17 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do alias Pinchflat.Sources.Source alias Pinchflat.FastIndexing.FastIndexingHelpers - @impl Oban.Worker + @doc """ + Starts the source fast indexing worker and creates a task for the source. + + Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}} + """ + def kickoff_with_task(source, opts \\ []) do + %{id: source.id} + |> FastIndexingWorker.new(opts) + |> Tasks.create_job_with_task(source) + end + @doc """ Kicks off the fast indexing process for a source, reschedules the job to run again once complete. See `MediaCollectionIndexingWorker` and `MediaIndexingWorker` comments @@ -20,6 +30,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do Returns :ok | {:ok, :job_exists} | {:ok, %Task{}} """ + @impl Oban.Worker def perform(%Oban.Job{args: %{"id" => source_id}}) do source = Sources.get_source!(source_id) @@ -35,10 +46,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do defp reschedule_indexing(source) do next_run_in = Source.fast_index_frequency() * 60 - %{id: source.id} - |> FastIndexingWorker.new(schedule_in: next_run_in) - |> Tasks.create_job_with_task(source) - |> case do + case kickoff_with_task(source, schedule_in: next_run_in) do {:ok, task} -> {:ok, task} {:error, :duplicate_job} -> {:ok, :job_exists} end diff --git a/lib/pinchflat/fast_indexing/media_indexing_worker.ex b/lib/pinchflat/fast_indexing/media_indexing_worker.ex index 1bcc9d3..8792f60 100644 --- a/lib/pinchflat/fast_indexing/media_indexing_worker.ex +++ b/lib/pinchflat/fast_indexing/media_indexing_worker.ex @@ -8,10 +8,22 @@ defmodule Pinchflat.FastIndexing.MediaIndexingWorker do require Logger + alias __MODULE__ + alias Pinchflat.Tasks alias Pinchflat.Sources alias Pinchflat.FastIndexing.FastIndexingHelpers - @impl Oban.Worker + @doc """ + Starts the fast media indexing worker and creates a task for the source. + + Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}} + """ + def kickoff_with_task(source, media_url, opts \\ []) do + %{id: source.id, media_url: media_url} + |> MediaIndexingWorker.new(opts) + |> Tasks.create_job_with_task(source) + end + @doc """ Similar to `MediaCollectionIndexingWorker`, but for individual media items. Does not reschedule or check anything to do with a source's indexing @@ -37,6 +49,7 @@ defmodule Pinchflat.FastIndexing.MediaIndexingWorker do Returns :ok """ + @impl Oban.Worker def perform(%Oban.Job{args: %{"id" => source_id, "media_url" => media_url}}) do source = Sources.get_source!(source_id) diff --git a/lib/pinchflat/filesystem/filesystem_data_worker.ex b/lib/pinchflat/filesystem/filesystem_data_worker.ex deleted file mode 100644 index 2b36d37..0000000 --- a/lib/pinchflat/filesystem/filesystem_data_worker.ex +++ /dev/null @@ -1,31 +0,0 @@ -defmodule Pinchflat.Filesystem.FilesystemDataWorker do - @moduledoc false - - use Oban.Worker, - queue: :local_metadata, - tags: ["media_item", "media_metadata", "local_metadata"], - max_attempts: 1 - - alias Pinchflat.Media - alias Pinchflat.Filesystem.FilesystemHelpers - - @impl Oban.Worker - @doc """ - For a given media item, compute and save metadata about the file on-disk. - - IDEA: does this have to be a standalone job? I originally split it out - so a failure here wouldn't cause a downloader job retry, but I can match - for failures so it doesn't retry. - - Returns :ok - """ - def perform(%Oban.Job{args: %{"id" => media_item_id}}) do - media_item = Media.get_media_item!(media_item_id) - - FilesystemHelpers.compute_and_save_media_filesize(media_item) - - # Don't retry on failure - if it didn't work immediately there's no - # reason to believe it will work later. - :ok - end -end diff --git a/lib/pinchflat/filesystem/filesystem_helpers.ex b/lib/pinchflat/filesystem/filesystem_helpers.ex index 528e107..895a8a6 100644 --- a/lib/pinchflat/filesystem/filesystem_helpers.ex +++ b/lib/pinchflat/filesystem/filesystem_helpers.ex @@ -48,4 +48,36 @@ defmodule Pinchflat.Filesystem.FilesystemHelpers do err end end + + @doc """ + Deletes a file and removes any empty directories in the path. + Does NOT remove any directories that are not empty. + + Returns :ok | {:error, any()} + """ + def delete_file_and_remove_empty_directories(filepath) do + case File.rm(filepath) do + :ok -> + filepath + |> Path.dirname() + |> recursively_delete_empty_directories() + + err -> + err + end + end + + defp recursively_delete_empty_directories(directory) do + case File.rmdir(directory) do + :ok -> + directory + |> Path.dirname() + |> recursively_delete_empty_directories() + + err -> + err + end + + :ok + end end diff --git a/lib/pinchflat/media/media.ex b/lib/pinchflat/media/media.ex index 500866f..170968e 100644 --- a/lib/pinchflat/media/media.ex +++ b/lib/pinchflat/media/media.ex @@ -7,9 +7,10 @@ defmodule Pinchflat.Media do alias Pinchflat.Repo alias Pinchflat.Tasks - alias Pinchflat.Media.MediaItem alias Pinchflat.Sources.Source + alias Pinchflat.Media.MediaItem alias Pinchflat.Metadata.MediaMetadata + alias Pinchflat.Filesystem.FilesystemHelpers @doc """ Returns the list of media_items. @@ -141,42 +142,6 @@ defmodule Pinchflat.Media do """ def get_media_item!(id), do: Repo.get!(MediaItem, id) - @doc """ - Produces a flat list of the filesystem paths for a media_item's downloaded files - - NOTE: this can almost certainly be made private - - Returns [binary()] - """ - def media_filepaths(media_item) do - mapped_struct = Map.from_struct(media_item) - - MediaItem.filepath_attributes() - |> Enum.map(fn - :subtitle_filepaths = field -> Enum.map(mapped_struct[field], fn [_, filepath] -> filepath end) - field -> List.wrap(mapped_struct[field]) - end) - |> List.flatten() - |> Enum.filter(&is_binary/1) - end - - @doc """ - Produces a flat list of the filesystem paths for a media_item's metadata files. - Returns an empty list if the media_item has no metadata. - - NOTE: this can almost certainly be made private - - Returns [binary()] | [] - """ - def metadata_filepaths(media_item) do - metadata = Repo.preload(media_item, :metadata).metadata || %MediaMetadata{} - mapped_struct = Map.from_struct(metadata) - - MediaMetadata.filepath_attributes() - |> Enum.map(fn field -> mapped_struct[field] end) - |> Enum.filter(&is_binary/1) - end - @doc """ Creates a media_item. @@ -223,19 +188,20 @@ defmodule Pinchflat.Media do end @doc """ - Deletes a media_item and its associated tasks. - Can optionally delete the media_item's files. + Deletes a media_item, its associated tasks, and our internal metadata files. + Can optionally delete the media_item's media files (media, thumbnail, subtitles, etc). Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}} """ def delete_media_item(%MediaItem{} = media_item, opts \\ []) do delete_files = Keyword.get(opts, :delete_files, false) - # NOTE: this should delete metadata no matter what if delete_files do - {:ok, _} = delete_all_attachments(media_item) + {:ok, _} = delete_media_files(media_item) end + # Should delete these no matter what + delete_internal_metadata_files(media_item) Tasks.delete_tasks_for(media_item) Repo.delete(media_item) end @@ -247,27 +213,31 @@ defmodule Pinchflat.Media do MediaItem.changeset(media_item, attrs) end - # NOTE: refactor this - defp delete_all_attachments(media_item) do - media_item = Repo.preload(media_item, :metadata) + defp delete_media_files(media_item) do + mapped_struct = Map.from_struct(media_item) - media_item - |> media_filepaths() - |> Enum.concat(metadata_filepaths(media_item)) - |> Enum.each(&File.rm/1) - - # rmdir will attempt to delete the directory, but only if it is empty - if media_item.media_filepath do - File.rmdir(Path.dirname(media_item.media_filepath)) - end - - if media_item.metadata && media_item.metadata.metadata_filepath do - File.rmdir(Path.dirname(media_item.metadata.metadata_filepath)) - end + MediaItem.filepath_attributes() + |> Enum.map(fn + :subtitle_filepaths = field -> Enum.map(mapped_struct[field], fn [_, filepath] -> filepath end) + field -> List.wrap(mapped_struct[field]) + end) + |> List.flatten() + |> Enum.filter(&is_binary/1) + |> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1) {:ok, media_item} end + defp delete_internal_metadata_files(media_item) do + metadata = Repo.preload(media_item, :metadata).metadata || %MediaMetadata{} + mapped_struct = Map.from_struct(metadata) + + MediaMetadata.filepath_attributes() + |> Enum.map(fn field -> mapped_struct[field] end) + |> Enum.filter(&is_binary/1) + |> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1) + end + defp maybe_apply_cutoff_date(source) do if source.download_cutoff_date do dynamic([mi], mi.upload_date >= ^source.download_cutoff_date) diff --git a/lib/pinchflat/metadata/source_metadata_storage_worker.ex b/lib/pinchflat/metadata/source_metadata_storage_worker.ex index bdc81b1..c0bc885 100644 --- a/lib/pinchflat/metadata/source_metadata_storage_worker.ex +++ b/lib/pinchflat/metadata/source_metadata_storage_worker.ex @@ -4,7 +4,10 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do use Oban.Worker, queue: :remote_metadata, tags: ["media_source", "source_metadata", "remote_metadata"], - max_attempts: 1 + max_attempts: 1, + # This is the only thing stopping this job from calling itself + # in an infinite loop. + unique: [period: 600] alias __MODULE__ alias Pinchflat.Repo @@ -16,27 +19,27 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do @doc """ Starts the source metadata storage worker and creates a task for the source. - IDEA: testing out this method of handling job kickoff. I think I like it, so - I may use it in other places. Just testing it for now - Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}} """ - def kickoff_with_task(source) do + def kickoff_with_task(source, opts \\ []) do %{id: source.id} - |> SourceMetadataStorageWorker.new() + |> SourceMetadataStorageWorker.new(opts) |> Tasks.create_job_with_task(source) end - @impl Oban.Worker @doc """ Fetches and stores metadata for a source in the secret metadata location. Returns :ok """ + @impl Oban.Worker def perform(%Oban.Job{args: %{"id" => source_id}}) do source = Repo.preload(Sources.get_source!(source_id), :metadata) {:ok, metadata} = MediaCollection.get_source_metadata(source.original_url) + # Since updating a source kicks this job off again, we enforce job uniqueness (above) + # to once, per source, per x minutes. This is to prevent a job from calling itself + # in an infinite loop. Sources.update_source(source, %{ metadata: %{ metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(source, metadata) diff --git a/lib/pinchflat/slow_indexing/media_collection_indexing_worker.ex b/lib/pinchflat/slow_indexing/media_collection_indexing_worker.ex index 46b2ef0..2c5cee3 100644 --- a/lib/pinchflat/slow_indexing/media_collection_indexing_worker.ex +++ b/lib/pinchflat/slow_indexing/media_collection_indexing_worker.ex @@ -13,7 +13,17 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do alias Pinchflat.FastIndexing.FastIndexingWorker alias Pinchflat.SlowIndexing.SlowIndexingHelpers - @impl Oban.Worker + @doc """ + Starts the source slow indexing worker and creates a task for the source. + + Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}} + """ + def kickoff_with_task(source, opts \\ []) do + %{id: source.id} + |> MediaCollectionIndexingWorker.new(opts) + |> Tasks.create_job_with_task(source) + end + @doc """ The ID is that of a source _record_, not a YouTube channel/playlist ID. Indexes the provided source, kicks off downloads for each new MediaItem, and @@ -58,6 +68,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do Returns :ok | {:ok, %Task{}} """ + @impl Oban.Worker def perform(%Oban.Job{args: %{"id" => source_id}}) do source = Sources.get_source!(source_id) diff --git a/lib/pinchflat/slow_indexing/slow_indexing_helpers.ex b/lib/pinchflat/slow_indexing/slow_indexing_helpers.ex index 4598d1b..cb4d63b 100644 --- a/lib/pinchflat/slow_indexing/slow_indexing_helpers.ex +++ b/lib/pinchflat/slow_indexing/slow_indexing_helpers.ex @@ -31,10 +31,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do Tasks.delete_pending_tasks_for(source, "MediaIndexingWorker") Tasks.delete_pending_tasks_for(source, "MediaCollectionIndexingWorker") - %{id: source.id} - # Schedule this one immediately, but future ones will be on an interval - |> MediaCollectionIndexingWorker.new() - |> Tasks.create_job_with_task(source) + MediaCollectionIndexingWorker.kickoff_with_task(source) end @doc """ @@ -125,9 +122,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do if source.download_media && Media.pending_download?(media_item) do Logger.debug("FileFollowerServer Handler: Enqueuing download task for #{inspect(media_attrs)}") - %{id: media_item.id} - |> MediaDownloadWorker.new() - |> Tasks.create_job_with_task(media_item) + MediaDownloadWorker.kickoff_with_task(media_item) end {:error, changeset} -> diff --git a/lib/pinchflat/sources/sources.ex b/lib/pinchflat/sources/sources.ex index 974a94d..ea2b2d3 100644 --- a/lib/pinchflat/sources/sources.ex +++ b/lib/pinchflat/sources/sources.ex @@ -12,8 +12,9 @@ defmodule Pinchflat.Sources do alias Pinchflat.Profiles.MediaProfile alias Pinchflat.YtDlp.MediaCollection alias Pinchflat.Metadata.SourceMetadata + alias Pinchflat.Filesystem.FilesystemHelpers alias Pinchflat.Downloading.DownloadingHelpers - alias Pinchflat.FastIndexing.FastIndexingHelpers + alias Pinchflat.FastIndexing.FastIndexingWorker alias Pinchflat.SlowIndexing.SlowIndexingHelpers alias Pinchflat.Metadata.SourceMetadataStorageWorker @@ -108,8 +109,8 @@ defmodule Pinchflat.Sources do Media.delete_media_item(media_item, delete_files: delete_files) end) - Tasks.delete_tasks_for(source) delete_source_metadata_files(source) + Tasks.delete_tasks_for(source) Repo.delete(source) end @@ -120,17 +121,9 @@ defmodule Pinchflat.Sources do Source.changeset(source, attrs, validation_stage) end - @doc """ - Returns an `%Ecto.Changeset{}` for tracking source changes and additionally - fetches source details from the original_url (if provided). If the source - details cannot be fetched, an error is added to the changeset. - - NOTE: When operating in the ideal path, this effectively adds an API call - to the source creation/update process. Should be used only when needed. - - NOTE: this can almost certainly be made private now - """ - def maybe_change_source_from_url(%Source{} = source, attrs) do + # NOTE: When operating in the ideal path, this effectively adds an API call + # to the source creation/update process. Should be used only when needed. + defp maybe_change_source_from_url(%Source{} = source, attrs) do case change_source(source, attrs) do %Ecto.Changeset{changes: %{original_url: _}} = changeset -> add_source_details_to_changeset(source, changeset) @@ -149,7 +142,7 @@ defmodule Pinchflat.Sources do |> Enum.map(fn field -> mapped_struct[field] end) |> Enum.filter(&is_binary/1) - Enum.each(filepaths, &File.rm/1) + Enum.each(filepaths, &FilesystemHelpers.delete_file_and_remove_empty_directories/1) end defp add_source_details_to_changeset(source, changeset) do @@ -270,7 +263,8 @@ defmodule Pinchflat.Sources do defp maybe_update_fast_indexing_task(changeset, source) do case changeset.changes do %{fast_index: true} -> - FastIndexingHelpers.kickoff_fast_indexing_task(source) + Tasks.delete_pending_tasks_for(source, "FastIndexingWorker") + FastIndexingWorker.kickoff_with_task(source) %{fast_index: false} -> Tasks.delete_pending_tasks_for(source, "FastIndexingWorker") diff --git a/lib/pinchflat/tasks/tasks.ex b/lib/pinchflat/tasks/tasks.ex index 35f0461..7b94e3c 100644 --- a/lib/pinchflat/tasks/tasks.ex +++ b/lib/pinchflat/tasks/tasks.ex @@ -20,13 +20,17 @@ defmodule Pinchflat.Tasks do Returns the list of tasks for a given record type and ID. Optionally allows you to specify which worker or job states to include. - IDEA: this should be updated to take a struct instead of a record type and ID - Returns [%Task{}, ...] """ - def list_tasks_for(attached_record_type, attached_record_id, worker_name \\ nil, job_states \\ Oban.Job.states()) do + def list_tasks_for(record, worker_name \\ nil, job_states \\ Oban.Job.states()) do stringified_states = Enum.map(job_states, &to_string/1) + record_type = + case record do + %Source{} -> :source_id + %MediaItem{} -> :media_item_id + end + worker_name_finder = if worker_name do # Workers are the full module name - we want to match on the string ENDING with @@ -43,7 +47,7 @@ defmodule Pinchflat.Tasks do Repo.all( from t in Task, join: j in assoc(t, :job), - where: field(t, ^attached_record_type) == ^attached_record_id, + where: field(t, ^record_type) == ^record.id, where: ^worker_name_finder, where: j.state in ^stringified_states ) @@ -55,10 +59,9 @@ defmodule Pinchflat.Tasks do Returns [%Task{}, ...] """ - def list_pending_tasks_for(attached_record_type, attached_record_id, worker_name \\ nil) do + def list_pending_tasks_for(record, worker_name \\ nil) do list_tasks_for( - attached_record_type, - attached_record_id, + record, worker_name, [:available, :scheduled, :retryable] ) @@ -128,14 +131,10 @@ defmodule Pinchflat.Tasks do Returns :ok """ - def delete_tasks_for(attached_record, worker_name \\ nil) do - tasks = - case attached_record do - %Source{} = source -> list_tasks_for(:source_id, source.id, worker_name) - %MediaItem{} = media_item -> list_tasks_for(:media_item_id, media_item.id, worker_name) - end - - Enum.each(tasks, &delete_task/1) + def delete_tasks_for(record, worker_name \\ nil) do + record + |> list_tasks_for(worker_name) + |> Enum.each(&delete_task/1) end @doc """ @@ -144,14 +143,10 @@ defmodule Pinchflat.Tasks do Returns :ok """ - def delete_pending_tasks_for(attached_record, worker_name \\ nil) do - tasks = - case attached_record do - %Source{} = source -> list_pending_tasks_for(:source_id, source.id, worker_name) - %MediaItem{} = media_item -> list_pending_tasks_for(:media_item_id, media_item.id, worker_name) - end - - Enum.each(tasks, &delete_task/1) + def delete_pending_tasks_for(record, worker_name \\ nil) do + record + |> list_pending_tasks_for(worker_name) + |> Enum.each(&delete_task/1) end @doc """ diff --git a/lib/pinchflat/yt_dlp/command_runner.ex b/lib/pinchflat/yt_dlp/command_runner.ex index 925e5be..3e36e74 100644 --- a/lib/pinchflat/yt_dlp/command_runner.ex +++ b/lib/pinchflat/yt_dlp/command_runner.ex @@ -37,7 +37,8 @@ defmodule Pinchflat.YtDlp.CommandRunner do case System.cmd(command, formatted_command_opts, stderr_to_stdout: true) do {_, 0} -> - # IDEA: consider deleting the file after reading it + # 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?) File.read(output_filepath) diff --git a/lib/pinchflat/yt_dlp/media_collection.ex b/lib/pinchflat/yt_dlp/media_collection.ex index 465437b..5a0912d 100644 --- a/lib/pinchflat/yt_dlp/media_collection.ex +++ b/lib/pinchflat/yt_dlp/media_collection.ex @@ -6,7 +6,6 @@ defmodule Pinchflat.YtDlp.MediaCollection do require Logger - alias Pinchflat.Utils.FunctionUtils alias Pinchflat.Filesystem.FilesystemHelpers alias Pinchflat.YtDlp.Media, as: YtDlpMedia @@ -36,11 +35,20 @@ defmodule Pinchflat.YtDlp.MediaCollection do case runner.run(url, command_opts, output_template, output_filepath: output_filepath) do {:ok, output} -> - output - |> String.split("\n", trim: true) - |> Enum.map(&Phoenix.json_library().decode!/1) - |> Enum.map(&YtDlpMedia.response_to_struct/1) - |> FunctionUtils.wrap_ok() + parsed_lines = + output + |> String.split("\n", trim: true) + |> Enum.map(fn line -> + case Phoenix.json_library().decode(line) do + {:ok, parsed_json} -> + YtDlpMedia.response_to_struct(parsed_json) + + _ -> + nil + end + end) + + {:ok, Enum.filter(parsed_lines, &(&1 != nil))} res -> res diff --git a/lib/pinchflat_web/controllers/sources/source_controller.ex b/lib/pinchflat_web/controllers/sources/source_controller.ex index 60039f4..3211ccc 100644 --- a/lib/pinchflat_web/controllers/sources/source_controller.ex +++ b/lib/pinchflat_web/controllers/sources/source_controller.ex @@ -46,7 +46,7 @@ defmodule PinchflatWeb.Sources.SourceController do def show(conn, %{"id" => id}) do source = Repo.preload(Sources.get_source!(id), :media_profile) - pending_tasks = Repo.preload(Tasks.list_pending_tasks_for(:source_id, source.id), :job) + pending_tasks = Repo.preload(Tasks.list_pending_tasks_for(source), :job) pending_media = Media.list_pending_media_items_for(source, limit: 100) downloaded_media = Media.list_downloaded_media_items_for(source, limit: 100) diff --git a/mix.exs b/mix.exs index 455140f..ac23e35 100644 --- a/mix.exs +++ b/mix.exs @@ -13,6 +13,16 @@ defmodule Pinchflat.MixProject do preferred_cli_env: [ check: :test, credo: :test + ], + test_coverage: [ + ignore_modules: [ + Pinchflat.HTTP.HTTPClient, + PinchflatWeb.Layouts, + Pinchflat.DataCase, + Pinchflat.Release, + ~r/Fixtures/, + ~r/HTML$/ + ] ] ] end diff --git a/test/pinchflat/boot/data_backfill_worker_test.exs b/test/pinchflat/boot/data_backfill_worker_test.exs index f08872c..773cf99 100644 --- a/test/pinchflat/boot/data_backfill_worker_test.exs +++ b/test/pinchflat/boot/data_backfill_worker_test.exs @@ -4,7 +4,7 @@ defmodule Pinchflat.Boot.DataBackfillWorkerTest do import Pinchflat.MediaFixtures alias Pinchflat.Boot.DataBackfillWorker - alias Pinchflat.Filesystem.FilesystemDataWorker + alias Pinchflat.JobFixtures.TestJobWorker describe "cancel_pending_backfill_jobs/0" do test "cancels all pending backfill jobs" do @@ -21,14 +21,14 @@ defmodule Pinchflat.Boot.DataBackfillWorkerTest do test "does not cancel jobs for other workers" do %{id: 0} - |> FilesystemDataWorker.new() + |> TestJobWorker.new() |> Repo.insert_unique_job() - assert_enqueued(worker: FilesystemDataWorker) + assert_enqueued(worker: TestJobWorker) DataBackfillWorker.cancel_pending_backfill_jobs() - assert_enqueued(worker: FilesystemDataWorker) + assert_enqueued(worker: TestJobWorker) end end diff --git a/test/pinchflat/downloading/download_option_builder_test.exs b/test/pinchflat/downloading/download_option_builder_test.exs index bd88236..3f759b3 100644 --- a/test/pinchflat/downloading/download_option_builder_test.exs +++ b/test/pinchflat/downloading/download_option_builder_test.exs @@ -197,12 +197,19 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do end describe "build/1 when testing quality options" do - test "it includes quality options", %{media_item: media_item} do - media_item = update_media_profile_attribute(media_item, %{preferred_resolution: :"1080p"}) + test "it includes quality options" do + resolutions = ["360", "480", "720", "1080", "2160"] - assert {:ok, res} = DownloadOptionBuilder.build(media_item) + Enum.each(resolutions, fn resolution -> + resolution_atom = String.to_existing_atom(resolution <> "p") - assert {:format_sort, "res:1080,+codec:avc:m4a"} in res + media_profile = media_profile_fixture(%{preferred_resolution: resolution_atom}) + source = source_fixture(%{media_profile_id: media_profile.id}) + media_item = Repo.preload(media_item_fixture(source_id: source.id), source: :media_profile) + + assert {:ok, res} = DownloadOptionBuilder.build(media_item) + assert {:format_sort, "res:#{resolution},+codec:avc:m4a"} in res + end) end test "it includes quality options for audio only", %{media_item: media_item} do @@ -218,10 +225,10 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do defp update_media_profile_attribute(media_item_with_preloads, attrs) do media_item_with_preloads.source.media_profile |> Profiles.change_media_profile(attrs) - |> Repo.update!() + |> Repo.update() media_item_with_preloads |> Repo.reload() - |> Repo.preload(source: :media_profile) + |> Repo.preload([source: :media_profile], force: true) end end diff --git a/test/pinchflat/downloading/downloading_helpers_test.exs b/test/pinchflat/downloading/downloading_helpers_test.exs index f2af81f..b54a5af 100644 --- a/test/pinchflat/downloading/downloading_helpers_test.exs +++ b/test/pinchflat/downloading/downloading_helpers_test.exs @@ -34,11 +34,11 @@ defmodule Pinchflat.Downloading.DownloadingHelpersTest do source = source_fixture() media_item = media_item_fixture(source_id: source.id, media_filepath: nil) - assert [] = Tasks.list_tasks_for(:media_item_id, media_item.id) + assert [] = Tasks.list_tasks_for(media_item) assert :ok = DownloadingHelpers.enqueue_pending_download_tasks(source) - assert [_] = Tasks.list_tasks_for(:media_item_id, media_item.id) + assert [_] = Tasks.list_tasks_for(media_item) end test "it does not create a job if the source is set to not download" do @@ -54,7 +54,7 @@ defmodule Pinchflat.Downloading.DownloadingHelpersTest do media_item = media_item_fixture(source_id: source.id, media_filepath: nil) assert :ok = DownloadingHelpers.enqueue_pending_download_tasks(source) - assert [] = Tasks.list_tasks_for(:media_item_id, media_item.id) + assert [] = Tasks.list_tasks_for(media_item) end end @@ -69,7 +69,7 @@ defmodule Pinchflat.Downloading.DownloadingHelpersTest do assert :ok = DownloadingHelpers.dequeue_pending_download_tasks(source) refute_enqueued(worker: MediaDownloadWorker) - assert [] = Tasks.list_tasks_for(:media_item_id, media_item.id) + assert [] = Tasks.list_tasks_for(media_item) end end end diff --git a/test/pinchflat/downloading/media_download_worker_test.exs b/test/pinchflat/downloading/media_download_worker_test.exs index 0023419..aab984b 100644 --- a/test/pinchflat/downloading/media_download_worker_test.exs +++ b/test/pinchflat/downloading/media_download_worker_test.exs @@ -5,25 +5,37 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do import Pinchflat.MediaFixtures alias Pinchflat.Sources + alias Pinchflat.Filesystem.FilesystemHelpers alias Pinchflat.Downloading.MediaDownloadWorker - alias Pinchflat.Filesystem.FilesystemDataWorker setup :verify_on_exit! setup do - media_item = - Repo.preload( - media_item_fixture(%{media_filepath: nil}), - [:metadata, source: :media_profile] - ) - stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end) + media_item = + %{media_filepath: nil} + |> media_item_fixture() + |> Repo.preload([:metadata, source: :media_profile]) + {:ok, %{media_item: media_item}} end + describe "kickoff_with_task/2" do + test "starts the worker", %{media_item: media_item} do + assert [] = all_enqueued(worker: MediaDownloadWorker) + assert {:ok, _} = MediaDownloadWorker.kickoff_with_task(media_item) + assert [_] = all_enqueued(worker: MediaDownloadWorker) + end + + test "attaches a task", %{media_item: media_item} do + assert {:ok, task} = MediaDownloadWorker.kickoff_with_task(media_item) + assert task.media_item_id == media_item.id + end + end + describe "perform/1" do test "it saves attributes to the media_item", %{media_item: media_item} do expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> @@ -70,16 +82,18 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do perform_job(MediaDownloadWorker, %{id: media_item.id}) end - test "it schedules a filesystem data worker", %{media_item: media_item} do + test "it saves the file's size to the database", %{media_item: media_item} do expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> - {:ok, render_metadata(:media_metadata)} + metadata = render_parsed_metadata(:media_metadata) + FilesystemHelpers.write_p!(metadata["filepath"], "test") + + {:ok, Phoenix.json_library().encode!(metadata)} end) - assert [] = all_enqueued(worker: FilesystemDataWorker) - perform_job(MediaDownloadWorker, %{id: media_item.id}) + media_item = Repo.reload(media_item) - assert [_] = all_enqueued(worker: FilesystemDataWorker) + assert media_item.media_size_bytes > 0 end end end diff --git a/test/pinchflat/fast_indexing/fast_indexing_helpers_test.exs b/test/pinchflat/fast_indexing/fast_indexing_helpers_test.exs index 6aa078f..6c55051 100644 --- a/test/pinchflat/fast_indexing/fast_indexing_helpers_test.exs +++ b/test/pinchflat/fast_indexing/fast_indexing_helpers_test.exs @@ -2,51 +2,20 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do use Pinchflat.DataCase import Mox - import Pinchflat.TasksFixtures import Pinchflat.MediaFixtures import Pinchflat.SourcesFixtures import Pinchflat.ProfilesFixtures alias Pinchflat.Tasks - alias Pinchflat.Tasks.Task alias Pinchflat.Media.MediaItem alias Pinchflat.Downloading.MediaDownloadWorker alias Pinchflat.FastIndexing.MediaIndexingWorker alias Pinchflat.FastIndexing.FastIndexingHelpers - alias Pinchflat.FastIndexing.FastIndexingWorker setup :verify_on_exit! @media_url "https://www.youtube.com/watch?v=test_1" - describe "kickoff_fast_indexing_task/1" do - test "it schedules a job" do - source = source_fixture() - - assert {:ok, _} = FastIndexingHelpers.kickoff_fast_indexing_task(source) - - assert_enqueued(worker: FastIndexingWorker, args: %{"id" => source.id}) - end - - test "it creates and attaches a task" do - source = source_fixture() - - assert {:ok, %Task{} = task} = FastIndexingHelpers.kickoff_fast_indexing_task(source) - - assert task.source_id == source.id - end - - test "it deletes any fast indexing tasks for the source" do - source = source_fixture() - {:ok, job} = Oban.insert(FastIndexingWorker.new(%{"id" => source.id})) - task = task_fixture(source_id: source.id, job_id: job.id) - - assert {:ok, _} = FastIndexingHelpers.kickoff_fast_indexing_task(source) - - assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end - end - end - describe "kickoff_indexing_tasks_from_youtube_rss_feed/1" do setup do {:ok, [source: source_fixture()]} @@ -104,7 +73,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do test "creates a download task record", %{source: source} do assert {:ok, media_item} = FastIndexingHelpers.index_and_enqueue_download_for_media_item(source, @media_url) - assert [_] = Tasks.list_tasks_for(:media_item_id, media_item.id, "MediaDownloadWorker") + assert [_] = Tasks.list_tasks_for(media_item, "MediaDownloadWorker") end test "does not enqueue a download job if the source does not allow it" do diff --git a/test/pinchflat/fast_indexing/fast_indexing_worker_test.exs b/test/pinchflat/fast_indexing/fast_indexing_worker_test.exs index 5108865..4536fcd 100644 --- a/test/pinchflat/fast_indexing/fast_indexing_worker_test.exs +++ b/test/pinchflat/fast_indexing/fast_indexing_worker_test.exs @@ -9,6 +9,23 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorkerTest do setup :verify_on_exit! + describe "kickoff_with_task/2" do + test "starts the worker" do + source = source_fixture(fast_index: true) + + assert [] = all_enqueued(worker: FastIndexingWorker) + assert {:ok, _} = FastIndexingWorker.kickoff_with_task(source) + assert [_] = all_enqueued(worker: FastIndexingWorker) + end + + test "attaches a task" do + source = source_fixture(fast_index: true) + + assert {:ok, task} = FastIndexingWorker.kickoff_with_task(source) + assert task.source_id == source.id + end + end + describe "perform/1" do test "calls out to Youtube RSS if enabled" do expect(HTTPClientMock, :get, fn _url -> {:ok, ""} end) @@ -29,6 +46,16 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorkerTest do ) end + test "does not reschedule if that would create a duplicate job" do + stub(HTTPClientMock, :get, fn _url -> {:ok, ""} end) + source = source_fixture(fast_index: true) + + perform_job(FastIndexingWorker, %{"id" => source.id}) + perform_job(FastIndexingWorker, %{"id" => source.id}) + + assert [_] = all_enqueued(worker: FastIndexingWorker) + end + test "does not call out to Youtube RSS if disabled" do expect(HTTPClientMock, :get, 0, fn _url -> {:ok, ""} end) source = source_fixture(fast_index: false) diff --git a/test/pinchflat/fast_indexing/media_indexing_worker_test.exs b/test/pinchflat/fast_indexing/media_indexing_worker_test.exs index 96cc932..db31cc8 100644 --- a/test/pinchflat/fast_indexing/media_indexing_worker_test.exs +++ b/test/pinchflat/fast_indexing/media_indexing_worker_test.exs @@ -6,8 +6,8 @@ defmodule Pinchflat.FastIndexing.MediaIndexingWorkerTest do import Pinchflat.SourcesFixtures alias Pinchflat.Media.MediaItem - alias Pinchflat.FastIndexing.MediaIndexingWorker alias Pinchflat.Downloading.MediaDownloadWorker + alias Pinchflat.FastIndexing.MediaIndexingWorker @media_url "https://www.youtube.com/watch?v=1234567890" @@ -19,6 +19,19 @@ defmodule Pinchflat.FastIndexing.MediaIndexingWorkerTest do {:ok, source: source} end + describe "kickoff_with_task/2" do + test "starts the worker", %{source: source} do + assert [] = all_enqueued(worker: MediaIndexingWorker) + assert {:ok, _} = MediaIndexingWorker.kickoff_with_task(source, @media_url) + assert [_] = all_enqueued(worker: MediaIndexingWorker) + end + + test "attaches a task", %{source: source} do + assert {:ok, task} = MediaIndexingWorker.kickoff_with_task(source, @media_url) + assert task.source_id == source.id + end + end + describe "perform/1" do test "indexes the media item and saves it to the database", %{source: source} do expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> diff --git a/test/pinchflat/filesystem/filesystem_data_worker_test.exs b/test/pinchflat/filesystem/filesystem_data_worker_test.exs deleted file mode 100644 index 7a50508..0000000 --- a/test/pinchflat/filesystem/filesystem_data_worker_test.exs +++ /dev/null @@ -1,19 +0,0 @@ -defmodule Pinchflat.Filesystem.FilesystemDataWorkerTest do - use Pinchflat.DataCase - - import Pinchflat.MediaFixtures - - alias Pinchflat.Filesystem.FilesystemDataWorker - - describe "perform/1" do - test "Computes and stores the media file size" do - media_item = media_item_with_attachments() - - refute media_item.media_size_bytes - - perform_job(FilesystemDataWorker, %{id: media_item.id}) - - assert Repo.reload!(media_item).media_size_bytes - end - end -end diff --git a/test/pinchflat/filesystem/filesystem_helpers_test.exs b/test/pinchflat/filesystem/filesystem_helpers_test.exs index d776632..d5aef98 100644 --- a/test/pinchflat/filesystem/filesystem_helpers_test.exs +++ b/test/pinchflat/filesystem/filesystem_helpers_test.exs @@ -56,4 +56,55 @@ defmodule Pinchflat.Filesystem.FilesystemHelpersTest do File.rm!(filepath) end end + + describe "delete_file_and_remove_empty_directories/1" do + test "deletes file at the provided filepath" do + filepath = FilesystemHelpers.generate_metadata_tmpfile(:json) + + assert File.exists?(filepath) + + assert :ok = FilesystemHelpers.delete_file_and_remove_empty_directories(filepath) + + refute File.exists?(filepath) + end + + test "deletes empty directories" do + tmpfile_directory = Application.get_env(:pinchflat, :tmpfile_directory) + filepath = Path.join([tmpfile_directory, "foo", "bar", "baz", "qux.json"]) + FilesystemHelpers.write_p!(filepath, "") + + assert :ok = FilesystemHelpers.delete_file_and_remove_empty_directories(filepath) + + refute File.exists?(filepath) + refute File.exists?(Path.join([tmpfile_directory, "foo", "bar", "baz"])) + refute File.exists?(Path.join([tmpfile_directory, "foo", "bar"])) + refute File.exists?(Path.join([tmpfile_directory, "foo"])) + end + + test "does not delete directories with other files in them" do + tmpfile_directory = Application.get_env(:pinchflat, :tmpfile_directory) + filepath_1 = Path.join([tmpfile_directory, "foo", "bar", "baz", "qux.json"]) + filepath_2 = Path.join([tmpfile_directory, "foo", "baz.json"]) + FilesystemHelpers.write_p!(filepath_1, "") + FilesystemHelpers.write_p!(filepath_2, "") + + assert :ok = FilesystemHelpers.delete_file_and_remove_empty_directories(filepath_1) + + refute File.exists?(filepath_1) + refute File.exists?(Path.join([tmpfile_directory, "foo", "bar", "baz"])) + refute File.exists?(Path.join([tmpfile_directory, "foo", "bar"])) + + assert File.exists?(filepath_2) + assert File.exists?(Path.join([tmpfile_directory, "foo"])) + + # cleanup + FilesystemHelpers.delete_file_and_remove_empty_directories(filepath_2) + end + + test "returns an error if file could not be deleted" do + filepath = "/nonexistent/file.json" + + assert {:error, _} = FilesystemHelpers.delete_file_and_remove_empty_directories(filepath) + end + end end diff --git a/test/pinchflat/media_test.exs b/test/pinchflat/media_test.exs index a91e8b1..500628e 100644 --- a/test/pinchflat/media_test.exs +++ b/test/pinchflat/media_test.exs @@ -360,57 +360,6 @@ defmodule Pinchflat.MediaTest do end end - describe "media_filepaths/1" do - test "returns filepaths in a flat list" do - filepaths = %{ - media_filepath: "/video/test.mp4", - thumbnail_filepath: "/video/test.jpg", - subtitle_filepaths: [["en", "video/test.srt"]] - } - - media_item = media_item_fixture(filepaths) - - assert Media.media_filepaths(media_item) == [ - "/video/test.mp4", - "/video/test.jpg", - "video/test.srt" - ] - end - - test "strips out nil values" do - filepaths = %{ - media_filepath: "/video/test.mp4", - thumbnail_filepath: nil, - subtitle_filepaths: [["en", nil]] - } - - media_item = media_item_fixture(filepaths) - - assert Media.media_filepaths(media_item) == ["/video/test.mp4"] - end - end - - describe "metadata_filepaths" do - test "returns filepaths in a flat list" do - filepaths = %{ - metadata_filepath: "/metadata.json.gz", - thumbnail_filepath: "/thumbnail.jpg" - } - - media_item = media_item_fixture(%{metadata: filepaths}) - - assert Media.metadata_filepaths(media_item) == [ - "/metadata.json.gz", - "/thumbnail.jpg" - ] - end - - test "returns an empty list when there is no metadata" do - media_item = media_item_fixture() - assert Media.metadata_filepaths(media_item) == [] - end - end - describe "create_media_item/1" do test "creating with valid data creates a media_item" do valid_attrs = %{ @@ -515,6 +464,26 @@ defmodule Pinchflat.MediaTest do assert {:ok, _} = Media.delete_media_item(media_item) assert File.exists?(media_item.media_filepath) end + + test "does delete the media item's metadata files" do + stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end) + media_item = Repo.preload(media_item_with_attachments(), :metadata) + + update_attrs = %{ + metadata: %{ + metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}), + thumbnail_filepath: + MetadataFileHelpers.download_and_store_thumbnail_for(media_item, %{ + "thumbnail" => "https://example.com/thumbnail.jpg" + }) + } + } + + {:ok, updated_media_item} = Media.update_media_item(media_item, update_attrs) + + assert {:ok, _} = Media.delete_media_item(updated_media_item) + refute File.exists?(updated_media_item.metadata.metadata_filepath) + end end describe "delete_media_item/2 when testing file deletion" do diff --git a/test/pinchflat/metadata/source_metadata_storage_worker_test.exs b/test/pinchflat/metadata/source_metadata_storage_worker_test.exs index c3a4d2b..48047b5 100644 --- a/test/pinchflat/metadata/source_metadata_storage_worker_test.exs +++ b/test/pinchflat/metadata/source_metadata_storage_worker_test.exs @@ -51,5 +51,28 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do assert metadata == %{"title" => "test"} end + + test "won't call itself in an infinite loop" do + stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "{}"} end) + source = source_fixture() + + perform_job(SourceMetadataStorageWorker, %{id: source.id}) + perform_job(SourceMetadataStorageWorker, %{id: source.id}) + + assert [_] = all_enqueued(worker: SourceMetadataStorageWorker) + end + + test "doesn't prevent over source jobs from running" do + stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "{}"} end) + source_1 = source_fixture() + source_2 = source_fixture() + + perform_job(SourceMetadataStorageWorker, %{id: source_1.id}) + perform_job(SourceMetadataStorageWorker, %{id: source_1.id}) + perform_job(SourceMetadataStorageWorker, %{id: source_2.id}) + perform_job(SourceMetadataStorageWorker, %{id: source_2.id}) + + assert [_, _] = all_enqueued(worker: SourceMetadataStorageWorker) + end end end diff --git a/test/pinchflat/slow_indexing/media_collection_indexing_worker_test.exs b/test/pinchflat/slow_indexing/media_collection_indexing_worker_test.exs index 3f91de7..5cc07df 100644 --- a/test/pinchflat/slow_indexing/media_collection_indexing_worker_test.exs +++ b/test/pinchflat/slow_indexing/media_collection_indexing_worker_test.exs @@ -103,7 +103,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorkerTest do source = source_fixture(index_frequency_minutes: 10) task_count_fetcher = fn -> - Enum.count(Tasks.list_tasks_for(:source_id, source.id, "MediaCollectionIndexingWorker")) + Enum.count(Tasks.list_tasks_for(source, "MediaCollectionIndexingWorker")) end assert_changed([from: 0, to: 1], task_count_fetcher, fn -> diff --git a/test/pinchflat/slow_indexing/slow_indexing_helpers_test.exs b/test/pinchflat/slow_indexing/slow_indexing_helpers_test.exs index 754f331..f183a1e 100644 --- a/test/pinchflat/slow_indexing/slow_indexing_helpers_test.exs +++ b/test/pinchflat/slow_indexing/slow_indexing_helpers_test.exs @@ -149,7 +149,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source) - assert [] = Tasks.list_tasks_for(:media_item_id, media_item.id) + assert [] = Tasks.list_tasks_for(media_item) end test "it doesn't blow up if a media item cannot be coerced into a struct", %{source: source} do @@ -290,5 +290,22 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do assert Repo.aggregate(MediaItem, :count, :id) == 3 assert [_, _, _] = all_enqueued(worker: MediaDownloadWorker) end + + 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 -> + filepath = Keyword.get(addl_opts, :output_filepath) + File.write(filepath, "INVALID") + + # Need to add a delay to ensure the file watcher has time to read the file + :timer.sleep(watcher_poll_interval * 2) + # We know we're testing the file watcher since the syncronous call will only + # return an empty string (creating no records) + {:ok, ""} + end) + + assert [] = SlowIndexingHelpers.index_and_enqueue_download_for_media_items(source) + end end end diff --git a/test/pinchflat/sources_test.exs b/test/pinchflat/sources_test.exs index 59f9a84..103a33f 100644 --- a/test/pinchflat/sources_test.exs +++ b/test/pinchflat/sources_test.exs @@ -508,70 +508,6 @@ defmodule Pinchflat.SourcesTest do end end - describe "maybe_change_source_from_url/2" do - test "it returns a changeset" do - stub(YtDlpRunnerMock, :run, &channel_mock/3) - source = source_fixture() - - assert %Ecto.Changeset{} = Sources.maybe_change_source_from_url(source, %{}) - end - - test "it does not fetch source details if the original_url isn't in the changeset" do - expect(YtDlpRunnerMock, :run, 0, &channel_mock/3) - - changeset = Sources.maybe_change_source_from_url(%Source{}, %{name: "some updated name"}) - - assert %Ecto.Changeset{} = changeset - end - - test "it fetches source details if the original_url is in the changeset" do - expect(YtDlpRunnerMock, :run, &channel_mock/3) - - changeset = - Sources.maybe_change_source_from_url(%Source{}, %{ - original_url: "https://www.youtube.com/channel/abc123" - }) - - assert %Ecto.Changeset{} = changeset - end - - test "it adds source details to the changeset, keeping the orignal details" do - expect(YtDlpRunnerMock, :run, &channel_mock/3) - - media_profile = media_profile_fixture() - media_profile_id = media_profile.id - - changeset = - Sources.maybe_change_source_from_url(%Source{}, %{ - original_url: "https://www.youtube.com/channel/abc123", - media_profile_id: media_profile.id - }) - - assert %Ecto.Changeset{} = changeset - assert String.starts_with?(changeset.changes.collection_id, "some_channel_id_") - - assert %{ - collection_name: "some channel name", - media_profile_id: ^media_profile_id, - original_url: "https://www.youtube.com/channel/abc123" - } = changeset.changes - end - - test "it adds an error to the changeset if the runner fails" do - expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot -> - {:error, "some error", 1} - end) - - changeset = - Sources.maybe_change_source_from_url(%Source{}, %{ - original_url: "https://www.youtube.com/channel/abc123" - }) - - assert %Ecto.Changeset{} = changeset - assert errors_on(changeset).original_url == ["could not fetch source details from URL"] - end - end - defp playlist_mock(_url, _opts, _ot) do { :ok, diff --git a/test/pinchflat/tasks_test.exs b/test/pinchflat/tasks_test.exs index d780280..36140d5 100644 --- a/test/pinchflat/tasks_test.exs +++ b/test/pinchflat/tasks_test.exs @@ -36,53 +36,59 @@ defmodule Pinchflat.TasksTest do end end - describe "list_tasks_for/4" do + describe "list_tasks_for/3" do test "it lets you specify which record type/ID to join on" do - task = task_fixture() + source = source_fixture() + task = task_fixture(source_id: source.id) - assert Tasks.list_tasks_for(:source_id, task.source_id) == [task] + assert Tasks.list_tasks_for(source, nil, [:available]) == [task] end test "it lets you specify which job states to include" do - task = task_fixture() + source = source_fixture() + task = task_fixture(source_id: source.id) - assert Tasks.list_tasks_for(:source_id, task.source_id, nil, [:available]) == [task] - assert Tasks.list_tasks_for(:source_id, task.source_id, nil, [:cancelled]) == [] + assert Tasks.list_tasks_for(source, nil, [:available]) == [task] + assert Tasks.list_tasks_for(source, nil, [:cancelled]) == [] end test "it lets you specify which worker to include" do - task = task_fixture() + source = source_fixture() + task = task_fixture(source_id: source.id) - assert Tasks.list_tasks_for(:source_id, task.source_id, "TestJobWorker") == [task] - assert Tasks.list_tasks_for(:source_id, task.source_id, "FooBarWorker") == [] + assert Tasks.list_tasks_for(source, "TestJobWorker") == [task] + assert Tasks.list_tasks_for(source, "FooBarWorker") == [] end test "it includes all workers if no worker is specified" do - task = task_fixture() + source = source_fixture() + task = task_fixture(source_id: source.id) - assert Tasks.list_tasks_for(:source_id, task.source_id, nil) == [task] + assert Tasks.list_tasks_for(source, nil) == [task] end end describe "list_pending_tasks_for/3" do test "it lists pending tasks" do - task = task_fixture() + source = source_fixture() + task = task_fixture(source_id: source.id) - assert Tasks.list_pending_tasks_for(:source_id, task.source_id) == [task] + assert Tasks.list_pending_tasks_for(source) == [task] end test "it does not list non-pending tasks" do - task = Repo.preload(task_fixture(), :job) + task = Repo.preload(task_fixture(), [:job, :source]) :ok = Oban.cancel_job(task.job) - assert Tasks.list_pending_tasks_for(:source_id, task.source_id) == [] + assert Tasks.list_pending_tasks_for(task.source) == [] end test "it lets you specify which worker to include" do - task = task_fixture() + source = source_fixture() + task = task_fixture(source_id: source.id) - assert Tasks.list_pending_tasks_for(:source_id, task.source_id, "TestJobWorker") == [task] - assert Tasks.list_pending_tasks_for(:source_id, task.source_id, "FooBarWorker") == [] + assert Tasks.list_pending_tasks_for(source, "TestJobWorker") == [task] + assert Tasks.list_pending_tasks_for(source, "FooBarWorker") == [] end end diff --git a/test/pinchflat/yt_dlp/media_collection_test.exs b/test/pinchflat/yt_dlp/media_collection_test.exs index 0a26708..1f88c53 100644 --- a/test/pinchflat/yt_dlp/media_collection_test.exs +++ b/test/pinchflat/yt_dlp/media_collection_test.exs @@ -62,6 +62,15 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do assert_receive {:handler, filename} assert String.ends_with?(filename, ".json") end + + test "gracefully handles partially failed responses" do + expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl_opts -> + {:ok, "INVALID\n\n" <> source_attributes_return_fixture() <> "\nINVALID\n"} + end) + + assert {:ok, [%Media{media_id: "video1"}, %Media{media_id: "video2"}, %Media{media_id: "video3"}]} = + MediaCollection.get_media_attributes_for_collection(@channel_url) + end end describe "get_source_details/1" do