Download indexed videos (#10)

* Clarified documentation

* more comments

* [WIP] hooked up basic video downloading; starting work on metadata

* Added metadata model and parsing

Adding the metadata model made me realize that, in many cases, yt-dlp
returns undesired input in stdout, breaking parsing. In order to get
the metadata model working, I had to change the way in which the app
interacts with yt-dlp. Now, output is written as a file to disk which
is immediately re-read and returned.

* Added tests for video download worker

* Hooked up video downloading to the channel indexing pipeline

* Adds tasks for media items

* Updated video metadata parser to extract the title
This commit is contained in:
Kieran 2024-01-30 19:42:00 -08:00 committed by GitHub
parent 445e7c7417
commit b202b3b865
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 771 additions and 155 deletions

1
.gitignore vendored
View file

@ -38,3 +38,4 @@ npm-debug.log
/.elixir_ls /.elixir_ls
.env .env
.DS_Store

View file

@ -29,5 +29,7 @@ RUN chmod +x ./docker-run.sh
# Install Elixir deps # Install Elixir deps
RUN mix deps.get RUN mix deps.get
# Gives us iex shell history
ENV ERL_AFLAGS="-kernel shell_history enabled"
EXPOSE 4008 EXPOSE 4008

View file

@ -14,7 +14,8 @@ config :pinchflat,
yt_dlp_executable: System.find_executable("yt-dlp"), yt_dlp_executable: System.find_executable("yt-dlp"),
yt_dlp_runner: Pinchflat.MediaClient.Backends.YtDlp.CommandRunner, yt_dlp_runner: Pinchflat.MediaClient.Backends.YtDlp.CommandRunner,
# TODO: figure this out # TODO: figure this out
media_directory: :not_implemented media_directory: :not_implemented,
metadata_directory: Path.join([System.tmp_dir!(), "pinchflat", "metadata"])
# Configures the endpoint # Configures the endpoint
config :pinchflat, PinchflatWeb.Endpoint, config :pinchflat, PinchflatWeb.Endpoint,

View file

@ -1,7 +1,8 @@
import Config import Config
config :pinchflat, config :pinchflat,
media_directory: Path.join([System.tmp_dir!(), "yt-dlp"]) media_directory: Path.join([File.cwd!(), "tmp", "videos"]),
metadata_directory: Path.join([File.cwd!(), "tmp", "metadata"])
# Configure your database # Configure your database
config :pinchflat, Pinchflat.Repo, config :pinchflat, Pinchflat.Repo,

View file

@ -3,7 +3,8 @@ import Config
config :pinchflat, config :pinchflat,
# Specifying backend data here makes mocking and local testing SUPER easy # Specifying backend data here makes mocking and local testing SUPER easy
yt_dlp_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]), yt_dlp_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]),
media_directory: Path.join([System.tmp_dir!(), "yt-dlp"]) media_directory: Path.join([System.tmp_dir!(), "videos"]),
metadata_directory: Path.join([System.tmp_dir!(), "metadata"])
config :pinchflat, Oban, testing: :manual config :pinchflat, Oban, testing: :manual

View file

@ -4,9 +4,11 @@ defmodule Pinchflat.Media do
""" """
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias Pinchflat.Repo
alias Pinchflat.Repo
alias Pinchflat.Tasks
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.MediaSource.Channel
@doc """ @doc """
Returns the list of media_items. Returns [%MediaItem{}, ...]. Returns the list of media_items. Returns [%MediaItem{}, ...].
@ -15,6 +17,20 @@ defmodule Pinchflat.Media do
Repo.all(MediaItem) Repo.all(MediaItem)
end end
@doc """
Returns a list of pending media_items for a given channel, where
pending means the `video_filepath` is `nil`.
Returns [%MediaItem{}, ...].
"""
def list_pending_media_items_for(%Channel{} = channel) do
from(
m in MediaItem,
where: m.channel_id == ^channel.id and is_nil(m.video_filepath)
)
|> Repo.all()
end
@doc """ @doc """
Gets a single media_item. Gets a single media_item.
@ -25,7 +41,7 @@ defmodule Pinchflat.Media do
@doc """ @doc """
Creates a media_item. Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}. Creates a media_item. Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}.
""" """
def create_media_item(attrs \\ %{}) do def create_media_item(attrs) do
%MediaItem{} %MediaItem{}
|> MediaItem.changeset(attrs) |> MediaItem.changeset(attrs)
|> Repo.insert() |> Repo.insert()
@ -41,9 +57,12 @@ defmodule Pinchflat.Media do
end end
@doc """ @doc """
Deletes a media_item. Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}. Deletes a media_item and its associated tasks.
Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}.
""" """
def delete_media_item(%MediaItem{} = media_item) do def delete_media_item(%MediaItem{} = media_item) do
Tasks.delete_tasks_for(media_item)
Repo.delete(media_item) Repo.delete(media_item)
end end

View file

@ -6,13 +6,13 @@ defmodule Pinchflat.Media.MediaItem do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias Pinchflat.Tasks.Task
alias Pinchflat.MediaSource.Channel alias Pinchflat.MediaSource.Channel
alias Pinchflat.Media.MediaMetadata
@required_fields ~w(media_id channel_id)a @required_fields ~w(media_id channel_id)a
@allowed_fields ~w(title media_id video_filepath channel_id)a @allowed_fields ~w(title media_id video_filepath channel_id)a
# IDEA: consider making an attached `metadata` model to store the JSON response from whatever backend is used
schema "media_items" do schema "media_items" do
field :title, :string field :title, :string
field :media_id, :string field :media_id, :string
@ -20,6 +20,10 @@ defmodule Pinchflat.Media.MediaItem do
belongs_to :channel, Channel belongs_to :channel, Channel
has_one :metadata, MediaMetadata, on_replace: :update
has_many :tasks, Task
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@ -27,6 +31,7 @@ defmodule Pinchflat.Media.MediaItem do
def changeset(media_item, attrs) do def changeset(media_item, attrs) do
media_item media_item
|> cast(attrs, @allowed_fields) |> cast(attrs, @allowed_fields)
|> cast_assoc(:metadata, with: &MediaMetadata.changeset/2, required: false)
|> validate_required(@required_fields) |> validate_required(@required_fields)
|> unique_constraint([:media_id, :channel_id]) |> unique_constraint([:media_id, :channel_id])
end end

View file

@ -0,0 +1,28 @@
defmodule Pinchflat.Media.MediaMetadata do
@moduledoc """
The MediaMetadata schema.
Look. Don't @ me about Metadata vs. Metadatum. I'm very sensitive.
"""
use Ecto.Schema
import Ecto.Changeset
alias Pinchflat.Media.MediaItem
schema "media_metadata" do
field :client_response, :map
belongs_to :media_item, MediaItem
timestamps(type: :utc_datetime)
end
@doc false
def changeset(media_metadata, attrs) do
media_metadata
|> cast(attrs, [:client_response])
|> validate_required([:client_response])
|> unique_constraint([:media_item_id])
end
end

View file

@ -3,5 +3,5 @@ defmodule Pinchflat.MediaClient.Backends.BackendCommandRunner do
A behaviour for running CLI commands against a downloader backend A behaviour for running CLI commands against a downloader backend
""" """
@callback run(binary(), keyword()) :: {:ok, binary()} | {:error, binary(), integer()} @callback run(binary(), keyword(), binary()) :: {:ok, binary()} | {:error, binary(), integer()}
end end

View file

@ -16,9 +16,9 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.Channel do
Returns {:ok, %ChannelDetails{}} | {:error, any, ...}. Returns {:ok, %ChannelDetails{}} | {:error, any, ...}.
""" """
def get_channel_details(channel_url) do def get_channel_details(channel_url) do
opts = [print: "%(.{channel,channel_id})j", playlist_end: 1] opts = [playlist_end: 1]
with {:ok, output} <- backend_runner().run(channel_url, opts), with {:ok, output} <- backend_runner().run(channel_url, opts, "%(.{channel,channel_id})j"),
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do {:ok, parsed_json} <- Phoenix.json_library().decode(output) do
{:ok, ChannelDetails.new(parsed_json["channel_id"], parsed_json["channel"])} {:ok, ChannelDetails.new(parsed_json["channel_id"], parsed_json["channel"])}
else else

View file

