Add "channel" type Media Source (#8)

* [WIP] Working on fetching channel metadata in yt-dlp backend

* Finished first draft of methods to do with querying channels

* Renamed CommandRunnerMock to have a more descriptive name

* Ran the phx generator for the channel model

* Renamed Downloader namespace to MediaClient

* [WIP] saving before attempting LiveView

* LiveView did not work out but here's a working controller how about
This commit is contained in:
Kieran 2024-01-24 14:07:39 -08:00 committed by GitHub
parent 4252c27f87
commit 480af45527
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1007 additions and 133 deletions

View file

@ -12,7 +12,7 @@ config :pinchflat,
generators: [timestamp_type: :utc_datetime], generators: [timestamp_type: :utc_datetime],
# 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: System.find_executable("yt-dlp"), yt_dlp_executable: System.find_executable("yt-dlp"),
yt_dlp_runner: Pinchflat.Downloader.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

View file

@ -1,3 +1,3 @@
- Write media datbase ID as metadata/to file/whatever so it gives us an option to retroactively match media to the DB down the line. Useful if someone moves the media without informing the UI - Write media datbase ID as metadata/to file/whatever so it gives us an option to retroactively match media to the DB down the line. Useful if someone moves the media without informing the UI
- Use a UUID for the media database ID (or at least alongside it) - Use a UUID for the media database ID (or at least alongside it)
- Look into this and its recommended plugins https://hexdocs.pm/ex_check/readme.html - Look into this and its recommended plugins https://hexdocs.pm/ex_check/readme.html

View file

@ -1,21 +0,0 @@
defmodule Pinchflat.Downloader.Backends.YtDlp.VideoCollection do
@moduledoc """
Contains utilities for working with collections of videos (ie: channels, playlists)
"""
@doc """
Returns a list of strings representing the video ids in the collection
"""
def get_video_ids(url, command_opts \\ []) do
opts = command_opts ++ [:simulate, :skip_download, :get_id]
case backend_runner().run(url, opts) do
{:ok, output} -> {:ok, String.split(output, "\n", trim: true)}
res -> res
end
end
defp backend_runner do
Application.get_env(:pinchflat, :yt_dlp_runner)
end
end

View file

@ -1,4 +1,4 @@
defmodule Pinchflat.Downloader.Backends.BackendCommandRunner do defmodule Pinchflat.MediaClient.Backends.BackendCommandRunner do
@moduledoc """ @moduledoc """
A behaviour for running CLI commands against a downloader backend A behaviour for running CLI commands against a downloader backend
""" """

View file

@ -0,0 +1,32 @@
defmodule Pinchflat.MediaClient.Backends.YtDlp.Channel do
@moduledoc """
Contains utilities for working with a channel's videos
"""
use Pinchflat.MediaClient.Backends.YtDlp.VideoCollection
alias Pinchflat.MediaClient.ChannelDetails
@doc """
Gets a channel's ID and name from its URL.
yt-dlp does not _really_ have channel-specific functions, so
instead we're fetching just the first video (using playlist_end: 1)
and parsing the channel ID and name from _its_ metadata
Returns {:ok, %ChannelDetails{}} | {:error, any, ...}.
"""
def get_channel_info(channel_url) do
opts = [print: "%(.{channel,channel_id})j", playlist_end: 1]
with {:ok, output} <- backend_runner().run(channel_url, opts),
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
{:ok, ChannelDetails.new(parsed_json["channel_id"], parsed_json["channel"])}
else
err -> err
end
end
defp backend_runner do
Application.get_env(:pinchflat, :yt_dlp_runner)
end
end

View file

@ -1,16 +1,18 @@
defmodule Pinchflat.Downloader.Backends.YtDlp.CommandRunner do defmodule Pinchflat.MediaClient.Backends.YtDlp.CommandRunner do
@moduledoc """ @moduledoc """
Runs yt-dlp commands using the `System.cmd/3` function Runs yt-dlp commands using the `System.cmd/3` function
""" """
alias Pinchflat.Utils.StringUtils alias Pinchflat.Utils.StringUtils
alias Pinchflat.Downloader.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
Returns {:ok, binary()} | {:error, output, status}.
# IDEA: deduplicate command opts, keeping the last one on conflict # IDEA: deduplicate command opts, keeping the last one on conflict
although possibly not needed (and a LOT easier) if yt-dlp although possibly not needed (and a LOT easier) if yt-dlp
just ignores duplicate options (ie: look into that) just ignores duplicate options (ie: look into that)

View file

@ -1,4 +1,4 @@
defmodule Pinchflat.Downloader.Backends.YtDlp.Video do defmodule Pinchflat.MediaClient.Backends.YtDlp.Video do
@moduledoc """ @moduledoc """
Contains utilities for working with singular videos Contains utilities for working with singular videos
""" """
@ -6,12 +6,16 @@ defmodule Pinchflat.Downloader.Backends.YtDlp.Video do
@doc """ @doc """
Downloads a single video (and possible metadata) directly to its Downloads a single video (and possible 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, ...}.
""" """
def download(url, command_opts \\ []) do def download(url, command_opts \\ []) do
opts = [:no_simulate, :dump_json] ++ command_opts opts = [:no_simulate, print: "%()j"] ++ command_opts
case backend_runner().run(url, opts) do with {:ok, output} <- backend_runner().run(url, opts),
{:ok, output} -> Phoenix.json_library().decode(output) {:ok, parsed_json} <- Phoenix.json_library().decode(output) do
{:ok, parsed_json}
else
err -> err err -> err
end end
end end

View file

@ -0,0 +1,28 @@
defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoCollection do
@moduledoc """
Contains utilities for working with collections of videos (ie: channels, playlists).
Meant to be included in other modules but can be used on its own. Channels and playlists
will have many of their own methods, but also share a lot of methods. This module is for
those shared methods.
"""
defmacro __using__(_) do
quote do
@doc """
Returns a list of strings representing the video ids in the collection.
Returns {:ok, [binary()]} | {:error, any, ...}.
"""
def get_video_ids(url, command_opts \\ []) do
runner = Application.get_env(:pinchflat, :yt_dlp_runner)
opts = command_opts ++ [:simulate, :skip_download, print: :id]
case runner.run(url, opts) do
{:ok, output} -> {:ok, String.split(output, "\n", trim: true)}
res -> res
end
end
end
end
end

View file

@ -0,0 +1,32 @@
defmodule Pinchflat.MediaClient.ChannelDetails do
@moduledoc """
This is the integration layer for actually working with channels.
Technically hardcodes the yt-dlp backend for now, but should leave
it open-ish for future expansion (just in case).
"""
@enforce_keys [:id, :name]
defstruct [:id, :name]
alias Pinchflat.MediaClient.Backends.YtDlp.Channel, as: YtDlpChannel
@doc false
def new(id, name) do
%__MODULE__{id: id, name: name}
end
@doc """
Gets a channel's ID and name from its URL, using the given backend.
Returns {:ok, map()} | {:error, any, ...}.
"""
def get_channel_details(channel_url, backend \\ :yt_dlp) do
channel_module(backend).get_channel_info(channel_url)
end
defp channel_module(backend) do
case backend do
:yt_dlp -> YtDlpChannel
end
end
end

View file

@ -1,4 +1,4 @@
defmodule Pinchflat.Downloader.VideoDownloader do defmodule Pinchflat.MediaClient.VideoDownloader do
@moduledoc """ @moduledoc """
This is the integration layer for actually downloading videos. This is the integration layer for actually downloading videos.
It takes into account the media profile's settings in order It takes into account the media profile's settings in order
@ -10,11 +10,13 @@ defmodule Pinchflat.Downloader.VideoDownloader do
alias Pinchflat.Profiles.MediaProfile alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.Downloader.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
@doc """ @doc """
Downloads a single video based on the settings in the given media profile. Downloads a single video based on the settings in the given media profile.
Returns {:ok, %ChannelDetails{}} | {:error, any, ...}.
""" """
def download_for_media_profile(url, %MediaProfile{} = media_profile, backend \\ :yt_dlp) do def download_for_media_profile(url, %MediaProfile{} = media_profile, backend \\ :yt_dlp) do
option_builder = option_builder(backend) option_builder = option_builder(backend)

View file

@ -0,0 +1,99 @@
defmodule Pinchflat.MediaSource do
@moduledoc """
The MediaSource context.
"""
import Ecto.Query, warn: false
alias Pinchflat.Repo
alias Pinchflat.MediaSource.Channel
alias Pinchflat.MediaClient.ChannelDetails
@doc """
Returns the list of channels. Returns [%Channel{}, ...]
"""
def list_channels do
Repo.all(Channel)
end
@doc """
Gets a single channel.
Returns %Channel{}. Raises `Ecto.NoResultsError` if the Channel does not exist.
"""
def get_channel!(id), do: Repo.get!(Channel, id)
@doc """
Creates a channel. Returns {:ok, %Channel{}} | {:error, %Ecto.Changeset{}}
"""
def create_channel(attrs \\ %{}) do
%Channel{}
|> change_channel_from_url(attrs)
|> Repo.insert()
end
@doc """
Updates a channel. Returns {:ok, %Channel{}} | {:error, %Ecto.Changeset{}}
"""
def update_channel(%Channel{} = channel, attrs) do
channel
|> change_channel_from_url(attrs)
|> Repo.update()
end
@doc """
Deletes a channel. Returns {:ok, %Channel{}} | {:error, %Ecto.Changeset{}}
"""
def delete_channel(%Channel{} = channel) do
Repo.delete(channel)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking channel changes.
"""
def change_channel(%Channel{} = channel, attrs \\ %{}) do
Channel.changeset(channel, attrs)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking channel changes and additionally
fetches channel details from the original_url (if provided). If the channel
details cannot be fetched, an error is added to the changeset.
Note that this fetches channel details as long as the `original_url` is present.
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.
"""
def change_channel_from_url(%Channel{} = channel, attrs \\ %{}) do
case change_channel(channel, attrs) do
%Ecto.Changeset{changes: %{original_url: _}} = changeset ->
add_channel_details_to_changeset(channel, changeset)
changeset ->
changeset
end
end
defp add_channel_details_to_changeset(channel, changeset) do
%Ecto.Changeset{changes: changes} = changeset
case ChannelDetails.get_channel_details(changes.original_url) do
{:ok, %ChannelDetails{} = channel_details} ->
change_channel(
channel,
Map.merge(changes, %{
name: channel_details.name,
channel_id: channel_details.id
})
)
{:error, runner_error, _status_code} ->
Ecto.Changeset.add_error(
changeset,
:original_url,
"could not fetch channel details from URL",
error: runner_error
)
end
end
end

View file

@ -0,0 +1,33 @@
defmodule Pinchflat.MediaSource.Channel do
@moduledoc """
The Channel schema.
"""
use Ecto.Schema
import Ecto.Changeset
alias Pinchflat.Profiles.MediaProfile
@required_fields ~w(name channel_id original_url media_profile_id)a
@allowed_fields @required_fields
schema "channels" do
field :name, :string
field :channel_id, :string
# This should only be used for user reference going forward
# as the channel_id should be used for all API calls
field :original_url, :string
belongs_to :media_profile, MediaProfile
timestamps(type: :utc_datetime)
end
@doc false
def changeset(channel, attrs) do
channel
|> cast(attrs, @allowed_fields)
|> validate_required(@required_fields)
|> unique_constraint([:channel_id, :media_profile_id])
end
end

View file

@ -6,10 +6,14 @@ defmodule Pinchflat.Profiles.MediaProfile do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias Pinchflat.MediaSource.Channel
schema "media_profiles" do schema "media_profiles" do
field :name, :string field :name, :string
field :output_path_template, :string field :output_path_template, :string
has_many :channels, Channel
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end

View file

@ -20,6 +20,8 @@ defmodule Pinchflat.Profiles.Options.YtDlp.OptionBuilder do
# NOTE: I'll be hardcoding most things for now (esp. options to help me test) - # NOTE: I'll be hardcoding most things for now (esp. options to help me test) -
# add more configuration later as I build out the models. Walk before you can run! # add more configuration later as I build out the models. Walk before you can run!
# NOTE: Looks like you can put different media types in different directories.
# see: https://github.com/yt-dlp/yt-dlp#output-template
{:ok, {:ok,
[ [
:write_thumbnail, :write_thumbnail,

View file

@ -0,0 +1,75 @@
defmodule PinchflatWeb.MediaSources.ChannelController do
use PinchflatWeb, :controller
alias Pinchflat.Profiles
alias Pinchflat.MediaSource
alias Pinchflat.MediaSource.Channel
def index(conn, _params) do
channels = MediaSource.list_channels()
render(conn, :index, channels: channels)
end
def new(conn, _params) do
changeset = MediaSource.change_channel(%Channel{})
render(conn, :new, changeset: changeset, media_profiles: media_profiles())
end
def create(conn, %{"channel" => channel_params}) do
case MediaSource.create_channel(channel_params) do
{:ok, channel} ->
conn
|> put_flash(:info, "Channel created successfully.")
|> redirect(to: ~p"/media_sources/channels/#{channel}")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new, changeset: changeset, media_profiles: media_profiles())
end
end
def show(conn, %{"id" => id}) do
channel = MediaSource.get_channel!(id)
render(conn, :show, channel: channel)
end
def edit(conn, %{"id" => id}) do
channel = MediaSource.get_channel!(id)
changeset = MediaSource.change_channel(channel)
render(conn, :edit, channel: channel, changeset: changeset, media_profiles: media_profiles())
end
def update(conn, %{"id" => id, "channel" => channel_params}) do
channel = MediaSource.get_channel!(id)
case MediaSource.update_channel(channel, channel_params) do
{:ok, channel} ->
conn
|> put_flash(:info, "Channel updated successfully.")
|> redirect(to: ~p"/media_sources/channels/#{channel}")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :edit,
channel: channel,
changeset: changeset,
media_profiles: media_profiles()
)
end
end
def delete(conn, %{"id" => id}) do
channel = MediaSource.get_channel!(id)
{:ok, _channel} = MediaSource.delete_channel(channel)
conn
|> put_flash(:info, "Channel deleted successfully.")
|> redirect(to: ~p"/media_sources/channels")
end
defp media_profiles do
Profiles.list_media_profiles()
end
end

View file

@ -0,0 +1,14 @@
defmodule PinchflatWeb.MediaSources.ChannelHTML do
use PinchflatWeb, :html
embed_templates "channel_html/*"
@doc """
Renders a channel form.
"""
attr :changeset, Ecto.Changeset, required: true
attr :action, :string, required: true
attr :media_profiles, :list, required: true
def channel_form(assigns)
end

View file

@ -0,0 +1,18 @@
<.simple_form :let={f} for={@changeset} action={@action}>
<.error :if={@changeset.action}>
Oops, something went wrong! Please check the errors below.
</.error>
<.input
field={f[:media_profile_id]}
options={Enum.map(@media_profiles, &{&1.name, &1.id})}
type="select"
label="Media Profile"
/>
<.input field={f[:original_url]} type="text" label="Channel URL" />
<:actions>
<.button>Save Channel</.button>
</:actions>
</.simple_form>

View file

@ -0,0 +1,12 @@
<.header>
Edit Channel <%= @channel.id %>
<:subtitle>Use this form to manage channel records in your database.</:subtitle>
</.header>
<.channel_form
changeset={@changeset}
media_profiles={@media_profiles}
action={~p"/media_sources/channels/#{@channel}"}
/>
<.back navigate={~p"/media_sources/channels"}>Back to channels</.back>

View file

@ -0,0 +1,28 @@
<.header>
Listing Channels
<:actions>
<.link href={~p"/media_sources/channels/new"}>
<.button>New Channel</.button>
</.link>
</:actions>
</.header>
<.table id="channels" rows={@channels} row_click={&JS.navigate(~p"/media_sources/channels/#{&1}")}>
<:col :let={channel} label="Name"><%= channel.name %></:col>
<:col :let={channel} label="Channel"><%= channel.channel_id %></:col>
<:action :let={channel}>
<div class="sr-only">
<.link navigate={~p"/media_sources/channels/#{channel}"}>Show</.link>
</div>
<.link navigate={~p"/media_sources/channels/#{channel}/edit"}>Edit</.link>
</:action>
<:action :let={channel}>
<.link
href={~p"/media_sources/channels/#{channel}"}
method="delete"
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>

View file

@ -0,0 +1,12 @@
<.header>
New Channel
<:subtitle>Use this form to manage channel records in your database.</:subtitle>
</.header>
<.channel_form
changeset={@changeset}
media_profiles={@media_profiles}
action={~p"/media_sources/channels"}
/>
<.back navigate={~p"/media_sources/channels"}>Back to channels</.back>

View file

@ -0,0 +1,17 @@
<.header>
Channel <%= @channel.id %>
<:subtitle>This is a channel record from your database.</:subtitle>
<:actions>
<.link href={~p"/media_sources/channels/#{@channel}/edit"}>
<.button>Edit channel</.button>
</.link>
</:actions>
</.header>
<.list>
<:item title="Channel Name"><%= @channel.name %></:item>
<:item title="Channel ID"><%= @channel.channel_id %></:item>
<:item title="Original URL"><%= @channel.original_url %></:item>
</.list>
<.back navigate={~p"/media_sources/channels"}>Back to channels</.back>

View file

@ -20,6 +20,10 @@ defmodule PinchflatWeb.Router do
get "/", PageController, :home get "/", PageController, :home
resources "/media_profiles", MediaProfiles.MediaProfileController resources "/media_profiles", MediaProfiles.MediaProfileController
scope "/media_sources", MediaSources do
resources "/channels", ChannelController
end
end end
# Other scopes may use custom stacks. # Other scopes may use custom stacks.

View file

@ -0,0 +1,17 @@
defmodule Pinchflat.Repo.Migrations.CreateChannels do
use Ecto.Migration
def change do
create table(:channels) do
add :name, :string, null: false
add :channel_id, :string, null: false
add :original_url, :string, null: false
add :media_profile_id, references(:media_profiles, on_delete: :restrict), null: false
timestamps(type: :utc_datetime)
end
create index(:channels, [:media_profile_id])
create unique_index(:channels, [:channel_id, :media_profile_id])
end
end

View file

@ -1,44 +0,0 @@
defmodule Pinchflat.Downloader.Backends.YtDlp.VideoCollectionTest do
use ExUnit.Case, async: true
import Mox
alias Pinchflat.Downloader.Backends.YtDlp.VideoCollection
@channel_url "https://www.youtube.com/@TheUselessTrials"
setup :verify_on_exit!
describe "get_video_ids/2" do
test "returns a list of video ids with no blank elements" do
expect(CommandRunnerMock, :run, fn _url, _opts -> {:ok, "id1\nid2\n\nid3\n"} end)
assert {:ok, ["id1", "id2", "id3"]} = VideoCollection.get_video_ids(@channel_url)
end
test "it passes the expected default args" do
expect(CommandRunnerMock, :run, fn _url, opts ->
assert opts == [:simulate, :skip_download, :get_id]
{:ok, ""}
end)
assert {:ok, _} = VideoCollection.get_video_ids(@channel_url)
end
test "it passes the expected custom args" do
expect(CommandRunnerMock, :run, fn _url, opts ->
assert opts == [:custom_arg, :simulate, :skip_download, :get_id]
{:ok, ""}
end)
assert {:ok, _} = VideoCollection.get_video_ids(@channel_url, [:custom_arg])
end
test "returns the error straight through when the command fails" do
expect(CommandRunnerMock, :run, fn _url, _opts -> {:error, "Big issue", 1} end)
assert {:error, "Big issue", 1} = VideoCollection.get_video_ids(@channel_url)
end
end
end

View file

@ -1,44 +0,0 @@
defmodule Pinchflat.Downloader.Backends.YtDlp.VideoTest do
use ExUnit.Case, async: true
import Mox
alias Pinchflat.Downloader.Backends.YtDlp.Video
@video_url "https://www.youtube.com/watch?v=TiZPUDkDYbk"
setup :verify_on_exit!
describe "download/2" do
test "it calls the backend runner with the expected arguments" do
expect(CommandRunnerMock, :run, fn @video_url, opts ->
assert opts == [:no_simulate, :dump_json]
{:ok, "{}"}
end)
assert {:ok, _} = Video.download(@video_url)
end
test "it passes along additional options" do
expect(CommandRunnerMock, :run, fn _url, opts ->
assert opts == [:no_simulate, :dump_json, :custom_arg]
{:ok, "{}"}
end)
assert {:ok, _} = Video.download(@video_url, [:custom_arg])
end
test "it parses the output as JSON" do
expect(CommandRunnerMock, :run, fn _url, _opts -> {:ok, "{\"title\": \"Test\"}"} end)
assert {:ok, %{"title" => "Test"}} = Video.download(@video_url)
end
test "it directly passes along any errors" do
expect(CommandRunnerMock, :run, fn _url, _opts -> {:error, "Big issue", 1} end)
assert {:error, "Big issue", 1} = Video.download(@video_url)
end
end
end

View file

@ -0,0 +1,44 @@
defmodule Pinchflat.MediaClient.Backends.YtDlp.ChannelTest do
use ExUnit.Case, async: true
import Mox
alias Pinchflat.MediaClient.ChannelDetails
alias Pinchflat.MediaClient.Backends.YtDlp.Channel
@channel_url "https://www.youtube.com/c/TheUselessTrials"
setup :verify_on_exit!
describe "get_channel_info/1" do
test "it returns a %ChannelDetails{} with data on success" do
expect(YtDlpRunnerMock, :run, fn _url, _opts ->
{:ok, "{\"channel\": \"TheUselessTrials\", \"channel_id\": \"UCQH2\"}"}
end)
assert {:ok, res} = Channel.get_channel_info(@channel_url)
assert %ChannelDetails{id: "UCQH2", name: "TheUselessTrials"} = res
end
test "it passes the expected args to the backend runner" do
expect(YtDlpRunnerMock, :run, fn @channel_url, opts ->
assert opts == [{:print, "%(.{channel,channel_id})j"}, {:playlist_end, 1}]
{:ok, "{}"}
end)
assert {:ok, _} = Channel.get_channel_info(@channel_url)
end
test "it returns an error if the runner returns an error" do
expect(YtDlpRunnerMock, :run, fn _url, _opts -> {:error, "Big issue", 1} end)
assert {:error, "Big issue", 1} = Channel.get_channel_info(@channel_url)
end
test "it returns an error if the output is not JSON" do
expect(YtDlpRunnerMock, :run, fn _url, _opts -> {:ok, "Not JSON"} end)
assert {:error, %Jason.DecodeError{}} = Channel.get_channel_info(@channel_url)
end
end
end

View file

@ -1,10 +1,10 @@
defmodule Pinchflat.Downloader.Backends.YtDlp.CommandRunnerTest do defmodule Pinchflat.MediaClient.Backends.YtDlp.CommandRunnerTest do
use ExUnit.Case, async: true use ExUnit.Case, async: true
alias Pinchflat.Downloader.Backends.YtDlp.CommandRunner, as: Runner alias Pinchflat.MediaClient.Backends.YtDlp.CommandRunner, as: Runner
@original_executable Application.compile_env(:pinchflat, :yt_dlp_executable) @original_executable Application.compile_env(:pinchflat, :yt_dlp_executable)
@video_url "https://www.youtube.com/watch?v=9bZkp7q19f0" @video_url "https://www.youtube.com/watch?v=-LHXuyzpex0"
setup do setup do
on_exit(&reset_executable/0) on_exit(&reset_executable/0)

View file

@ -1,4 +1,4 @@
defmodule Pinchflat.Downloader.Backends.YtDlp.OutputPathBuilderTest do defmodule Pinchflat.MediaClient.Backends.YtDlp.OutputPathBuilderTest do
use ExUnit.Case, async: true use ExUnit.Case, async: true
alias Pinchflat.Profiles.Options.YtDlp.OutputPathBuilder alias Pinchflat.Profiles.Options.YtDlp.OutputPathBuilder

View file

@ -0,0 +1,48 @@
defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoCollectionTest do
use ExUnit.Case, async: true
import Mox
alias Pinchflat.MediaClient.Backends.YtDlp.VideoCollection
@channel_url "https://www.youtube.com/@TheUselessTrials"
defmodule VideoCollectionUser do
use VideoCollection
end
setup :verify_on_exit!
describe "get_video_ids/2" 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)
assert {:ok, ["id1", "id2", "id3"]} = VideoCollectionUser.get_video_ids(@channel_url)
end
test "it passes the expected default args" do
expect(YtDlpRunnerMock, :run, fn _url, opts ->
assert opts == [:simulate, :skip_download, {:print, :id}]
{:ok, ""}
end)
assert {:ok, _} = VideoCollectionUser.get_video_ids(@channel_url)
end
test "it passes the expected custom args" do
expect(YtDlpRunnerMock, :run, fn _url, opts ->
assert opts == [:custom_arg, :simulate, :skip_download, {:print, :id}]
{:ok, ""}
end)
assert {:ok, _} = VideoCollectionUser.get_video_ids(@channel_url, [:custom_arg])
end
test "returns the error straight through when the command fails" do
expect(YtDlpRunnerMock, :run, fn _url, _opts -> {:error, "Big issue", 1} end)
assert {:error, "Big issue", 1} = VideoCollectionUser.get_video_ids(@channel_url)
end
end
end

View file

@ -0,0 +1,50 @@
defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoTest do
use ExUnit.Case, async: true
import Mox
alias Pinchflat.MediaClient.Backends.YtDlp.Video
@video_url "https://www.youtube.com/watch?v=TiZPUDkDYbk"
setup :verify_on_exit!
describe "download/2" do
test "it calls the backend runner with the expected arguments" do
expect(YtDlpRunnerMock, :run, fn @video_url, opts ->
assert opts == [:no_simulate, {:print, "%()j"}]
{:ok, "{}"}
end)
assert {:ok, _} = Video.download(@video_url)
end
test "it passes along additional options" do
expect(YtDlpRunnerMock, :run, fn _url, opts ->
assert opts == [:no_simulate, {:print, "%()j"}, :custom_arg]
{:ok, "{}"}
end)
assert {:ok, _} = Video.download(@video_url, [:custom_arg])
end
test "it parses the output as JSON" do
expect(YtDlpRunnerMock, :run, fn _url, _opts -> {:ok, "{\"title\": \"Test\"}"} end)
assert {:ok, %{"title" => "Test"}} = Video.download(@video_url)
end
test "it returns an error if the output is not JSON" do
expect(YtDlpRunnerMock, :run, fn _url, _opts -> {:ok, "Not JSON"} end)
assert {:error, %Jason.DecodeError{}} = 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

View file

@ -0,0 +1,38 @@
defmodule Pinchflat.MediaClient.ChannelDetailsTest do
use ExUnit.Case, async: true
import Mox
alias Pinchflat.MediaClient.ChannelDetails
@channel_url "https://www.youtube.com/c/TheUselessTrials"
setup :verify_on_exit!
describe "new/2" do
test "it returns a struct with the given values" do
assert %ChannelDetails{id: "UCQH2", name: "TheUselessTrials"} =
ChannelDetails.new("UCQH2", "TheUselessTrials")
end
end
describe "get_channel_details/2" do
test "it passes the expected arguments to the backend" do
expect(YtDlpRunnerMock, :run, fn @channel_url, opts ->
assert opts == [{:print, "%(.{channel,channel_id})j"}, {:playlist_end, 1}]
{:ok, "{\"channel\": \"TheUselessTrials\", \"channel_id\": \"UCQH2\"}"}
end)
assert {:ok, _} = ChannelDetails.get_channel_details(@channel_url)
end
test "it returns a struct composed of the returned data" do
expect(YtDlpRunnerMock, :run, fn _url, _opts ->
{:ok, "{\"channel\": \"TheUselessTrials\", \"channel_id\": \"UCQH2\"}"}
end)
assert {:ok, res} = ChannelDetails.get_channel_details(@channel_url)
assert %ChannelDetails{id: "UCQH2", name: "TheUselessTrials"} = res
end
end
end

View file

@ -1,9 +1,9 @@
defmodule Pinchflat.Downloader.VideoDownloaderTest do defmodule Pinchflat.MediaClient.VideoDownloaderTest do
use ExUnit.Case, async: true use ExUnit.Case, async: true
import Mox import Mox
alias Pinchflat.Profiles.MediaProfile alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.Downloader.VideoDownloader alias Pinchflat.MediaClient.VideoDownloader
@video_url "https://www.youtube.com/watch?v=TiZPUDkDYbk" @video_url "https://www.youtube.com/watch?v=TiZPUDkDYbk"
@media_profile %MediaProfile{ @media_profile %MediaProfile{
@ -14,9 +14,9 @@ defmodule Pinchflat.Downloader.VideoDownloaderTest do
describe "download_for_media_profile/3" do describe "download_for_media_profile/3" do
test "it calls the backend runner with the arguments built from the media profile" do test "it calls the backend runner with the arguments built from the media profile" do
expect(CommandRunnerMock, :run, fn @video_url, opts -> expect(YtDlpRunnerMock, :run, fn @video_url, opts ->
assert :no_simulate in opts assert :no_simulate in opts
assert :dump_json in opts assert {:print, "%()j"} in opts
assert {:output, "/tmp/yt-dlp/videos/%(title)S.%(ext)s"} in opts assert {:output, "/tmp/yt-dlp/videos/%(title)S.%(ext)s"} in opts
{:ok, "{}"} {:ok, "{}"}
@ -26,7 +26,7 @@ defmodule Pinchflat.Downloader.VideoDownloaderTest do
end end
test "it returns the parsed JSON output" do test "it returns the parsed JSON output" do
expect(CommandRunnerMock, :run, fn _url, _opts -> {:ok, "{\"title\": \"Test\"}"} end) expect(YtDlpRunnerMock, :run, fn _url, _opts -> {:ok, "{\"title\": \"Test\"}"} end)
assert {:ok, %{"title" => "Test"}} = assert {:ok, %{"title" => "Test"}} =
VideoDownloader.download_for_media_profile(@video_url, @media_profile) VideoDownloader.download_for_media_profile(@video_url, @media_profile)

View file

@ -0,0 +1,220 @@
defmodule Pinchflat.MediaSourceTest do
use Pinchflat.DataCase
import Mox
alias Pinchflat.MediaSource
alias Pinchflat.MediaSource.Channel
import Pinchflat.ProfilesFixtures
import Pinchflat.MediaSourceFixtures
@invalid_channel_attrs %{name: nil, channel_id: nil}
setup :verify_on_exit!
describe "list_channels/0" do
test "it returns all channels" do
channel = channel_fixture()
assert MediaSource.list_channels() == [channel]
end
end
describe "get_channel!/1" do
test "it returns the channel with given id" do
channel = channel_fixture()
assert MediaSource.get_channel!(channel.id) == channel
end
end
describe "create_channel/1" do
test "creates a channel and adds name + ID from runner response" do
expect(YtDlpRunnerMock, :run, &runner_function_mock/2)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
original_url: "https://www.youtube.com/channel/abc123"
}
assert {:ok, %Channel{} = channel} = MediaSource.create_channel(valid_attrs)
assert channel.name == "some name"
assert String.starts_with?(channel.channel_id, "some_channel_id_")
end
test "creation with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = MediaSource.create_channel(@invalid_channel_attrs)
end
test "creation enforces uniqueness of channel_id scoped to the media_profile" do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts ->
{:ok,
Phoenix.json_library().encode!(%{
channel: "some name",
channel_id: "some_channel_id_12345678"
})}
end)
valid_once_attrs = %{
media_profile_id: media_profile_fixture().id,
original_url: "https://www.youtube.com/channel/abc123"
}
assert {:ok, %Channel{}} = MediaSource.create_channel(valid_once_attrs)
assert {:error, %Ecto.Changeset{}} = MediaSource.create_channel(valid_once_attrs)
end
test "creation lets you duplicate channel_ids as long as the media profile is different" do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts ->
{:ok,
Phoenix.json_library().encode!(%{
channel: "some name",
channel_id: "some_channel_id_12345678"
})}
end)
valid_attrs = %{
name: "some name",
original_url: "https://www.youtube.com/channel/abc123"
}
channel_1_attrs = Map.merge(valid_attrs, %{media_profile_id: media_profile_fixture().id})
channel_2_attrs = Map.merge(valid_attrs, %{media_profile_id: media_profile_fixture().id})
assert {:ok, %Channel{}} = MediaSource.create_channel(channel_1_attrs)
assert {:ok, %Channel{}} = MediaSource.create_channel(channel_2_attrs)
end
end
describe "update_channel/2" do
test "updates with valid data updates the channel" do
channel = channel_fixture()
update_attrs = %{name: "some updated name"}
assert {:ok, %Channel{} = channel} = MediaSource.update_channel(channel, update_attrs)
assert channel.name == "some updated name"
end
test "updating the original_url will re-fetch the channel details" do
expect(YtDlpRunnerMock, :run, &runner_function_mock/2)
channel = channel_fixture()
update_attrs = %{original_url: "https://www.youtube.com/channel/abc123"}
assert {:ok, %Channel{} = channel} = MediaSource.update_channel(channel, update_attrs)
assert channel.name == "some name"
assert String.starts_with?(channel.channel_id, "some_channel_id_")
end
test "not updating the original_url will not re-fetch the channel details" do
expect(YtDlpRunnerMock, :run, 0, &runner_function_mock/2)
channel = channel_fixture()
update_attrs = %{name: "some updated name"}
assert {:ok, %Channel{}} = MediaSource.update_channel(channel, update_attrs)
end
test "updates with invalid data returns error changeset" do
channel = channel_fixture()
assert {:error, %Ecto.Changeset{}} =
MediaSource.update_channel(channel, @invalid_channel_attrs)
assert channel == MediaSource.get_channel!(channel.id)
end
end
describe "delete_channel/1" do
test "it deletes the channel" do
channel = channel_fixture()
assert {:ok, %Channel{}} = MediaSource.delete_channel(channel)
assert_raise Ecto.NoResultsError, fn -> MediaSource.get_channel!(channel.id) end
end
test "it returns a channel changeset" do
channel = channel_fixture()
assert %Ecto.Changeset{} = MediaSource.change_channel(channel)
end
end
describe "change_channel/2" do
test "it returns a changeset" do
channel = channel_fixture()
assert %Ecto.Changeset{} = MediaSource.change_channel(channel)
end
end
describe "change_channel_from_url/2" do
test "it returns a changeset" do
stub(YtDlpRunnerMock, :run, &runner_function_mock/2)
channel = channel_fixture()
assert %Ecto.Changeset{} = MediaSource.change_channel_from_url(channel)
end
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)
changeset = MediaSource.change_channel_from_url(%Channel{}, %{name: "some updated name"})
assert %Ecto.Changeset{} = changeset
end
test "it fetches channel details if the original_url is in the changeset" do
expect(YtDlpRunnerMock, :run, &runner_function_mock/2)
changeset =
MediaSource.change_channel_from_url(%Channel{}, %{
original_url: "https://www.youtube.com/channel/abc123"
})
assert %Ecto.Changeset{} = changeset
end
test "it adds channel details to the changeset, keeping the orignal details" do
expect(YtDlpRunnerMock, :run, &runner_function_mock/2)
media_profile = media_profile_fixture()
media_profile_id = media_profile.id
changeset =
MediaSource.change_channel_from_url(%Channel{}, %{
original_url: "https://www.youtube.com/channel/abc123",
media_profile_id: media_profile.id
})
assert %Ecto.Changeset{} = changeset
assert String.starts_with?(changeset.changes.channel_id, "some_channel_id_")
assert %{
name: "some 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 ->
{:error, "some error", 1}
end)
changeset =
MediaSource.change_channel_from_url(%Channel{}, %{
original_url: "https://www.youtube.com/channel/abc123"
})
assert %Ecto.Changeset{} = changeset
assert errors_on(changeset).original_url == ["could not fetch channel details from URL"]
end
end
defp runner_function_mock(_url, _opts) do
{
:ok,
Phoenix.json_library().encode!(%{
channel: "some name",
channel_id: "some_channel_id_#{:rand.uniform(1_000_000)}"
})
}
end
end

View file

@ -0,0 +1,119 @@
defmodule PinchflatWeb.ChannelControllerTest do
use PinchflatWeb.ConnCase
import Mox
import Pinchflat.ProfilesFixtures
import Pinchflat.MediaSourceFixtures
setup do
media_profile = media_profile_fixture()
{
:ok,
%{
create_attrs: %{
media_profile_id: media_profile.id,
original_url: "https://www.youtube.com/channel/abc123"
},
update_attrs: %{
original_url: "https://www.youtube.com/channel/321xyz"
},
invalid_attrs: %{original_url: nil, media_profile_id: nil}
}
}
end
setup :verify_on_exit!
describe "index" do
test "lists all channels", %{conn: conn} do
conn = get(conn, ~p"/media_sources/channels")
assert html_response(conn, 200) =~ "Listing Channels"
end
end
describe "new channel" do
test "renders form", %{conn: conn} do
conn = get(conn, ~p"/media_sources/channels/new")
assert html_response(conn, 200) =~ "New Channel"
end
end
describe "create channel" do
test "redirects to show when data is valid", %{conn: conn, create_attrs: create_attrs} do
expect(YtDlpRunnerMock, :run, 1, &runner_function_mock/2)
conn = post(conn, ~p"/media_sources/channels", channel: create_attrs)
assert %{id: id} = redirected_params(conn)
assert redirected_to(conn) == ~p"/media_sources/channels/#{id}"
conn = get(conn, ~p"/media_sources/channels/#{id}")
assert html_response(conn, 200) =~ "Channel #{id}"
end
test "renders errors when data is invalid", %{conn: conn, invalid_attrs: invalid_attrs} do
conn = post(conn, ~p"/media_sources/channels", channel: invalid_attrs)
assert html_response(conn, 200) =~ "New Channel"
end
end
describe "edit channel" do
setup [:create_channel]
test "renders form for editing chosen channel", %{conn: conn, channel: channel} do
conn = get(conn, ~p"/media_sources/channels/#{channel}/edit")
assert html_response(conn, 200) =~ "Edit Channel"
end
end
describe "update channel" do
setup [:create_channel]
test "redirects when data is valid", %{conn: conn, channel: channel, update_attrs: update_attrs} do
expect(YtDlpRunnerMock, :run, 1, &runner_function_mock/2)
conn = put(conn, ~p"/media_sources/channels/#{channel}", channel: update_attrs)
assert redirected_to(conn) == ~p"/media_sources/channels/#{channel}"
conn = get(conn, ~p"/media_sources/channels/#{channel}")
assert html_response(conn, 200) =~ "https://www.youtube.com/channel/321xyz"
end
test "renders errors when data is invalid", %{
conn: conn,
channel: channel,
invalid_attrs: invalid_attrs
} do
conn = put(conn, ~p"/media_sources/channels/#{channel}", channel: invalid_attrs)
assert html_response(conn, 200) =~ "Edit Channel"
end
end
describe "delete channel" do
setup [:create_channel]
test "deletes chosen channel", %{conn: conn, channel: channel} do
conn = delete(conn, ~p"/media_sources/channels/#{channel}")
assert redirected_to(conn) == ~p"/media_sources/channels"
assert_error_sent 404, fn ->
get(conn, ~p"/media_sources/channels/#{channel}")
end
end
end
defp create_channel(_) do
channel = channel_fixture()
%{channel: channel}
end
defp runner_function_mock(_url, _opts) do
{
:ok,
Phoenix.json_library().encode!(%{
channel: "some name",
channel_id: "some_channel_id_#{:rand.uniform(1_000_000)}"
})
}
end
end

View file

@ -0,0 +1,29 @@
defmodule Pinchflat.MediaSourceFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `Pinchflat.MediaSource` context.
"""
alias Pinchflat.Repo
alias Pinchflat.ProfilesFixtures
alias Pinchflat.MediaSource.Channel
@doc """
Generate a channel.
"""
def channel_fixture(attrs \\ %{}) do
{:ok, channel} =
%Channel{}
|> Channel.changeset(
Enum.into(attrs, %{
name: "Channel ##{:rand.uniform(1_000_000)}",
channel_id: Base.encode16(:crypto.hash(:md5, "#{:rand.uniform(1_000_000)}")),
original_url: "https://www.youtube.com/channel/#{:rand.uniform(1_000_000)}",
media_profile_id: ProfilesFixtures.media_profile_fixture().id
})
)
|> Repo.insert()
channel
end
end

View file

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

View file

@ -1,5 +1,5 @@
Mox.defmock(CommandRunnerMock, for: Pinchflat.Downloader.Backends.BackendCommandRunner) Mox.defmock(YtDlpRunnerMock, for: Pinchflat.MediaClient.Backends.BackendCommandRunner)
Application.put_env(:pinchflat, :yt_dlp_runner, CommandRunnerMock) Application.put_env(:pinchflat, :yt_dlp_runner, YtDlpRunnerMock)
ExUnit.start() ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(Pinchflat.Repo, :manual) Ecto.Adapters.SQL.Sandbox.mode(Pinchflat.Repo, :manual)