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:
parent
4252c27f87
commit
480af45527
37 changed files with 1007 additions and 133 deletions
|
|
@ -12,7 +12,7 @@ config :pinchflat,
|
|||
generators: [timestamp_type: :utc_datetime],
|
||||
# Specifying backend data here makes mocking and local testing SUPER easy
|
||||
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
|
||||
media_directory: :not_implemented
|
||||
|
||||
|
|
|
|||
2
ideas.md
2
ideas.md
|
|
@ -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
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
defmodule Pinchflat.Downloader.Backends.BackendCommandRunner do
|
||||
defmodule Pinchflat.MediaClient.Backends.BackendCommandRunner do
|
||||
@moduledoc """
|
||||
A behaviour for running CLI commands against a downloader backend
|
||||
"""
|
||||
32
lib/pinchflat/media_client/backends/yt_dlp/channel.ex
Normal file
32
lib/pinchflat/media_client/backends/yt_dlp/channel.ex
Normal 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
|
||||
|
|
@ -1,16 +1,18 @@
|
|||
defmodule Pinchflat.Downloader.Backends.YtDlp.CommandRunner do
|
||||
defmodule Pinchflat.MediaClient.Backends.YtDlp.CommandRunner do
|
||||
@moduledoc """
|
||||
Runs yt-dlp commands using the `System.cmd/3` function
|
||||
"""
|
||||
|
||||
alias Pinchflat.Utils.StringUtils
|
||||
alias Pinchflat.Downloader.Backends.BackendCommandRunner
|
||||
alias Pinchflat.MediaClient.Backends.BackendCommandRunner
|
||||
|
||||
@behaviour BackendCommandRunner
|
||||
|
||||
@doc """
|
||||
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
|
||||
although possibly not needed (and a LOT easier) if yt-dlp
|
||||
just ignores duplicate options (ie: look into that)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
defmodule Pinchflat.Downloader.Backends.YtDlp.Video do
|
||||
defmodule Pinchflat.MediaClient.Backends.YtDlp.Video do
|
||||
@moduledoc """
|
||||
Contains utilities for working with singular videos
|
||||
"""
|
||||
|
|
@ -6,12 +6,16 @@ defmodule Pinchflat.Downloader.Backends.YtDlp.Video do
|
|||
@doc """
|
||||
Downloads a single video (and possible metadata) directly to its
|
||||
final destination. Returns the parsed JSON output from yt-dlp.
|
||||
|
||||
Returns {:ok, map()} | {:error, any, ...}.
|
||||
"""
|
||||
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
|
||||
{:ok, output} -> Phoenix.json_library().decode(output)
|
||||
with {:ok, output} <- backend_runner().run(url, opts),
|
||||
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
|
||||
{:ok, parsed_json}
|
||||
else
|
||||
err -> err
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
32
lib/pinchflat/media_client/channel_details.ex
Normal file
32
lib/pinchflat/media_client/channel_details.ex
Normal 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
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
defmodule Pinchflat.Downloader.VideoDownloader do
|
||||
defmodule Pinchflat.MediaClient.VideoDownloader do
|
||||
@moduledoc """
|
||||
This is the integration layer for actually downloading videos.
|
||||
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.Downloader.Backends.YtDlp.Video, as: YtDlpVideo
|
||||
alias Pinchflat.MediaClient.Backends.YtDlp.Video, as: YtDlpVideo
|
||||
alias Pinchflat.Profiles.Options.YtDlp.OptionBuilder, as: YtDlpOptionBuilder
|
||||
|
||||
@doc """
|
||||
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
|
||||
option_builder = option_builder(backend)
|
||||
99
lib/pinchflat/media_source.ex
Normal file
99
lib/pinchflat/media_source.ex
Normal 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
|
||||
33
lib/pinchflat/media_source/channel.ex
Normal file
33
lib/pinchflat/media_source/channel.ex
Normal 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
|
||||
|
|
@ -6,10 +6,14 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
|||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Pinchflat.MediaSource.Channel
|
||||
|
||||
schema "media_profiles" do
|
||||
field :name, :string
|
||||
field :output_path_template, :string
|
||||
|
||||
has_many :channels, Channel
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -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) -
|
||||
# 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,
|
||||
[
|
||||
:write_thumbnail,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
14
lib/pinchflat_web/controllers/media_sources/channel_html.ex
Normal file
14
lib/pinchflat_web/controllers/media_sources/channel_html.ex
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -20,6 +20,10 @@ defmodule PinchflatWeb.Router do
|
|||
get "/", PageController, :home
|
||||
|
||||
resources "/media_profiles", MediaProfiles.MediaProfileController
|
||||
|
||||
scope "/media_sources", MediaSources do
|
||||
resources "/channels", ChannelController
|
||||
end
|
||||
end
|
||||
|
||||
# Other scopes may use custom stacks.
|
||||
|
|
|
|||
17
priv/repo/migrations/20240123174417_create_channels.exs
Normal file
17
priv/repo/migrations/20240123174417_create_channels.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
44
test/pinchflat/media_client/backends/yt_dlp/channel_test.exs
Normal file
44
test/pinchflat/media_client/backends/yt_dlp/channel_test.exs
Normal 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
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
defmodule Pinchflat.Downloader.Backends.YtDlp.CommandRunnerTest do
|
||||
defmodule Pinchflat.MediaClient.Backends.YtDlp.CommandRunnerTest do
|
||||
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)
|
||||
@video_url "https://www.youtube.com/watch?v=9bZkp7q19f0"
|
||||
@video_url "https://www.youtube.com/watch?v=-LHXuyzpex0"
|
||||
|
||||
setup do
|
||||
on_exit(&reset_executable/0)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
defmodule Pinchflat.Downloader.Backends.YtDlp.OutputPathBuilderTest do
|
||||
defmodule Pinchflat.MediaClient.Backends.YtDlp.OutputPathBuilderTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Pinchflat.Profiles.Options.YtDlp.OutputPathBuilder
|
||||
|
|
@ -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
|
||||
50
test/pinchflat/media_client/backends/yt_dlp/video_test.exs
Normal file
50
test/pinchflat/media_client/backends/yt_dlp/video_test.exs
Normal 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
|
||||
38
test/pinchflat/media_client/channel_details_test.exs
Normal file
38
test/pinchflat/media_client/channel_details_test.exs
Normal 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
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
defmodule Pinchflat.Downloader.VideoDownloaderTest do
|
||||
defmodule Pinchflat.MediaClient.VideoDownloaderTest do
|
||||
use ExUnit.Case, async: true
|
||||
import Mox
|
||||
|
||||
alias Pinchflat.Profiles.MediaProfile
|
||||
alias Pinchflat.Downloader.VideoDownloader
|
||||
alias Pinchflat.MediaClient.VideoDownloader
|
||||
|
||||
@video_url "https://www.youtube.com/watch?v=TiZPUDkDYbk"
|
||||
@media_profile %MediaProfile{
|
||||
|
|
@ -14,9 +14,9 @@ defmodule Pinchflat.Downloader.VideoDownloaderTest do
|
|||
|
||||
describe "download_for_media_profile/3" 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 :dump_json in opts
|
||||
assert {:print, "%()j"} in opts
|
||||
assert {:output, "/tmp/yt-dlp/videos/%(title)S.%(ext)s"} in opts
|
||||
|
||||
{:ok, "{}"}
|
||||
|
|
@ -26,7 +26,7 @@ defmodule Pinchflat.Downloader.VideoDownloaderTest do
|
|||
end
|
||||
|
||||
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"}} =
|
||||
VideoDownloader.download_for_media_profile(@video_url, @media_profile)
|
||||
220
test/pinchflat/media_source_test.exs
Normal file
220
test/pinchflat/media_source_test.exs
Normal 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
|
||||
119
test/pinchflat_web/controllers/channel_controller_test.exs
Normal file
119
test/pinchflat_web/controllers/channel_controller_test.exs
Normal 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
|
||||
29
test/support/fixtures/media_source_fixtures.ex
Normal file
29
test/support/fixtures/media_source_fixtures.ex
Normal 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
|
||||
|
|
@ -11,8 +11,8 @@ defmodule Pinchflat.ProfilesFixtures do
|
|||
{:ok, media_profile} =
|
||||
attrs
|
||||
|> Enum.into(%{
|
||||
name: "some name",
|
||||
output_path_template: "some output_path_template"
|
||||
name: "Media Profile ##{:rand.uniform(1_000_000)}",
|
||||
output_path_template: "/video/{{title}}.{{ext}}"
|
||||
})
|
||||
|> Pinchflat.Profiles.create_media_profile()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
Mox.defmock(CommandRunnerMock, for: Pinchflat.Downloader.Backends.BackendCommandRunner)
|
||||
Application.put_env(:pinchflat, :yt_dlp_runner, CommandRunnerMock)
|
||||
Mox.defmock(YtDlpRunnerMock, for: Pinchflat.MediaClient.Backends.BackendCommandRunner)
|
||||
Application.put_env(:pinchflat, :yt_dlp_runner, YtDlpRunnerMock)
|
||||
|
||||
ExUnit.start()
|
||||
Ecto.Adapters.SQL.Sandbox.mode(Pinchflat.Repo, :manual)
|
||||
|
|
|
|||
Loading…
Reference in a new issue