@ -3,31 +3,57 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.CommandRunner do
Runs yt-dlp commands using the `System.cmd/3` function Runs yt-dlp commands using the `System.cmd/3` function
""" """
require Logger
alias Pinchflat.Utils.StringUtils alias Pinchflat.Utils.StringUtils
alias Pinchflat.MediaClient.Backends.BackendCommandRunner alias Pinchflat.MediaClient.Backends.BackendCommandRunner
@behaviour BackendCommandRunner @behaviour BackendCommandRunner
@doc """ @doc """
Runs a yt-dlp command and returns the string output Runs a yt-dlp command and returns the string output. Saves the output to
a file and then returns its contents because yt-dlp will return warnings
to stdout even if the command is successful, but these will break JSON parsing.
Returns {:ok, binary()} | {:error, output, status}. Returns {:ok, binary()} | {:error, output, status}.
# IDEA: deduplicate command opts, keeping the last one on conflict IDEA: Indexing takes a long time, but the output is actually streamed to stdout.
although possibly not needed (and a LOT easier) if yt-dlp Maybe we could listen to that stream instead so we can index videos as they're discovered.
just ignores duplicate options (ie: look into that) See: https://stackoverflow.com/a/49061086/5665799
""" """
@impl BackendCommandRunner @impl BackendCommandRunner
def run(url, command_opts) do def run(url, command_opts, output_template) do
command = backend_executable() command = backend_executable()
formatted_command_opts = parse_options(command_opts) ++ [url] # These must stay in exactly this order, hence why I'm giving it its own variable.
# Also, can't use RAM file since yt-dlp needs a concrete filepath.
json_output_path = generate_json_output_path()
print_to_file_opts = [{:print_to_file, output_template}, json_output_path]
formatted_command_opts = [url] ++ parse_options(command_opts ++ print_to_file_opts)
Logger.debug("[yt-dlp] called with: #{Enum.join(formatted_command_opts, " ")}")
case System.cmd(command, formatted_command_opts, stderr_to_stdout: true) do case System.cmd(command, formatted_command_opts, stderr_to_stdout: true) do
{output, 0} -> {:ok, output} {_, 0} ->
{output, status} -> {:error, output, status} # IDEA: consider deleting the file after reading it
# (even on error? especially on error?)
File.read(json_output_path)
{output, status} ->
{:error, output, status}
end end
end end
defp generate_json_output_path do
metadata_directory = Application.get_env(:pinchflat, :metadata_directory)
filepath = Path.join([metadata_directory, "#{StringUtils.random_string(64)}.json"])
# Ensure the file can be created and written to BEFORE we run the `yt-dlp` command
:ok = File.mkdir_p!(Path.dirname(filepath))
:ok = File.write(filepath, "")
filepath
end
# We want to satisfy the following behaviours: # We want to satisfy the following behaviours:
# #
# 1. If the key is an atom, convert it to a string and convert it to kebab case (for convenience) # 1. If the key is an atom, convert it to a string and convert it to kebab case (for convenience)
@ -55,7 +81,7 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.CommandRunner do
end end
defp parse_option(arg, acc) when is_binary(arg) do defp parse_option(arg, acc) when is_binary(arg) do
[arg | acc] acc ++ [arg]
end end
defp backend_executable do defp backend_executable do

View file

@ -0,0 +1,27 @@
defmodule Pinchflat.MediaClient.Backends.YtDlp.MetadataParser do
@moduledoc """
yt-dlp offers a LOT of metadata in its JSON response, some of which
needs to be extracted and included in various models.
For now, also squirrel all of it away in the `media_metadata` table.
I might revisit this or pare it down later, but I'd rather need it
and not have it, ya know?
"""
@doc """
Parses the given JSON response from yt-dlp and returns a map of
the needful media_item attributes, along with anything needed for
its associations.
Returns map()
"""
def parse_for_media_item(metadata) do
%{
title: metadata["title"],
video_filepath: metadata["filepath"],
metadata: %{
client_response: metadata
}
}
end
end

View file

@ -4,15 +4,15 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.Video do
""" """
@doc """ @doc """
Downloads a single video (and possible metadata) directly to its Downloads a single video (and possibly its metadata) directly to its
final destination. Returns the parsed JSON output from yt-dlp. final destination. Returns the parsed JSON output from yt-dlp.
Returns {:ok, map()} | {:error, any, ...}. Returns {:ok, map()} | {:error, any, ...}.
""" """
def download(url, command_opts \\ []) do def download(url, command_opts \\ []) do
opts = [:no_simulate, print: "%()j"] ++ command_opts opts = [:no_simulate] ++ command_opts
with {:ok, output} <- backend_runner().run(url, opts), with {:ok, output} <- backend_runner().run(url, opts, "after_move:%()j"),
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do {:ok, parsed_json} <- Phoenix.json_library().decode(output) do
{:ok, parsed_json} {:ok, parsed_json}
else else

View file

@ -16,9 +16,9 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoCollection do
""" """
def get_video_ids(url, command_opts \\ []) do def get_video_ids(url, command_opts \\ []) do
runner = Application.get_env(:pinchflat, :yt_dlp_runner) runner = Application.get_env(:pinchflat, :yt_dlp_runner)
opts = command_opts ++ [:simulate, :skip_download, print: :id] opts = command_opts ++ [:simulate, :skip_download]
case runner.run(url, opts) do case runner.run(url, opts, "%(id)s") do
{:ok, output} -> {:ok, String.split(output, "\n", trim: true)} {:ok, output} -> {:ok, String.split(output, "\n", trim: true)}
res -> res res -> res
end end

View file

@ -8,17 +8,41 @@ defmodule Pinchflat.MediaClient.VideoDownloader do
it open-ish for future expansion (just in case). it open-ish for future expansion (just in case).
""" """
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Media.MediaItem
alias Pinchflat.Profiles.MediaProfile alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.MediaClient.Backends.YtDlp.Video, as: YtDlpVideo alias Pinchflat.MediaClient.Backends.YtDlp.Video, as: YtDlpVideo
alias Pinchflat.Profiles.Options.YtDlp.OptionBuilder, as: YtDlpOptionBuilder alias Pinchflat.Profiles.Options.YtDlp.OptionBuilder, as: YtDlpOptionBuilder
alias Pinchflat.MediaClient.Backends.YtDlp.MetadataParser, as: YtDlpMetadataParser
@doc """ @doc """
Downloads a single video based on the settings in the given media profile. Downloads a video for a media item, updating the media item based on the metadata
returned by the backend. Also saves the entire metadata response to the associated
media_metadata record.
Returns {:ok, %ChannelDetails{}} | {:error, any, ...}. Returns {:ok, %MediaItem{}} | {:error, any, ...any}
""" """
def download_for_media_profile(url, %MediaProfile{} = media_profile, backend \\ :yt_dlp) do def download_for_media_item(%MediaItem{} = media_item, backend \\ :yt_dlp) do
item_with_preloads = Repo.preload(media_item, [:metadata, channel: :media_profile])
media_profile = item_with_preloads.channel.media_profile
case download_for_media_profile(media_item.media_id, media_profile, backend) do
{:ok, parsed_json} ->
parser = metadata_parser(backend)
parsed_attrs = parser.parse_for_media_item(parsed_json)
# Don't forgor to use preloaded associations or updates to
# associations won't work!
Media.update_media_item(item_with_preloads, parsed_attrs)
err ->
err
end
end
defp download_for_media_profile(url, %MediaProfile{} = media_profile, backend) do
option_builder = option_builder(backend) option_builder = option_builder(backend)
video_backend = video_backend(backend) video_backend = video_backend(backend)
{:ok, options} = option_builder.build(media_profile) {:ok, options} = option_builder.build(media_profile)
@ -37,4 +61,10 @@ defmodule Pinchflat.MediaClient.VideoDownloader do
:yt_dlp -> YtDlpVideo :yt_dlp -> YtDlpVideo
end end
end end
defp metadata_parser(backend) do
case backend do
:yt_dlp -> YtDlpMetadataParser
end
end
end end

View file

@ -33,7 +33,7 @@ defmodule Pinchflat.MediaSource do
Returns {:ok, %Channel{}} | {:error, %Ecto.Changeset{}} Returns {:ok, %Channel{}} | {:error, %Ecto.Changeset{}}
""" """
def create_channel(attrs \\ %{}) do def create_channel(attrs) do
%Channel{} %Channel{}
|> change_channel_from_url(attrs) |> change_channel_from_url(attrs)
|> commit_and_start_indexing() |> commit_and_start_indexing()
@ -101,7 +101,7 @@ defmodule Pinchflat.MediaSource do
This means that it'll go for it even if a changeset is otherwise invalid. This This means that it'll go for it even if a changeset is otherwise invalid. This
is pretty easy to change, but for MVP I'm not concerned. is pretty easy to change, but for MVP I'm not concerned.
""" """
def change_channel_from_url(%Channel{} = channel, attrs \\ %{}) do def change_channel_from_url(%Channel{} = channel, attrs) do
case change_channel(channel, attrs) do case change_channel(channel, attrs) do
%Ecto.Changeset{changes: %{original_url: _}} = changeset -> %Ecto.Changeset{changes: %{original_url: _}} = changeset ->
add_channel_details_to_changeset(channel, changeset) add_channel_details_to_changeset(channel, changeset)
@ -148,11 +148,11 @@ defmodule Pinchflat.MediaSource do
defp maybe_run_indexing_task(changeset, channel) do defp maybe_run_indexing_task(changeset, channel) do
case changeset.data do case changeset.data do
# If the changeset is new (not persisted), start indexing no matter what # If the changeset is new (not persisted), attempt indexing no matter what
%{__meta__: %{state: :built}} -> %{__meta__: %{state: :built}} ->
ChannelTasks.kickoff_indexing_task(channel) ChannelTasks.kickoff_indexing_task(channel)
# If the record has been persisted, only run indexing if the # If the record has been persisted, only attempt indexing if the
# indexing frequency has been changed # indexing frequency has been changed
%{__meta__: %{state: :loaded}} -> %{__meta__: %{state: :loaded}} ->
if Map.has_key?(changeset.changes, :index_frequency_minutes) do if Map.has_key?(changeset.changes, :index_frequency_minutes) do

View file

@ -25,7 +25,7 @@ defmodule Pinchflat.Profiles do
@doc """ @doc """
Creates a media_profile. Returns {:ok, %MediaProfile{}} | {:error, %Ecto.Changeset{}} Creates a media_profile. Returns {:ok, %MediaProfile{}} | {:error, %Ecto.Changeset{}}
""" """
def create_media_profile(attrs \\ %{}) do def create_media_profile(attrs) do
%MediaProfile{} %MediaProfile{}
|> MediaProfile.changeset(attrs) |> MediaProfile.changeset(attrs)
|> Repo.insert() |> Repo.insert()

View file

@ -24,15 +24,10 @@ defmodule Pinchflat.Profiles.Options.YtDlp.OptionBuilder do
# see: https://github.com/yt-dlp/yt-dlp#output-template # see: https://github.com/yt-dlp/yt-dlp#output-template
{:ok, {:ok,
[ [
:write_thumbnail,
:write_subs,
:embed_metadata, :embed_metadata,
:embed_thumbnail, :embed_thumbnail,
:embed_subs, :embed_subs,
:write_info_json,
:write_auto_subs,
:no_progress, :no_progress,
convert_thumbnails: "jpg",
sub_langs: "en.*", sub_langs: "en.*",
output: Path.join(base_directory(), output_path) output: Path.join(base_directory(), output_path)
]} ]}

View file

@ -2,4 +2,18 @@ defmodule Pinchflat.Repo do
use Ecto.Repo, use Ecto.Repo,
otp_app: :pinchflat, otp_app: :pinchflat,
adapter: Ecto.Adapters.Postgres adapter: Ecto.Adapters.Postgres
@doc """
It's not immediately obvious if an Oban job qualifies as unique, so this method
attempts creating a job and checks for the `conflict?` field in the returned job.
Returns {:ok, %Oban.Job{}} | {:duplicate, %Oban.Job{}} | {:error, any()}.
"""
def insert_unique_job(job_struct) do
case Oban.insert(job_struct) do
{:ok, %Oban.Job{conflict?: false} = job} -> {:ok, job}
{:ok, %Oban.Job{conflict?: true} = job} -> {:duplicate, job}
err -> err
end
end
end end

View file

@ -7,6 +7,7 @@ defmodule Pinchflat.Tasks do
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Tasks.Task alias Pinchflat.Tasks.Task
alias Pinchflat.Media.MediaItem
alias Pinchflat.MediaSource.Channel alias Pinchflat.MediaSource.Channel
@doc """ @doc """
@ -54,31 +55,41 @@ defmodule Pinchflat.Tasks do
def get_task!(id), do: Repo.get!(Task, id) def get_task!(id), do: Repo.get!(Task, id)
@doc """ @doc """
Creates a task. Returns {:ok, %Task{}} | {:error, %Ecto.Changeset{}}. Creates a task.
Accepts map() | %Oban.Job{}, %Channel{} | %Oban.Job{}, %MediaItem{}.
Returns {:ok, %Task{}} | {:error, %Ecto.Changeset{}}.
""" """
def create_task(attrs \\ %{}) do def create_task(attrs) do
%Task{} %Task{}
|> Task.changeset(attrs) |> Task.changeset(attrs)
|> Repo.insert() |> Repo.insert()
end end
# This one's function signature is designed to help simplify # This function's signature is designed to help simplify
# usage of `create_job_with_task/2` # usage of `create_job_with_task/2`
def create_task(%Oban.Job{} = job, %Channel{} = channel) do def create_task(%Oban.Job{} = job, attached_record) do
attached_record_attr =
case attached_record do
%Channel{} = channel -> %{channel_id: channel.id}
%MediaItem{} = media_item -> %{media_item_id: media_item.id}
end
%Task{} %Task{}
|> Task.changeset(%{job_id: job.id, channel_id: channel.id}) |> Task.changeset(Map.merge(%{job_id: job.id}, attached_record_attr))
|> Repo.insert() |> Repo.insert()
end end
@doc """ @doc """
Creates a job from given attrs, creating a task with an attached record Creates a job from given attrs, creating a task with an attached record
if successful. if successful. Returns an error if the job already exists.
Returns {:ok, %Task{}} | {:error, %Ecto.Changeset{}}. Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}.
""" """
def create_job_with_task(job_attrs, task_attached_record) do def create_job_with_task(job_attrs, task_attached_record) do
case Oban.insert(job_attrs) do case Repo.insert_unique_job(job_attrs) do
{:ok, job} -> create_task(job, task_attached_record) {:ok, job} -> create_task(job, task_attached_record)
{:duplicate, _} -> {:error, :duplicate_job}
err -> err err -> err
end end
end end
@ -99,8 +110,12 @@ defmodule Pinchflat.Tasks do
Returns :ok Returns :ok
""" """
def delete_tasks_for(%Channel{} = channel) do def delete_tasks_for(attached_record) do
tasks = list_tasks_for(:channel_id, channel.id) tasks =
case attached_record do
%Channel{} = channel -> list_tasks_for(:channel_id, channel.id)
%MediaItem{} = media_item -> list_tasks_for(:media_item_id, media_item.id)
end
Enum.each(tasks, fn task -> Enum.each(tasks, fn task ->
delete_task(task) delete_task(task)
@ -112,8 +127,12 @@ defmodule Pinchflat.Tasks do
Returns :ok Returns :ok
""" """
def delete_pending_tasks_for(%Channel{} = channel) do def delete_pending_tasks_for(attached_record) do
tasks = list_pending_tasks_for(:channel_id, channel.id) tasks =
case attached_record do
%Channel{} = channel -> list_pending_tasks_for(:channel_id, channel.id)
%MediaItem{} = media_item -> list_pending_tasks_for(:media_item_id, media_item.id)
end
Enum.each(tasks, fn task -> Enum.each(tasks, fn task ->
delete_task(task) delete_task(task)

View file

@ -8,7 +8,9 @@ defmodule Pinchflat.Tasks.ChannelTasks do
alias Pinchflat.Workers.MediaIndexingWorker alias Pinchflat.Workers.MediaIndexingWorker
@doc """ @doc """
Starts tasks for indexing a channel's media. Returns {:ok, :should_not_index} | {:ok, %Task{}}. Starts tasks for indexing a channel's media.
Returns {:ok, :should_not_index} | {:ok, %Task{}}.
""" """
def kickoff_indexing_task(%Channel{} = channel) do def kickoff_indexing_task(%Channel{} = channel) do
Tasks.delete_pending_tasks_for(channel) Tasks.delete_pending_tasks_for(channel)
@ -21,6 +23,11 @@ defmodule Pinchflat.Tasks.ChannelTasks do
# Schedule this one immediately, but future ones will be on an interval # Schedule this one immediately, but future ones will be on an interval
|> MediaIndexingWorker.new() |> MediaIndexingWorker.new()
|> Tasks.create_job_with_task(channel) |> Tasks.create_job_with_task(channel)
|> case do
# This should never return {:error, :duplicate_job} since we just deleted
# any pending tasks. I'm being assertive about it so it's obvious if I'm wrong
{:ok, task} -> {:ok, task}
end
end end
end end
end end

View file

@ -6,11 +6,13 @@ defmodule Pinchflat.Tasks.Task do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias Pinchflat.Media.MediaItem
alias Pinchflat.MediaSource.Channel alias Pinchflat.MediaSource.Channel
schema "tasks" do schema "tasks" do
belongs_to :job, Oban.Job belongs_to :job, Oban.Job
belongs_to :channel, Channel belongs_to :channel, Channel
belongs_to :media_item, MediaItem
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@ -18,7 +20,7 @@ defmodule Pinchflat.Tasks.Task do
@doc false @doc false
def changeset(task, attrs) do def changeset(task, attrs) do
task task
|> cast(attrs, [:job_id, :channel_id]) |> cast(attrs, [:job_id, :channel_id, :media_item_id])
|> validate_required([:job_id]) |> validate_required([:job_id])
end end
end end

View file

@ -5,10 +5,23 @@ defmodule Pinchflat.Utils.StringUtils do
@doc """ @doc """
Converts a string to kebab-case (ie: `hello world` -> `hello-world`) Converts a string to kebab-case (ie: `hello world` -> `hello-world`)
Returns binary()
""" """
def to_kebab_case(string) do def to_kebab_case(string) do
string string
|> String.replace(~r/[\s_]/, "-") |> String.replace(~r/[\s_]/, "-")
|> String.downcase() |> String.downcase()
end end
@doc """
Returns a random string of the given length. Base 16 encoded, lower case.
Returns binary()
"""
def random_string(length \\ 32) do
:crypto.strong_rand_bytes(length)
|> Base.encode16(case: :lower)
|> String.slice(0..(length - 1))
end
end end

View file

@ -7,14 +7,19 @@ defmodule Pinchflat.Workers.MediaIndexingWorker do
tags: ["media_source", "media_indexing"] tags: ["media_source", "media_indexing"]
alias __MODULE__ alias __MODULE__
alias Pinchflat.Media
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.MediaSource alias Pinchflat.MediaSource
alias Pinchflat.Workers.VideoDownloadWorker
@impl Oban.Worker @impl Oban.Worker
@doc """ @doc """
The ID is that of a channel _record_, not a YouTube channel ID. The ID is that of a channel _record_, not a YouTube channel ID. Indexes
the provided channel, kicks off downloads for each new MediaItem, and
reschedules the job to run again in the future (as determined by the
channel's `index_frequency_minutes` field).
NOTE: Re-scheduling here works a little different than you may expect. README: Re-scheduling here works a little different than you may expect.
The reschedule time is relative to the time the job has actually _completed_. The reschedule time is relative to the time the job has actually _completed_.
This has some benefits but also side effects to be aware of: This has some benefits but also side effects to be aware of:
@ -25,7 +30,12 @@ defmodule Pinchflat.Workers.MediaIndexingWorker do
actually run every 1 hour and 30 minutes. The tradeoff of not inundating actually run every 1 hour and 30 minutes. The tradeoff of not inundating
the API with requests and also not overlapping jobs is worth it, IMO. the API with requests and also not overlapping jobs is worth it, IMO.
Returns :ok | {:ok, %Task{}}. Not that it matters. NOTE: Since indexing can take a LONG time, I should check what happens if an
application restart occurs while a job is running. Will the job be lost?
IDEA: Should I use paging and do indexing in chunks? Is that even faster?
Returns :ok | {:ok, %Task{}}
""" """
def perform(%Oban.Job{args: %{"id" => channel_id}}) do def perform(%Oban.Job{args: %{"id" => channel_id}}) do
channel = MediaSource.get_channel!(channel_id) channel = MediaSource.get_channel!(channel_id)
@ -39,10 +49,32 @@ defmodule Pinchflat.Workers.MediaIndexingWorker do
defp index_media_and_reschedule(channel) do defp index_media_and_reschedule(channel) do
MediaSource.index_media_items(channel) MediaSource.index_media_items(channel)
enqueue_video_downloads(channel)
channel channel
|> Map.take([:id]) |> Map.take([:id])
|> MediaIndexingWorker.new(schedule_in: channel.index_frequency_minutes * 60) |> MediaIndexingWorker.new(schedule_in: channel.index_frequency_minutes * 60)
|> Tasks.create_job_with_task(channel) |> Tasks.create_job_with_task(channel)
|> case do
{:ok, task} -> {:ok, task}
{:error, :duplicate_job} -> {:ok, :job_exists}
end
end
# NOTE: this starts a download for each media item that is pending,
# not just the ones that were indexed in this job run. This should ensure
# that any stragglers are caught if, for some reason, they weren't enqueued
# or somehow got de-queued.
#
# I'm not sure of a case where this would happen, but it's cheap insurance.
defp enqueue_video_downloads(channel) do
channel
|> Media.list_pending_media_items_for()
|> Enum.each(fn media_item ->
media_item
|> Map.take([:id])
|> VideoDownloadWorker.new()
|> Tasks.create_job_with_task(media_item)
end)
end end
end end

View file

@ -0,0 +1,26 @@
defmodule Pinchflat.Workers.VideoDownloadWorker do
@moduledoc false
use Oban.Worker,
queue: :media_fetching,
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
tags: ["media_item", "media_fetching"]
alias Pinchflat.Media
alias Pinchflat.MediaClient.VideoDownloader
@impl Oban.Worker
@doc """
For a given media item, download the video and save the metadata.
Returns {:ok, %MediaItem{}} | {:error, any, ...any}
"""
def perform(%Oban.Job{args: %{"id" => media_item_id}}) do
media_item = Media.get_media_item!(media_item_id)
case VideoDownloader.download_for_media_item(media_item) do
{:ok, _} -> {:ok, media_item}
err -> err
end
end
end

View file

@ -0,0 +1,15 @@
defmodule Pinchflat.Repo.Migrations.CreateMediaMetadata do
use Ecto.Migration
def change do
create table(:media_metadata) do
add :client_response, :jsonb, null: false
add :media_item_id, references(:media_items, on_delete: :delete_all), null: false
timestamps(type: :utc_datetime)
end
create unique_index(:media_metadata, [:media_item_id])
create index(:media_metadata, [:client_response], using: :gin)
end
end

View file

@ -0,0 +1,12 @@
defmodule Pinchflat.Repo.Migrations.AddMediaItemToTasks do
use Ecto.Migration
def change do
alter table(:tasks) do
# `restrict` because we need to be sure to delete pending tasks when a channel is deleted
add :media_item_id, references(:media_items, on_delete: :restrict), null: true
end
create index(:tasks, [:media_item_id])
end
end

View file

@ -11,7 +11,7 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.ChannelTest do
describe "get_channel_details/1" do describe "get_channel_details/1" do
test "it returns a %ChannelDetails{} with data on success" do test "it returns a %ChannelDetails{} with data on success" do
expect(YtDlpRunnerMock, :run, fn _url, _opts -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, "{\"channel\": \"TheUselessTrials\", \"channel_id\": \"UCQH2\"}"} {:ok, "{\"channel\": \"TheUselessTrials\", \"channel_id\": \"UCQH2\"}"}
end) end)
@ -20,8 +20,9 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.ChannelTest do
end end
test "it passes the expected args to the backend runner" do test "it passes the expected args to the backend runner" do
expect(YtDlpRunnerMock, :run, fn @channel_url, opts -> expect(YtDlpRunnerMock, :run, fn @channel_url, opts, ot ->
assert opts == [{:print, "%(.{channel,channel_id})j"}, {:playlist_end, 1}] assert opts == [playlist_end: 1]
assert ot == "%(.{channel,channel_id})j"
{:ok, "{}"} {:ok, "{}"}
end) end)
@ -30,13 +31,13 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.ChannelTest do
end end
test "it returns an error if the runner returns an error" do test "it returns an error if the runner returns an error" do
expect(YtDlpRunnerMock, :run, fn _url, _opts -> {:error, "Big issue", 1} end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:error, "Big issue", 1} end)
assert {:error, "Big issue", 1} = Channel.get_channel_details(@channel_url) assert {:error, "Big issue", 1} = Channel.get_channel_details(@channel_url)
end end
test "it returns an error if the output is not JSON" do test "it returns an error if the output is not JSON" do
expect(YtDlpRunnerMock, :run, fn _url, _opts -> {:ok, "Not JSON"} end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "Not JSON"} end)
assert {:error, %Jason.DecodeError{}} = Channel.get_channel_details(@channel_url) assert {:error, %Jason.DecodeError{}} = Channel.get_channel_details(@channel_url)
end end

View file

@ -12,50 +12,49 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.CommandRunnerTest do
describe "run/2" do describe "run/2" do
test "it returns the output and status when the command succeeds" do test "it returns the output and status when the command succeeds" do
assert {:ok, _output} = Runner.run(@video_url, []) assert {:ok, _output} = Runner.run(@video_url, [], "")
end end
test "it converts symbol k-v arg keys to kebab case" do test "it converts symbol k-v arg keys to kebab case" do
assert {:ok, output} = Runner.run(@video_url, buffer_size: 1024) assert {:ok, output} = Runner.run(@video_url, [buffer_size: 1024], "")
assert String.contains?(output, "--buffer-size 1024") assert String.contains?(output, "--buffer-size 1024")
end end
test "it keeps string k-v arg keys untouched" do test "it keeps string k-v arg keys untouched" do
assert {:ok, output} = Runner.run(@video_url, [{"--under_score", 1024}]) assert {:ok, output} = Runner.run(@video_url, [{"--under_score", 1024}], "")
assert String.contains?(output, "--under_score 1024") assert String.contains?(output, "--under_score 1024")
end end
test "it converts symbol arg keys to kebab case" do test "it converts symbol arg keys to kebab case" do
assert {:ok, output} = Runner.run(@video_url, [:ignore_errors]) assert {:ok, output} = Runner.run(@video_url, [:ignore_errors], "")
assert String.contains?(output, "--ignore-errors") assert String.contains?(output, "--ignore-errors")
end end
test "it keeps string arg keys untouched" do test "it keeps string arg keys untouched" do
assert {:ok, output} = Runner.run(@video_url, ["-v"]) assert {:ok, output} = Runner.run(@video_url, ["-v"], "")
assert String.contains?(output, "-v") assert String.contains?(output, "-v")
refute String.contains?(output, "--v") refute String.contains?(output, "--v")
end end
test "it places arg keys (flags) at the beginning of the command" do test "it includes the video url as the first argument" do
assert {:ok, output} = assert {:ok, output} = Runner.run(@video_url, [:ignore_errors], "")
Runner.run(@video_url, [{"--under_score", 1024}, :ignore_errors])
assert String.contains?(output, "--ignore-errors --under_score 1024") assert String.contains?(output, "#{@video_url} --ignore-errors")
end end
test "it includes the video url as the last argument" do test "it automatically includes the --print-to-file flag" do
assert {:ok, output} = Runner.run(@video_url, [:ignore_errors]) assert {:ok, output} = Runner.run(@video_url, [], "%(id)s")
assert String.contains?(output, "--ignore-errors #{@video_url}\n") assert String.contains?(output, "--print-to-file %(id)s /tmp/")
end end
test "it returns the output and status when the command fails" do test "it returns the output and status when the command fails" do
wrap_executable("/bin/false", fn -> wrap_executable("/bin/false", fn ->
assert {:error, "", 1} = Runner.run(@video_url, []) assert {:error, "", 1} = Runner.run(@video_url, [], "")
end) end)
end end
end end

View file

@ -0,0 +1,45 @@
defmodule Pinchflat.MediaClient.Backends.YtDlp.MediaParserTest do
use ExUnit.Case, async: true
alias Pinchflat.MediaClient.Backends.YtDlp.MetadataParser, as: Parser
setup do
json_filepath =
Path.join([
File.cwd!(),
"test",
"support",
"files",
"media_metadata.json"
])
{:ok, file_body} = File.read(json_filepath)
{:ok, parsed_json} = Phoenix.json_library().decode(file_body)
{:ok,
%{
metadata: parsed_json
}}
end
describe "parse_for_media_item/1" do
test "it extracts the video filepath", %{metadata: metadata} do
result = Parser.parse_for_media_item(metadata)
assert String.contains?(result.video_filepath, "bwRHIkYqYJo")
assert String.ends_with?(result.video_filepath, ".mkv")
end
test "it extracts the title", %{metadata: metadata} do
result = Parser.parse_for_media_item(metadata)
assert result.title == "Trying to Wheelie Without the Rear Brake"
end
test "it returns the metadata as a map", %{metadata: metadata} do
result = Parser.parse_for_media_item(metadata)
assert result.metadata.client_response == metadata
end
end
end

View file

@ -14,14 +14,15 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoCollectionTest do
describe "get_video_ids/2" do describe "get_video_ids/2" do
test "returns a list of video ids with no blank elements" do test "returns a list of video ids with no blank elements" do
expect(YtDlpRunnerMock, :run, fn _url, _opts -> {:ok, "id1\nid2\n\nid3\n"} end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "id1\nid2\n\nid3\n"} end)
assert {:ok, ["id1", "id2", "id3"]} = VideoCollectionUser.get_video_ids(@channel_url) assert {:ok, ["id1", "id2", "id3"]} = VideoCollectionUser.get_video_ids(@channel_url)
end end
test "it passes the expected default args" do test "it passes the expected default args" do
expect(YtDlpRunnerMock, :run, fn _url, opts -> expect(YtDlpRunnerMock, :run, fn _url, opts, ot ->
assert opts == [:simulate, :skip_download, {:print, :id}] assert opts == [:simulate, :skip_download]
assert ot == "%(id)s"
{:ok, ""} {:ok, ""}
end) end)
@ -30,8 +31,8 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoCollectionTest do
end end
test "it passes the expected custom args" do test "it passes the expected custom args" do
expect(YtDlpRunnerMock, :run, fn _url, opts -> expect(YtDlpRunnerMock, :run, fn _url, opts, _ot ->
assert opts == [:custom_arg, :simulate, :skip_download, {:print, :id}] assert opts == [:custom_arg, :simulate, :skip_download]
{:ok, ""} {:ok, ""}
end) end)
@ -40,7 +41,7 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoCollectionTest do
end end
test "returns the error straight through when the command fails" do test "returns the error straight through when the command fails" do
expect(YtDlpRunnerMock, :run, fn _url, _opts -> {:error, "Big issue", 1} end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:error, "Big issue", 1} end)
assert {:error, "Big issue", 1} = VideoCollectionUser.get_video_ids(@channel_url) assert {:error, "Big issue", 1} = VideoCollectionUser.get_video_ids(@channel_url)
end end

View file

@ -1,5 +1,5 @@
defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoTest do defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoTest do
use ExUnit.Case, async: true use Pinchflat.DataCase
import Mox import Mox
alias Pinchflat.MediaClient.Backends.YtDlp.Video alias Pinchflat.MediaClient.Backends.YtDlp.Video
@ -8,20 +8,27 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoTest do
setup :verify_on_exit! setup :verify_on_exit!
# expect(YtDlpRunnerMock, :run, fn _url, [_, _, json_output_path | _] ->
# copy_metadata(json_output_path)
# {:ok, ""}
# end)
describe "download/2" do describe "download/2" do
test "it calls the backend runner with the expected arguments" do test "it calls the backend runner with the expected arguments" do
expect(YtDlpRunnerMock, :run, fn @video_url, opts -> expect(YtDlpRunnerMock, :run, fn @video_url, opts, ot ->
assert opts == [:no_simulate, {:print, "%()j"}] assert [:no_simulate] = opts
assert "after_move:%()j" = ot
{:ok, "{}"} {:ok, render_metadata(:media_metadata)}
end) end)
assert {:ok, _} = Video.download(@video_url) assert {:ok, _} = Video.download(@video_url)
end end
test "it passes along additional options" do test "it passes along additional options" do
expect(YtDlpRunnerMock, :run, fn _url, opts -> expect(YtDlpRunnerMock, :run, fn _url, opts, _ot ->
assert opts == [:no_simulate, {:print, "%()j"}, :custom_arg] assert [:no_simulate, :custom_arg] = opts
{:ok, "{}"} {:ok, "{}"}
end) end)
@ -29,22 +36,21 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoTest do
assert {:ok, _} = Video.download(@video_url, [:custom_arg]) assert {:ok, _} = Video.download(@video_url, [:custom_arg])
end end
test "it parses the output as JSON" do test "it parses and returns the generated file as JSON" do
expect(YtDlpRunnerMock, :run, fn _url, _opts -> {:ok, "{\"title\": \"Test\"}"} end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, render_metadata(:media_metadata)}
end)
assert {:ok, %{"title" => "Test"}} = Video.download(@video_url) assert {:ok, %{"title" => "Trying to Wheelie Without the Rear Brake"}} =
Video.download(@video_url)
end end
test "it returns an error if the output is not JSON" do test "it returns errors" do
expect(YtDlpRunnerMock, :run, fn _url, _opts -> {:ok, "Not JSON"} end) expect(YtDlpRunnerMock, :run, fn _url, _opt, _ot ->
{:error, "something"}
end)
assert {:error, %Jason.DecodeError{}} = Video.download(@video_url) assert {:error, "something"} = Video.download(@video_url)
end
test "it directly passes along any errors" do
expect(YtDlpRunnerMock, :run, fn _url, _opts -> {:error, "Big issue", 1} end)
assert {:error, "Big issue", 1} = Video.download(@video_url)
end end
end end
end end

View file

@ -17,8 +17,9 @@ defmodule Pinchflat.MediaClient.ChannelDetailsTest do
describe "get_channel_details/2" do describe "get_channel_details/2" do
test "it passes the expected arguments to the backend" do test "it passes the expected arguments to the backend" do
expect(YtDlpRunnerMock, :run, fn @channel_url, opts -> expect(YtDlpRunnerMock, :run, fn @channel_url, opts, ot ->
assert opts == [{:print, "%(.{channel,channel_id})j"}, {:playlist_end, 1}] assert opts == [playlist_end: 1]
assert ot == "%(.{channel,channel_id})j"
{:ok, "{\"channel\": \"TheUselessTrials\", \"channel_id\": \"UCQH2\"}"} {:ok, "{\"channel\": \"TheUselessTrials\", \"channel_id\": \"UCQH2\"}"}
end) end)
@ -27,7 +28,7 @@ defmodule Pinchflat.MediaClient.ChannelDetailsTest do
end end
test "it returns a struct composed of the returned data" do test "it returns a struct composed of the returned data" do
expect(YtDlpRunnerMock, :run, fn _url, _opts -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, "{\"channel\": \"TheUselessTrials\", \"channel_id\": \"UCQH2\"}"} {:ok, "{\"channel\": \"TheUselessTrials\", \"channel_id\": \"UCQH2\"}"}
end) end)
@ -38,8 +39,9 @@ defmodule Pinchflat.MediaClient.ChannelDetailsTest do
describe "get_video_ids/2" do describe "get_video_ids/2" do
test "it passes the expected arguments to the backend" do test "it passes the expected arguments to the backend" do
expect(YtDlpRunnerMock, :run, fn @channel_url, opts -> expect(YtDlpRunnerMock, :run, fn @channel_url, opts, ot ->
assert opts == [:simulate, :skip_download, {:print, :id}] assert opts == [:simulate, :skip_download]
assert ot == "%(id)s"
{:ok, ""} {:ok, ""}
end) end)
@ -48,7 +50,7 @@ defmodule Pinchflat.MediaClient.ChannelDetailsTest do
end end
test "it returns a list of strings" do test "it returns a list of strings" do
expect(YtDlpRunnerMock, :run, fn _url, _opts -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, "video1\nvideo2\nvideo3"} {:ok, "video1\nvideo2\nvideo3"}
end) end)

View file

@ -1,35 +1,61 @@
defmodule Pinchflat.MediaClient.VideoDownloaderTest do defmodule Pinchflat.MediaClient.VideoDownloaderTest do
use ExUnit.Case, async: true use Pinchflat.DataCase
import Mox import Mox
import Pinchflat.MediaFixtures
alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.MediaClient.VideoDownloader alias Pinchflat.MediaClient.VideoDownloader
@video_url "https://www.youtube.com/watch?v=TiZPUDkDYbk"
@media_profile %MediaProfile{
output_path_template: "videos/{{ title }}.%(ext)s"
}
setup :verify_on_exit! setup :verify_on_exit!
describe "download_for_media_profile/3" do setup do
test "it calls the backend runner with the arguments built from the media profile" do media_item =
expect(YtDlpRunnerMock, :run, fn @video_url, opts -> Repo.preload(
assert :no_simulate in opts media_item_fixture(%{title: nil, video_filepath: nil}),
assert {:print, "%()j"} in opts [:metadata, channel: :media_profile]
assert {:output, "/tmp/yt-dlp/videos/%(title)S.%(ext)s"} in opts )
{:ok, "{}"} {:ok, %{media_item: media_item}}
end
describe "download_for_media_item/3" do
test "it calls the backend runner", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, ot ->
assert ot == "after_move:%()j"
{:ok, render_metadata(:media_metadata)}
end) end)
assert {:ok, _} = VideoDownloader.download_for_media_profile(@video_url, @media_profile) assert {:ok, _} = VideoDownloader.download_for_media_item(media_item)
end end
test "it returns the parsed JSON output" do test "it writes attributes to the media item", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts -> {:ok, "{\"title\": \"Test\"}"} end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, render_metadata(:media_metadata)}
end)
assert {:ok, %{"title" => "Test"}} = assert %{video_filepath: nil, title: nil} = media_item
VideoDownloader.download_for_media_profile(@video_url, @media_profile) assert {:ok, updated_media_item} = VideoDownloader.download_for_media_item(media_item)
assert updated_media_item.video_filepath
assert updated_media_item.title
end
test "it saves the metadata to the database", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, render_metadata(:media_metadata)}
end)
assert is_nil(media_item.metadata)
assert {:ok, updated_media_item} = VideoDownloader.download_for_media_item(media_item)
assert updated_media_item.metadata
assert is_map(updated_media_item.metadata.client_response)
end
test "errors are passed through", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:error, :some_error}
end)
assert {:error, :some_error} = VideoDownloader.download_for_media_item(media_item)
end end
end end
end end

View file

@ -30,7 +30,7 @@ defmodule Pinchflat.MediaSourceTest do
describe "create_channel/1" do describe "create_channel/1" do
test "creates a channel and adds name + ID from runner response" do test "creates a channel and adds name + ID from runner response" do
expect(YtDlpRunnerMock, :run, &runner_function_mock/2) expect(YtDlpRunnerMock, :run, &runner_function_mock/3)
valid_attrs = %{ valid_attrs = %{
media_profile_id: media_profile_fixture().id, media_profile_id: media_profile_fixture().id,
@ -47,7 +47,7 @@ defmodule Pinchflat.MediaSourceTest do
end end
test "creation enforces uniqueness of channel_id scoped to the media_profile" do test "creation enforces uniqueness of channel_id scoped to the media_profile" do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts -> expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot ->
{:ok, {:ok,
Phoenix.json_library().encode!(%{ Phoenix.json_library().encode!(%{
channel: "some name", channel: "some name",
@ -65,7 +65,7 @@ defmodule Pinchflat.MediaSourceTest do
end end
test "creation lets you duplicate channel_ids as long as the media profile is different" do test "creation lets you duplicate channel_ids as long as the media profile is different" do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts -> expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot ->
{:ok, {:ok,
Phoenix.json_library().encode!(%{ Phoenix.json_library().encode!(%{
channel: "some name", channel: "some name",
@ -86,7 +86,7 @@ defmodule Pinchflat.MediaSourceTest do
end end
test "creation will schedule the indexing task" do test "creation will schedule the indexing task" do
expect(YtDlpRunnerMock, :run, &runner_function_mock/2) expect(YtDlpRunnerMock, :run, &runner_function_mock/3)
valid_attrs = %{ valid_attrs = %{
media_profile_id: media_profile_fixture().id, media_profile_id: media_profile_fixture().id,
@ -101,7 +101,7 @@ defmodule Pinchflat.MediaSourceTest do
describe "index_media_items/1" do describe "index_media_items/1" do
setup do setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts -> {:ok, "video1\nvideo2\nvideo3"} end) stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "video1\nvideo2\nvideo3"} end)
{:ok, [channel: channel_fixture()]} {:ok, [channel: channel_fixture()]}
end end
@ -162,7 +162,7 @@ defmodule Pinchflat.MediaSourceTest do
end end
test "updating the original_url will re-fetch the channel details" do test "updating the original_url will re-fetch the channel details" do
expect(YtDlpRunnerMock, :run, &runner_function_mock/2) expect(YtDlpRunnerMock, :run, &runner_function_mock/3)
channel = channel_fixture() channel = channel_fixture()
update_attrs = %{original_url: "https://www.youtube.com/channel/abc123"} update_attrs = %{original_url: "https://www.youtube.com/channel/abc123"}
@ -173,7 +173,7 @@ defmodule Pinchflat.MediaSourceTest do
end end
test "not updating the original_url will not re-fetch the channel details" do test "not updating the original_url will not re-fetch the channel details" do
expect(YtDlpRunnerMock, :run, 0, &runner_function_mock/2) expect(YtDlpRunnerMock, :run, 0, &runner_function_mock/3)
channel = channel_fixture() channel = channel_fixture()
update_attrs = %{name: "some updated name"} update_attrs = %{name: "some updated name"}
@ -239,14 +239,14 @@ defmodule Pinchflat.MediaSourceTest do
describe "change_channel_from_url/2" do describe "change_channel_from_url/2" do
test "it returns a changeset" do test "it returns a changeset" do
stub(YtDlpRunnerMock, :run, &runner_function_mock/2) stub(YtDlpRunnerMock, :run, &runner_function_mock/3)
channel = channel_fixture() channel = channel_fixture()
assert %Ecto.Changeset{} = MediaSource.change_channel_from_url(channel) assert %Ecto.Changeset{} = MediaSource.change_channel_from_url(channel, %{})
end end
test "it does not fetch channel details if the original_url isn't in the changeset" do test "it does not fetch channel details if the original_url isn't in the changeset" do
expect(YtDlpRunnerMock, :run, 0, &runner_function_mock/2) expect(YtDlpRunnerMock, :run, 0, &runner_function_mock/3)
changeset = MediaSource.change_channel_from_url(%Channel{}, %{name: "some updated name"}) changeset = MediaSource.change_channel_from_url(%Channel{}, %{name: "some updated name"})
@ -254,7 +254,7 @@ defmodule Pinchflat.MediaSourceTest do
end end
test "it fetches channel details if the original_url is in the changeset" do test "it fetches channel details if the original_url is in the changeset" do
expect(YtDlpRunnerMock, :run, &runner_function_mock/2) expect(YtDlpRunnerMock, :run, &runner_function_mock/3)
changeset = changeset =
MediaSource.change_channel_from_url(%Channel{}, %{ MediaSource.change_channel_from_url(%Channel{}, %{
@ -265,7 +265,7 @@ defmodule Pinchflat.MediaSourceTest do
end end
test "it adds channel details to the changeset, keeping the orignal details" do test "it adds channel details to the changeset, keeping the orignal details" do
expect(YtDlpRunnerMock, :run, &runner_function_mock/2) expect(YtDlpRunnerMock, :run, &runner_function_mock/3)
media_profile = media_profile_fixture() media_profile = media_profile_fixture()
media_profile_id = media_profile.id media_profile_id = media_profile.id
@ -287,7 +287,7 @@ defmodule Pinchflat.MediaSourceTest do
end end
test "it adds an error to the changeset if the runner fails" do test "it adds an error to the changeset if the runner fails" do
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts -> expect(YtDlpRunnerMock, :run, 1, fn _url, _opts, _ot ->
{:error, "some error", 1} {:error, "some error", 1}
end) end)
@ -301,7 +301,7 @@ defmodule Pinchflat.MediaSourceTest do
end end
end end
defp runner_function_mock(_url, _opts) do defp runner_function_mock(_url, _opts, _ot) do
{ {
:ok, :ok,
Phoenix.json_library().encode!(%{ Phoenix.json_library().encode!(%{

View file

@ -1,6 +1,7 @@
defmodule Pinchflat.MediaTest do defmodule Pinchflat.MediaTest do
use Pinchflat.DataCase use Pinchflat.DataCase
import Pinchflat.TasksFixtures
import Pinchflat.MediaFixtures import Pinchflat.MediaFixtures
import Pinchflat.MediaSourceFixtures import Pinchflat.MediaSourceFixtures
@ -9,6 +10,18 @@ defmodule Pinchflat.MediaTest do
@invalid_attrs %{title: nil, media_id: nil, video_filepath: nil} @invalid_attrs %{title: nil, media_id: nil, video_filepath: nil}
describe "schema" do
test "media_metadata is deleted when media_item is deleted" do
media_item = media_item_fixture(%{metadata: %{client_response: %{foo: "bar"}}})
metadata = media_item.metadata
assert {:ok, %MediaItem{}} = Media.delete_media_item(media_item)
assert_raise Ecto.NoResultsError, fn ->
Repo.reload!(metadata)
end
end
end
describe "list_media_items/0" do describe "list_media_items/0" do
test "it returns all media_items" do test "it returns all media_items" do
media_item = media_item_fixture() media_item = media_item_fixture()
@ -16,6 +29,27 @@ defmodule Pinchflat.MediaTest do
end end
end end
describe "list_pending_media_items_for/1" do
test "it returns pending media_items for a given channel" do
channel = channel_fixture()
media_item = media_item_fixture(%{channel_id: channel.id, video_filepath: nil})
assert Media.list_pending_media_items_for(channel) == [media_item]
end
test "it does not return media_items with video_filepath" do
channel = channel_fixture()
_media_item =
media_item_fixture(%{
channel_id: channel.id,
video_filepath: "/video/#{Faker.File.file_name(:video)}"
})
assert Media.list_pending_media_items_for(channel) == []
end
end
describe "get_media_item!/1" do describe "get_media_item!/1" do
test "it returns the media_item with given id" do test "it returns the media_item with given id" do
media_item = media_item_fixture() media_item = media_item_fixture()
@ -73,6 +107,14 @@ defmodule Pinchflat.MediaTest do
assert {:ok, %MediaItem{}} = Media.delete_media_item(media_item) assert {:ok, %MediaItem{}} = Media.delete_media_item(media_item)
assert_raise Ecto.NoResultsError, fn -> Media.get_media_item!(media_item.id) end assert_raise Ecto.NoResultsError, fn -> Media.get_media_item!(media_item.id) end
end end
test "it also deletes attached tasks" do
media_item = media_item_fixture()
task = task_fixture(%{media_item_id: media_item.id})
assert {:ok, %MediaItem{}} = Media.delete_media_item(media_item)
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end
end end
describe "change_media_item/1" do describe "change_media_item/1" do

View file

@ -5,14 +5,14 @@ defmodule Pinchflat.Profiles.Options.YtDlp.OptionBuilderTest do
alias Pinchflat.Profiles.Options.YtDlp.OptionBuilder alias Pinchflat.Profiles.Options.YtDlp.OptionBuilder
@media_profile %MediaProfile{ @media_profile %MediaProfile{
output_path_template: "videos/{{ title }}.%(ext)s" output_path_template: "{{ title }}.%(ext)s"
} }
describe "build/1" do describe "build/1" do
test "it generates an expanded output path based on the given template" do test "it generates an expanded output path based on the given template" do
assert {:ok, res} = OptionBuilder.build(@media_profile) assert {:ok, res} = OptionBuilder.build(@media_profile)
assert {:output, "/tmp/yt-dlp/videos/%(title)S.%(ext)s"} in res assert {:output, "/tmp/videos/%(title)S.%(ext)s"} in res
end end
end end
end end

View file

@ -0,0 +1,26 @@
defmodule Pinchflat.RepoTest do
use Pinchflat.DataCase
alias Pinchflat.JobFixtures.TestJobWorker
describe "insert_unique_job/1" do
test "returns {:ok, job} if there is no conflict" do
job = TestJobWorker.new(%{})
assert {:ok, %Oban.Job{}} = Pinchflat.Repo.insert_unique_job(job)
end
test "returns {:duplicate, original_job} if there is a conflict" do
job = TestJobWorker.new(%{foo: "bar"}, unique: [period: :infinity])
{:ok, saved_job_1} = Pinchflat.Repo.insert_unique_job(job)
assert {:duplicate, saved_job_2} = Pinchflat.Repo.insert_unique_job(job)
assert saved_job_1.id == saved_job_2.id
end
test "returns the error if there is an error" do
assert {:error, _} = Pinchflat.Repo.insert_unique_job(%Ecto.Changeset{})
end
end
end

View file

@ -2,6 +2,7 @@ defmodule Pinchflat.TasksTest do
use Pinchflat.DataCase use Pinchflat.DataCase
import Pinchflat.JobFixtures import Pinchflat.JobFixtures
import Pinchflat.TasksFixtures import Pinchflat.TasksFixtures
import Pinchflat.MediaFixtures
import Pinchflat.MediaSourceFixtures import Pinchflat.MediaSourceFixtures
alias Pinchflat.Tasks alias Pinchflat.Tasks
@ -92,14 +93,24 @@ defmodule Pinchflat.TasksTest do
assert task.job_id == job.id assert task.job_id == job.id
assert task.channel_id == channel.id assert task.channel_id == channel.id
end end
test "accepts a job and media item" do
job = job_fixture()
media_item = media_item_fixture()
assert {:ok, %Task{} = task} = Tasks.create_task(job, media_item)
assert task.job_id == job.id
assert task.media_item_id == media_item.id
end
end end
describe "create_job_with_task/2" do describe "create_job_with_task/2" do
test "it enqueues the given job" do test "it enqueues the given job" do
channel = channel_fixture() media_item = media_item_fixture()
refute_enqueued(worker: TestJobWorker) refute_enqueued(worker: TestJobWorker)
assert {:ok, %Task{}} = Tasks.create_job_with_task(TestJobWorker.new(%{}), channel) assert {:ok, %Task{}} = Tasks.create_job_with_task(TestJobWorker.new(%{}), media_item)
assert_enqueued(worker: TestJobWorker) assert_enqueued(worker: TestJobWorker)
end end
@ -110,6 +121,20 @@ defmodule Pinchflat.TasksTest do
assert task.channel_id == channel.id assert task.channel_id == channel.id
end end
test "it returns an error if the job already exists" do
channel = channel_fixture()
job = TestJobWorker.new(%{foo: "bar"}, unique: [period: :infinity])
assert {:ok, %Task{}} = Tasks.create_job_with_task(job, channel)
assert {:error, :duplicate_job} = Tasks.create_job_with_task(job, channel)
end
test "it returns an error if the job fails to enqueue" do
channel = channel_fixture()
assert {:error, %Ecto.Changeset{}} = Tasks.create_job_with_task(%Ecto.Changeset{}, channel)
end
end end
describe "delete_task/1" do describe "delete_task/1" do
@ -137,6 +162,14 @@ defmodule Pinchflat.TasksTest do
assert :ok = Tasks.delete_tasks_for(channel) assert :ok = Tasks.delete_tasks_for(channel)
assert_raise Ecto.NoResultsError, fn -> Tasks.get_task!(task.id) end assert_raise Ecto.NoResultsError, fn -> Tasks.get_task!(task.id) end
end end
test "it deletes the tasks attached to a media_item" do
media_item = media_item_fixture()
task = task_fixture(media_item_id: media_item.id)
assert :ok = Tasks.delete_tasks_for(media_item)
assert_raise Ecto.NoResultsError, fn -> Tasks.get_task!(task.id) end
end
end end
describe "delete_pending_tasks_for/1" do describe "delete_pending_tasks_for/1" do
@ -156,6 +189,17 @@ defmodule Pinchflat.TasksTest do
assert :ok = Tasks.delete_pending_tasks_for(channel) assert :ok = Tasks.delete_pending_tasks_for(channel)
assert Tasks.get_task!(task.id) assert Tasks.get_task!(task.id)
end end
test "it works on media_items" do
media_item = media_item_fixture()
pending_task = task_fixture(media_item_id: media_item.id)
cancelled_task = Repo.preload(task_fixture(media_item_id: media_item.id), :job)
:ok = Oban.cancel_job(cancelled_task.job)
assert :ok = Tasks.delete_pending_tasks_for(media_item)
assert Tasks.get_task!(cancelled_task.id)
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(pending_task) end
end
end end
describe "change_task/1" do describe "change_task/1" do

View file

@ -12,4 +12,19 @@ defmodule Pinchflat.Utils.StringUtilsTest do
assert StringUtils.to_kebab_case("hello_world") == "hello-world" assert StringUtils.to_kebab_case("hello_world") == "hello-world"
end end
end end
describe "random_string/1" do
test "generates a random string" do
assert is_binary(StringUtils.random_string())
assert StringUtils.random_string() != StringUtils.random_string()
end
test "has a defined default length" do
assert String.length(StringUtils.random_string()) == 32
end
test "can generate a string of a given length" do
assert String.length(StringUtils.random_string(64)) == 64
end
end
end end

View file

@ -2,16 +2,18 @@ defmodule Pinchflat.Workers.MediaIndexingWorkerTest do
use Pinchflat.DataCase use Pinchflat.DataCase
import Mox import Mox
import Pinchflat.MediaFixtures
import Pinchflat.MediaSourceFixtures import Pinchflat.MediaSourceFixtures
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Workers.MediaIndexingWorker alias Pinchflat.Workers.MediaIndexingWorker
alias Pinchflat.Workers.VideoDownloadWorker
setup :verify_on_exit! setup :verify_on_exit!
describe "perform/1" do describe "perform/1" do
test "it does not do any indexing if the channel shouldn't be indexed" do test "it does not do any indexing if the channel shouldn't be indexed" do
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts -> {:ok, ""} end) expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot -> {:ok, ""} end)
channel = channel_fixture(index_frequency_minutes: -1) channel = channel_fixture(index_frequency_minutes: -1)
@ -19,7 +21,7 @@ defmodule Pinchflat.Workers.MediaIndexingWorkerTest do
end end
test "it does not reschedule if the channel shouldn't be indexed" do test "it does not reschedule if the channel shouldn't be indexed" do
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts -> {:ok, ""} end) expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot -> {:ok, ""} end)
channel = channel_fixture(index_frequency_minutes: -1) channel = channel_fixture(index_frequency_minutes: -1)
perform_job(MediaIndexingWorker, %{id: channel.id}) perform_job(MediaIndexingWorker, %{id: channel.id})
@ -28,15 +30,44 @@ defmodule Pinchflat.Workers.MediaIndexingWorkerTest do
end end
test "it indexes the channel if it should be indexed" do test "it indexes the channel if it should be indexed" do
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts -> {:ok, ""} end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
channel = channel_fixture(index_frequency_minutes: 10) channel = channel_fixture(index_frequency_minutes: 10)
perform_job(MediaIndexingWorker, %{id: channel.id}) perform_job(MediaIndexingWorker, %{id: channel.id})
end end
test "it kicks off a download job for each pending media item" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "video1"} end)
channel = channel_fixture(index_frequency_minutes: 10)
perform_job(MediaIndexingWorker, %{id: channel.id})
assert [_] = all_enqueued(worker: VideoDownloadWorker)
end
test "it starts a job for any pending media item even if it's from another run" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "video1"} end)
channel = channel_fixture(index_frequency_minutes: 10)
media_item_fixture(%{channel_id: channel.id, video_filepath: nil})
perform_job(MediaIndexingWorker, %{id: channel.id})
assert [_, _] = all_enqueued(worker: VideoDownloadWorker)
end
test "it does not kick off a job for media items that could not be saved" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "video1\nvideo1"} end)
channel = channel_fixture(index_frequency_minutes: 10)
perform_job(MediaIndexingWorker, %{id: channel.id})
# Only one job should be enqueued, since the second video is a duplicate
assert [_] = all_enqueued(worker: VideoDownloadWorker)
end
test "it reschedules the job based on the index frequency" do test "it reschedules the job based on the index frequency" do
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts -> {:ok, ""} end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
channel = channel_fixture(index_frequency_minutes: 10) channel = channel_fixture(index_frequency_minutes: 10)
perform_job(MediaIndexingWorker, %{id: channel.id}) perform_job(MediaIndexingWorker, %{id: channel.id})
@ -49,7 +80,7 @@ defmodule Pinchflat.Workers.MediaIndexingWorkerTest do
end end
test "it creates a task for the rescheduled job" do test "it creates a task for the rescheduled job" do
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts -> {:ok, ""} end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
channel = channel_fixture(index_frequency_minutes: 10) channel = channel_fixture(index_frequency_minutes: 10)
task_count_fetcher = fn -> Enum.count(Tasks.list_tasks()) end task_count_fetcher = fn -> Enum.count(Tasks.list_tasks()) end
@ -60,7 +91,7 @@ defmodule Pinchflat.Workers.MediaIndexingWorkerTest do
end end
test "it creates the basic media_item records" do test "it creates the basic media_item records" do
expect(YtDlpRunnerMock, :run, 1, fn _url, _opts -> {:ok, "video1\nvideo2"} end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "video1\nvideo2"} end)
channel = channel_fixture(index_frequency_minutes: 10) channel = channel_fixture(index_frequency_minutes: 10)

View file

@ -0,0 +1,59 @@
defmodule Pinchflat.Workers.VideoDownloadWorkerTest do
use Pinchflat.DataCase
import Mox
import Pinchflat.MediaFixtures
alias Pinchflat.Workers.VideoDownloadWorker
setup :verify_on_exit!
setup do
media_item =
Repo.preload(
media_item_fixture(%{video_filepath: nil}),
[:metadata, channel: :media_profile]
)
{:ok, %{media_item: media_item}}
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 ->
{:ok, render_metadata(:media_metadata)}
end)
assert media_item.video_filepath == nil
perform_job(VideoDownloadWorker, %{id: media_item.id})
assert Repo.reload(media_item).video_filepath != nil
end
test "it saves the metadata to the media_item", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, render_metadata(:media_metadata)}
end)
assert media_item.metadata == nil
perform_job(VideoDownloadWorker, %{id: media_item.id})
assert Repo.reload(media_item).metadata != nil
end
test "it won't double-schedule downloading jobs", %{media_item: media_item} do
Oban.insert(VideoDownloadWorker.new(%{id: media_item.id}))
Oban.insert(VideoDownloadWorker.new(%{id: media_item.id}))
assert [_] = all_enqueued(worker: VideoDownloadWorker)
end
test "it sets the job to retryable if the download fails", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:error, "error"} end)
Oban.Testing.with_testing_mode(:inline, fn ->
{:ok, job} = Oban.insert(VideoDownloadWorker.new(%{id: media_item.id}))
assert job.state == "retryable"
end)
end
end
end

View file

@ -41,7 +41,7 @@ defmodule PinchflatWeb.ChannelControllerTest do
describe "create channel" do describe "create channel" do
test "redirects to show when data is valid", %{conn: conn, create_attrs: create_attrs} do test "redirects to show when data is valid", %{conn: conn, create_attrs: create_attrs} do
expect(YtDlpRunnerMock, :run, 1, &runner_function_mock/2) expect(YtDlpRunnerMock, :run, 1, &runner_function_mock/3)
conn = post(conn, ~p"/media_sources/channels", channel: create_attrs) conn = post(conn, ~p"/media_sources/channels", channel: create_attrs)
assert %{id: id} = redirected_params(conn) assert %{id: id} = redirected_params(conn)
@ -70,7 +70,7 @@ defmodule PinchflatWeb.ChannelControllerTest do
setup [:create_channel] setup [:create_channel]
test "redirects when data is valid", %{conn: conn, channel: channel, update_attrs: update_attrs} do test "redirects when data is valid", %{conn: conn, channel: channel, update_attrs: update_attrs} do
expect(YtDlpRunnerMock, :run, 1, &runner_function_mock/2) expect(YtDlpRunnerMock, :run, 1, &runner_function_mock/3)
conn = put(conn, ~p"/media_sources/channels/#{channel}", channel: update_attrs) conn = put(conn, ~p"/media_sources/channels/#{channel}", channel: update_attrs)
assert redirected_to(conn) == ~p"/media_sources/channels/#{channel}" assert redirected_to(conn) == ~p"/media_sources/channels/#{channel}"
@ -107,7 +107,7 @@ defmodule PinchflatWeb.ChannelControllerTest do
%{channel: channel} %{channel: channel}
end end
defp runner_function_mock(_url, _opts) do defp runner_function_mock(_url, _opts, _ot) do
{ {
:ok, :ok,
Phoenix.json_library().encode!(%{ Phoenix.json_library().encode!(%{

File diff suppressed because one or more lines are too long

View file

@ -22,4 +22,24 @@ defmodule Pinchflat.MediaFixtures do
media_item media_item
end end
@doc """
Generate a media_item with metadata.
"""
def media_item_with_metadata(attrs \\ %{}) do
json_filepath =
Path.join([
Path.dirname(__ENV__.file),
"support",
"fixtures",
"files",
"media_metadata.json"
])
{:ok, file_body} = File.read(json_filepath)
{:ok, parsed_json} = Phoenix.json_library().decode(file_body)
merged_attrs = Map.merge(attrs, %{metadata: %{client_respinse: parsed_json}})
media_item_fixture(merged_attrs)
end
end end

View file

@ -12,7 +12,7 @@ defmodule Pinchflat.ProfilesFixtures do
attrs attrs
|> Enum.into(%{ |> Enum.into(%{
name: "Media Profile ##{:rand.uniform(1_000_000)}", name: "Media Profile ##{:rand.uniform(1_000_000)}",
output_path_template: "/video/{{title}}.{{ext}}" output_path_template: "{{title}}.{{ext}}"
}) })
|> Pinchflat.Profiles.create_media_profile() |> Pinchflat.Profiles.create_media_profile()

View file

@ -1,7 +1,16 @@
#!/bin/bash #!/bin/bash
if [[ "$@" == *"--dump-json"* ]]; then # Args come in the format of "<unknown number of args> --print-to-file <output template> <file location> <unknown number of args>".
echo '{ "args": "'$@'"}' # I need to extract <file location> and write all args to it.
else
echo $@ # Extract the file location (in an unknown position BUT it's 2 args after --print-to-file).
fi for ((i = 1; i <= $#; i++)); do
if [ "${!i}" == "--print-to-file" ]; then
# Extract the file location.
file_location="${@:i+2:1}"
break
fi
done
# Write all args to the file
echo "$@" >"$file_location"

View file

@ -27,4 +27,17 @@ defmodule Pinchflat.TestingHelperMethods do
assert before_res == from assert before_res == from
assert after_res == to assert after_res == to
end end
def render_metadata(metadata_name) do
json_filepath =
Path.join([
File.cwd!(),
"test",
"support",
"files",
"#{metadata_name}.json"
])
File.read!(json_filepath)
end
end end