Change Channel to Source (#12)

* Ensure channel detail lookup doesn't download the video - oops

* Ran the needful migrations for replacing channels with sources

* got media source test back working

* channel tasks test

* Media indexing worker test

* media tests

* Got all tests working except controller tests

* got all tests working

* Renamed Channel struct to Source

* renamed ChannelTasks

* More renaming; renamed channel details module

* Removed Channel yt-dlp module, instead throwing things in the VideoCollection module

* Renamed what looks like the last of the outstanding data
This commit is contained in:
Kieran 2024-02-02 10:45:21 -08:00 committed by GitHub
parent d25e65f822
commit bdef6c75bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 805 additions and 762 deletions

View file

@ -3,7 +3,7 @@ alias Pinchflat.Repo
alias Pinchflat.Tasks.Task alias Pinchflat.Tasks.Task
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Media.MediaMetadata alias Pinchflat.Media.MediaMetadata
alias Pinchflat.MediaSource.Channel alias Pinchflat.MediaSource.Source
alias Pinchflat.Profiles.MediaProfile alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.Tasks alias Pinchflat.Tasks
@ -11,4 +11,4 @@ alias Pinchflat.Media
alias Pinchflat.Profiles alias Pinchflat.Profiles
alias Pinchflat.MediaSource alias Pinchflat.MediaSource
alias Pinchflat.MediaClient.{ChannelDetails, VideoDownloader} alias Pinchflat.MediaClient.{SourceDetails, VideoDownloader}

View file

@ -8,7 +8,7 @@ defmodule Pinchflat.Media do
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.MediaSource.Channel alias Pinchflat.MediaSource.Source
@doc """ @doc """
Returns the list of media_items. Returns [%MediaItem{}, ...]. Returns the list of media_items. Returns [%MediaItem{}, ...].
@ -18,15 +18,15 @@ defmodule Pinchflat.Media do
end end
@doc """ @doc """
Returns a list of pending media_items for a given channel, where Returns a list of pending media_items for a given source, where
pending means the `media_filepath` is `nil`. pending means the `media_filepath` is `nil`.
Returns [%MediaItem{}, ...]. Returns [%MediaItem{}, ...].
""" """
def list_pending_media_items_for(%Channel{} = channel) do def list_pending_media_items_for(%Source{} = source) do
from( from(
m in MediaItem, m in MediaItem,
where: m.channel_id == ^channel.id and is_nil(m.media_filepath) where: m.source_id == ^source.id and is_nil(m.media_filepath)
) )
|> Repo.all() |> Repo.all()
end end

View file

@ -7,11 +7,11 @@ defmodule Pinchflat.Media.MediaItem do
import Ecto.Changeset import Ecto.Changeset
alias Pinchflat.Tasks.Task alias Pinchflat.Tasks.Task
alias Pinchflat.MediaSource.Channel alias Pinchflat.MediaSource.Source
alias Pinchflat.Media.MediaMetadata alias Pinchflat.Media.MediaMetadata
@required_fields ~w(media_id channel_id)a @required_fields ~w(media_id source_id)a
@allowed_fields ~w(title media_id media_filepath channel_id subtitle_filepaths)a @allowed_fields ~w(title media_id media_filepath source_id subtitle_filepaths)a
schema "media_items" do schema "media_items" do
field :title, :string field :title, :string
@ -22,7 +22,7 @@ defmodule Pinchflat.Media.MediaItem do
# Will very likely revisit because I can't leave well-enough alone. # Will very likely revisit because I can't leave well-enough alone.
field :subtitle_filepaths, {:array, {:array, :string}}, default: [] field :subtitle_filepaths, {:array, {:array, :string}}, default: []
belongs_to :channel, Channel belongs_to :source, Source
has_one :metadata, MediaMetadata, on_replace: :update has_one :metadata, MediaMetadata, on_replace: :update
@ -37,6 +37,6 @@ defmodule Pinchflat.Media.MediaItem do
|> cast(attrs, @allowed_fields) |> cast(attrs, @allowed_fields)
|> cast_assoc(:metadata, with: &MediaMetadata.changeset/2, required: false) |> 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, :source_id])
end end
end end

View file

@ -1,32 +0,0 @@
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_details(channel_url) do
opts = [playlist_end: 1]
with {:ok, output} <- backend_runner().run(channel_url, opts, "%(.{channel,channel_id})j"),
{: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,28 +1,47 @@
defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoCollection do defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoCollection do
@moduledoc """ @moduledoc """
Contains utilities for working with collections of videos (ie: channels, playlists). Contains utilities for working with collections of
videos (aka: a source [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 alias Pinchflat.MediaClient.SourceDetails
quote do
@doc """
Returns a list of strings representing the video ids in the collection.
Returns {:ok, [binary()]} | {:error, any, ...}. @doc """
""" Returns a list of strings representing the video ids in the collection.
def get_video_ids(url, command_opts \\ []) do
runner = Application.get_env(:pinchflat, :yt_dlp_runner)
opts = command_opts ++ [:simulate, :skip_download]
case runner.run(url, opts, "%(id)s") do Returns {:ok, [binary()]} | {:error, any, ...}.
{:ok, output} -> {:ok, String.split(output, "\n", trim: true)} """
res -> res def get_video_ids(url, command_opts \\ []) do
end runner = Application.get_env(:pinchflat, :yt_dlp_runner)
end opts = command_opts ++ [:simulate, :skip_download]
case runner.run(url, opts, "%(id)s") do
{:ok, output} -> {:ok, String.split(output, "\n", trim: true)}
res -> res
end end
end end
@doc """
Gets a source's ID and name from its URL.
yt-dlp does not _really_ have source-specific functions, so
instead we're fetching just the first video (using playlist_end: 1)
and parsing the source ID and name from _its_ metadata
Returns {:ok, %SourceDetails{}} | {:error, any, ...}.
"""
def get_source_details(source_url) do
opts = [:skip_download, playlist_end: 1]
with {:ok, output} <- backend_runner().run(source_url, opts, "%(.{channel,channel_id})j"),
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
{:ok, SourceDetails.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 end

View file

@ -1,41 +0,0 @@
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_details(channel_url)
end
@doc """
Returns a list of video IDs for the given channel URL, using the given backend.
Returns {:ok, list(binary())} | {:error, any, ...}.
"""
def get_video_ids(channel_url, backend \\ :yt_dlp) do
channel_module(backend).get_video_ids(channel_url)
end
defp channel_module(backend) do
case backend do
:yt_dlp -> YtDlpChannel
end
end
end

View file

@ -0,0 +1,41 @@
defmodule Pinchflat.MediaClient.SourceDetails do
@moduledoc """
This is the integration layer for actually working with sources.
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.VideoCollection, as: YtDlpSource
@doc false
def new(id, name) do
%__MODULE__{id: id, name: name}
end
@doc """
Gets a source's ID and name from its URL, using the given backend.
Returns {:ok, map()} | {:error, any, ...}.
"""
def get_source_details(source_url, backend \\ :yt_dlp) do
source_module(backend).get_source_details(source_url)
end
@doc """
Returns a list of video IDs for the given source URL, using the given backend.
Returns {:ok, list(binary())} | {:error, any, ...}.
"""
def get_video_ids(source_url, backend \\ :yt_dlp) do
source_module(backend).get_video_ids(source_url)
end
defp source_module(backend) do
case backend do
:yt_dlp -> YtDlpSource
end
end
end

View file

@ -25,8 +25,8 @@ defmodule Pinchflat.MediaClient.VideoDownloader do
Returns {:ok, %MediaItem{}} | {:error, any, ...any} Returns {:ok, %MediaItem{}} | {:error, any, ...any}
""" """
def download_for_media_item(%MediaItem{} = media_item, 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]) item_with_preloads = Repo.preload(media_item, [:metadata, source: :media_profile])
media_profile = item_with_preloads.channel.media_profile media_profile = item_with_preloads.source.media_profile
case download_for_media_profile(media_item.media_id, media_profile, backend) do case download_for_media_profile(media_item.media_id, media_profile, backend) do
{:ok, parsed_json} -> {:ok, parsed_json} ->

View file

@ -8,34 +8,34 @@ defmodule Pinchflat.MediaSource do
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Tasks.ChannelTasks alias Pinchflat.Tasks.SourceTasks
alias Pinchflat.MediaSource.Channel alias Pinchflat.MediaSource.Source
alias Pinchflat.MediaClient.ChannelDetails alias Pinchflat.MediaClient.SourceDetails
@doc """ @doc """
Returns the list of channels. Returns [%Channel{}, ...] Returns the list of sources. Returns [%Source{}, ...]
""" """
def list_channels do def list_sources do
Repo.all(Channel) Repo.all(Source)
end end
@doc """ @doc """
Gets a single channel. Gets a single source.
Returns %Channel{}. Raises `Ecto.NoResultsError` if the Channel does not exist. Returns %Source{}. Raises `Ecto.NoResultsError` if the Source does not exist.
""" """
def get_channel!(id), do: Repo.get!(Channel, id) def get_source!(id), do: Repo.get!(Source, id)
@doc """ @doc """
Creates a channel. May attempt to pull additional channel details from the Creates a source. May attempt to pull additional source details from the
original_url (if provided). Will attempt to start indexing the channel's original_url (if provided). Will attempt to start indexing the source's
media if successfully inserted. media if successfully inserted.
Returns {:ok, %Channel{}} | {:error, %Ecto.Changeset{}} Returns {:ok, %Source{}} | {:error, %Ecto.Changeset{}}
""" """
def create_channel(attrs) do def create_source(attrs) do
%Channel{} %Source{}
|> change_channel_from_url(attrs) |> change_source_from_url(attrs)
|> commit_and_start_indexing() |> commit_and_start_indexing()
end end
@ -45,12 +45,12 @@ defmodule Pinchflat.MediaSource do
Returns [%MediaItem{}, ...] | [%Ecto.Changeset{}, ...] Returns [%MediaItem{}, ...] | [%Ecto.Changeset{}, ...]
""" """
def index_media_items(%Channel{} = channel) do def index_media_items(%Source{} = source) do
{:ok, media_ids} = ChannelDetails.get_video_ids(channel.original_url) {:ok, media_ids} = SourceDetails.get_video_ids(source.original_url)
media_ids media_ids
|> Enum.map(fn media_id -> |> Enum.map(fn media_id ->
attrs = %{channel_id: channel.id, media_id: media_id} attrs = %{source_id: source.id, media_id: media_id}
case Media.create_media_item(attrs) do case Media.create_media_item(attrs) do
{:ok, media_item} -> media_item {:ok, media_item} -> media_item
@ -60,67 +60,67 @@ defmodule Pinchflat.MediaSource do
end end
@doc """ @doc """
Updates a channel. May attempt to pull additional channel details from the Updates a source. May attempt to pull additional source details from the
original_url (if changed). May attempt to start indexing the channel's original_url (if changed). May attempt to start indexing the source's
media if the indexing frequency has been changed. media if the indexing frequency has been changed.
Existing indexing tasks will be cancelled if the indexing frequency has been Existing indexing tasks will be cancelled if the indexing frequency has been
changed (logic in `ChannelTasks.kickoff_indexing_task`) changed (logic in `SourceTasks.kickoff_indexing_task`)
Returns {:ok, %Channel{}} | {:error, %Ecto.Changeset{}} Returns {:ok, %Source{}} | {:error, %Ecto.Changeset{}}
""" """
def update_channel(%Channel{} = channel, attrs) do def update_source(%Source{} = source, attrs) do
channel source
|> change_channel_from_url(attrs) |> change_source_from_url(attrs)
|> commit_and_start_indexing() |> commit_and_start_indexing()
end end
@doc """ @doc """
Deletes a channel and it's associated tasks (of any state). Deletes a source and it's associated tasks (of any state).
Returns {:ok, %Channel{}} | {:error, %Ecto.Changeset{}} Returns {:ok, %Source{}} | {:error, %Ecto.Changeset{}}
""" """
def delete_channel(%Channel{} = channel) do def delete_source(%Source{} = source) do
Tasks.delete_tasks_for(channel) Tasks.delete_tasks_for(source)
Repo.delete(channel) Repo.delete(source)
end end
@doc """ @doc """
Returns an `%Ecto.Changeset{}` for tracking channel changes. Returns an `%Ecto.Changeset{}` for tracking source changes.
""" """
def change_channel(%Channel{} = channel, attrs \\ %{}) do def change_source(%Source{} = source, attrs \\ %{}) do
Channel.changeset(channel, attrs) Source.changeset(source, attrs)
end end
@doc """ @doc """
Returns an `%Ecto.Changeset{}` for tracking channel changes and additionally Returns an `%Ecto.Changeset{}` for tracking source changes and additionally
fetches channel details from the original_url (if provided). If the channel fetches source details from the original_url (if provided). If the source
details cannot be fetched, an error is added to the changeset. 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. Note that this fetches source 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 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_source_from_url(%Source{} = source, attrs) do
case change_channel(channel, attrs) do case change_source(source, attrs) do
%Ecto.Changeset{changes: %{original_url: _}} = changeset -> %Ecto.Changeset{changes: %{original_url: _}} = changeset ->
add_channel_details_to_changeset(channel, changeset) add_source_details_to_changeset(source, changeset)
changeset -> changeset ->
changeset changeset
end end
end end
defp add_channel_details_to_changeset(channel, changeset) do defp add_source_details_to_changeset(source, changeset) do
%Ecto.Changeset{changes: changes} = changeset %Ecto.Changeset{changes: changes} = changeset
case ChannelDetails.get_channel_details(changes.original_url) do case SourceDetails.get_source_details(changes.original_url) do
{:ok, %ChannelDetails{} = channel_details} -> {:ok, %SourceDetails{} = source_details} ->
change_channel( change_source(
channel, source,
Map.merge(changes, %{ Map.merge(changes, %{
name: channel_details.name, name: source_details.name,
channel_id: channel_details.id collection_id: source_details.id
}) })
) )
@ -128,7 +128,7 @@ defmodule Pinchflat.MediaSource do
Ecto.Changeset.add_error( Ecto.Changeset.add_error(
changeset, changeset,
:original_url, :original_url,
"could not fetch channel details from URL", "could not fetch source details from URL",
error: runner_error error: runner_error
) )
end end
@ -136,27 +136,27 @@ defmodule Pinchflat.MediaSource do
defp commit_and_start_indexing(changeset) do defp commit_and_start_indexing(changeset) do
case Repo.insert_or_update(changeset) do case Repo.insert_or_update(changeset) do
{:ok, %Channel{} = channel} -> {:ok, %Source{} = source} ->
maybe_run_indexing_task(changeset, channel) maybe_run_indexing_task(changeset, source)
{:ok, channel} {:ok, source}
err -> err ->
err err
end end
end end
defp maybe_run_indexing_task(changeset, channel) do defp maybe_run_indexing_task(changeset, source) do
case changeset.data do case changeset.data do
# If the changeset is new (not persisted), attempt 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) SourceTasks.kickoff_indexing_task(source)
# If the record has been persisted, only attempt 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
ChannelTasks.kickoff_indexing_task(channel) SourceTasks.kickoff_indexing_task(source)
end end
end end
end end

View file

@ -1,6 +1,6 @@
defmodule Pinchflat.MediaSource.Channel do defmodule Pinchflat.MediaSource.Source do
@moduledoc """ @moduledoc """
The Channel schema. The Source schema.
""" """
use Ecto.Schema use Ecto.Schema
@ -9,29 +9,30 @@ defmodule Pinchflat.MediaSource.Channel do
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Profiles.MediaProfile alias Pinchflat.Profiles.MediaProfile
@allowed_fields ~w(name channel_id index_frequency_minutes original_url media_profile_id)a @allowed_fields ~w(name collection_id collection_type index_frequency_minutes original_url media_profile_id)a
@required_fields @allowed_fields -- ~w(index_frequency_minutes)a @required_fields @allowed_fields -- ~w(index_frequency_minutes)a
schema "channels" do schema "sources" do
field :name, :string field :name, :string
field :channel_id, :string field :collection_id, :string
field :collection_type, Ecto.Enum, values: [:channel, :playlist]
field :index_frequency_minutes, :integer field :index_frequency_minutes, :integer
# This should only be used for user reference going forward # This should only be used for user reference going forward
# as the channel_id should be used for all API calls # as the collection_id should be used for all API calls
field :original_url, :string field :original_url, :string
belongs_to :media_profile, MediaProfile belongs_to :media_profile, MediaProfile
has_many :media_items, MediaItem has_many :media_items, MediaItem, foreign_key: :source_id
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@doc false @doc false
def changeset(channel, attrs) do def changeset(source, attrs) do
channel source
|> cast(attrs, @allowed_fields) |> cast(attrs, @allowed_fields)
|> validate_required(@required_fields) |> validate_required(@required_fields)
|> unique_constraint([:channel_id, :media_profile_id]) |> unique_constraint([:collection_id, :media_profile_id])
end end
end end

View file

@ -6,7 +6,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias Pinchflat.MediaSource.Channel alias Pinchflat.MediaSource.Source
@allowed_fields ~w( @allowed_fields ~w(
name name
@ -27,7 +27,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
field :embed_subs, :boolean, default: true field :embed_subs, :boolean, default: true
field :sub_langs, :string, default: "en" field :sub_langs, :string, default: "en"
has_many :channels, Channel has_many :sources, Source
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end

View file

@ -8,7 +8,7 @@ defmodule Pinchflat.Tasks do
alias Pinchflat.Tasks.Task alias Pinchflat.Tasks.Task
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.MediaSource.Channel alias Pinchflat.MediaSource.Source
@doc """ @doc """
Returns the list of tasks. Returns [%Task{}, ...] Returns the list of tasks. Returns [%Task{}, ...]
@ -57,7 +57,7 @@ defmodule Pinchflat.Tasks do
@doc """ @doc """
Creates a task. Creates a task.
Accepts map() | %Oban.Job{}, %Channel{} | %Oban.Job{}, %MediaItem{}. Accepts map() | %Oban.Job{}, %Source{} | %Oban.Job{}, %MediaItem{}.
Returns {:ok, %Task{}} | {:error, %Ecto.Changeset{}}. Returns {:ok, %Task{}} | {:error, %Ecto.Changeset{}}.
""" """
def create_task(attrs) do def create_task(attrs) do
@ -71,7 +71,7 @@ defmodule Pinchflat.Tasks do
def create_task(%Oban.Job{} = job, attached_record) do def create_task(%Oban.Job{} = job, attached_record) do
attached_record_attr = attached_record_attr =
case attached_record do case attached_record do
%Channel{} = channel -> %{channel_id: channel.id} %Source{} = source -> %{source_id: source.id}
%MediaItem{} = media_item -> %{media_item_id: media_item.id} %MediaItem{} = media_item -> %{media_item_id: media_item.id}
end end
@ -113,7 +113,7 @@ defmodule Pinchflat.Tasks do
def delete_tasks_for(attached_record) do def delete_tasks_for(attached_record) do
tasks = tasks =
case attached_record do case attached_record do
%Channel{} = channel -> list_tasks_for(:channel_id, channel.id) %Source{} = source -> list_tasks_for(:source_id, source.id)
%MediaItem{} = media_item -> list_tasks_for(:media_item_id, media_item.id) %MediaItem{} = media_item -> list_tasks_for(:media_item_id, media_item.id)
end end
@ -130,7 +130,7 @@ defmodule Pinchflat.Tasks do
def delete_pending_tasks_for(attached_record) do def delete_pending_tasks_for(attached_record) do
tasks = tasks =
case attached_record do case attached_record do
%Channel{} = channel -> list_pending_tasks_for(:channel_id, channel.id) %Source{} = source -> list_pending_tasks_for(:source_id, source.id)
%MediaItem{} = media_item -> list_pending_tasks_for(:media_item_id, media_item.id) %MediaItem{} = media_item -> list_pending_tasks_for(:media_item_id, media_item.id)
end end

View file

@ -1,28 +1,28 @@
defmodule Pinchflat.Tasks.ChannelTasks do defmodule Pinchflat.Tasks.SourceTasks do
@moduledoc """ @moduledoc """
This module contains methods for managing tasks (workers) related to channels. This module contains methods for managing tasks (workers) related to sources.
""" """
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.MediaSource.Channel alias Pinchflat.MediaSource.Source
alias Pinchflat.Workers.MediaIndexingWorker alias Pinchflat.Workers.MediaIndexingWorker
@doc """ @doc """
Starts tasks for indexing a channel's media. Starts tasks for indexing a source's media.
Returns {:ok, :should_not_index} | {:ok, %Task{}}. Returns {:ok, :should_not_index} | {:ok, %Task{}}.
""" """
def kickoff_indexing_task(%Channel{} = channel) do def kickoff_indexing_task(%Source{} = source) do
Tasks.delete_pending_tasks_for(channel) Tasks.delete_pending_tasks_for(source)
if channel.index_frequency_minutes <= 0 do if source.index_frequency_minutes <= 0 do
{:ok, :should_not_index} {:ok, :should_not_index}
else else
channel source
|> Map.take([:id]) |> Map.take([:id])
# 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(source)
|> case do |> case do
# This should never return {:error, :duplicate_job} since we just deleted # 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 # any pending tasks. I'm being assertive about it so it's obvious if I'm wrong

View file

@ -7,11 +7,11 @@ defmodule Pinchflat.Tasks.Task do
import Ecto.Changeset import Ecto.Changeset
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.MediaSource.Channel alias Pinchflat.MediaSource.Source
schema "tasks" do schema "tasks" do
belongs_to :job, Oban.Job belongs_to :job, Oban.Job
belongs_to :channel, Channel belongs_to :source, Source
belongs_to :media_item, MediaItem belongs_to :media_item, MediaItem
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
@ -20,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, :media_item_id]) |> cast(attrs, [:job_id, :source_id, :media_item_id])
|> validate_required([:job_id]) |> validate_required([:job_id])
end end
end end

View file

@ -14,10 +14,10 @@ defmodule Pinchflat.Workers.MediaIndexingWorker do
@impl Oban.Worker @impl Oban.Worker
@doc """ @doc """
The ID is that of a channel _record_, not a YouTube channel ID. Indexes The ID is that of a source _record_, not a YouTube channel/playlist ID. Indexes
the provided channel, kicks off downloads for each new MediaItem, and the provided source, kicks off downloads for each new MediaItem, and
reschedules the job to run again in the future (as determined by the reschedules the job to run again in the future (as determined by the
channel's `index_frequency_minutes` field). souce's `index_frequency_minutes` field).
README: 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_.
@ -37,24 +37,24 @@ defmodule Pinchflat.Workers.MediaIndexingWorker do
Returns :ok | {:ok, %Task{}} Returns :ok | {:ok, %Task{}}
""" """
def perform(%Oban.Job{args: %{"id" => channel_id}}) do def perform(%Oban.Job{args: %{"id" => source_id}}) do
channel = MediaSource.get_channel!(channel_id) source = MediaSource.get_source!(source_id)
if channel.index_frequency_minutes <= 0 do if source.index_frequency_minutes <= 0 do
:ok :ok
else else
index_media_and_reschedule(channel) index_media_and_reschedule(source)
end end
end end
defp index_media_and_reschedule(channel) do defp index_media_and_reschedule(source) do
MediaSource.index_media_items(channel) MediaSource.index_media_items(source)
enqueue_video_downloads(channel) enqueue_video_downloads(source)
channel source
|> Map.take([:id]) |> Map.take([:id])
|> MediaIndexingWorker.new(schedule_in: channel.index_frequency_minutes * 60) |> MediaIndexingWorker.new(schedule_in: source.index_frequency_minutes * 60)
|> Tasks.create_job_with_task(channel) |> Tasks.create_job_with_task(source)
|> case do |> case do
{:ok, task} -> {:ok, task} {:ok, task} -> {:ok, task}
{:error, :duplicate_job} -> {:ok, :job_exists} {:error, :duplicate_job} -> {:ok, :job_exists}
@ -67,8 +67,8 @@ defmodule Pinchflat.Workers.MediaIndexingWorker do
# or somehow got de-queued. # or somehow got de-queued.
# #
# I'm not sure of a case where this would happen, but it's cheap insurance. # I'm not sure of a case where this would happen, but it's cheap insurance.
defp enqueue_video_downloads(channel) do defp enqueue_video_downloads(source) do
channel source
|> Media.list_pending_media_items_for() |> Media.list_pending_media_items_for()
|> Enum.each(fn media_item -> |> Enum.each(fn media_item ->
media_item media_item

View file

@ -1,75 +0,0 @@
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

@ -1,12 +0,0 @@
<.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

@ -1,28 +0,0 @@
<.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

@ -1,12 +0,0 @@
<.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

@ -1,17 +0,0 @@
<.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

@ -0,0 +1,75 @@
defmodule PinchflatWeb.MediaSources.SourceController do
use PinchflatWeb, :controller
alias Pinchflat.Profiles
alias Pinchflat.MediaSource
alias Pinchflat.MediaSource.Source
def index(conn, _params) do
sources = MediaSource.list_sources()
render(conn, :index, sources: sources)
end
def new(conn, _params) do
changeset = MediaSource.change_source(%Source{})
render(conn, :new, changeset: changeset, media_profiles: media_profiles())
end
def create(conn, %{"source" => source_params}) do
case MediaSource.create_source(source_params) do
{:ok, source} ->
conn
|> put_flash(:info, "Source created successfully.")
|> redirect(to: ~p"/media_sources/sources/#{source}")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new, changeset: changeset, media_profiles: media_profiles())
end
end
def show(conn, %{"id" => id}) do
source = MediaSource.get_source!(id)
render(conn, :show, source: source)
end
def edit(conn, %{"id" => id}) do
source = MediaSource.get_source!(id)
changeset = MediaSource.change_source(source)
render(conn, :edit, source: source, changeset: changeset, media_profiles: media_profiles())
end
def update(conn, %{"id" => id, "source" => source_params}) do
source = MediaSource.get_source!(id)
case MediaSource.update_source(source, source_params) do
{:ok, source} ->
conn
|> put_flash(:info, "Source updated successfully.")
|> redirect(to: ~p"/media_sources/sources/#{source}")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :edit,
source: source,
changeset: changeset,
media_profiles: media_profiles()
)
end
end
def delete(conn, %{"id" => id}) do
source = MediaSource.get_source!(id)
{:ok, _source} = MediaSource.delete_source(source)
conn
|> put_flash(:info, "Source deleted successfully.")
|> redirect(to: ~p"/media_sources/sources")
end
defp media_profiles do
Profiles.list_media_profiles()
end
end

View file

@ -1,16 +1,16 @@
defmodule PinchflatWeb.MediaSources.ChannelHTML do defmodule PinchflatWeb.MediaSources.SourceHTML do
use PinchflatWeb, :html use PinchflatWeb, :html
embed_templates "channel_html/*" embed_templates "source_html/*"
@doc """ @doc """
Renders a channel form. Renders a source form.
""" """
attr :changeset, Ecto.Changeset, required: true attr :changeset, Ecto.Changeset, required: true
attr :action, :string, required: true attr :action, :string, required: true
attr :media_profiles, :list, required: true attr :media_profiles, :list, required: true
def channel_form(assigns) def source_form(assigns)
def friendly_index_frequencies do def friendly_index_frequencies do
[ [

View file

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

View file

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

View file

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

View file

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

View file

@ -10,7 +10,8 @@
label="Media Profile" label="Media Profile"
/> />
<.input field={f[:original_url]} type="text" label="Channel URL" /> <.input field={f[:collection_type]} type="text" label="Collection Type" />
<.input field={f[:original_url]} type="text" label="Source URL" />
<.input <.input
field={f[:index_frequency_minutes]} field={f[:index_frequency_minutes]}
@ -20,6 +21,6 @@
/> />
<:actions> <:actions>
<.button>Save Channel</.button> <.button>Save Source</.button>
</:actions> </:actions>
</.simple_form> </.simple_form>

View file

@ -22,7 +22,7 @@ defmodule PinchflatWeb.Router do
resources "/media_profiles", MediaProfiles.MediaProfileController resources "/media_profiles", MediaProfiles.MediaProfileController
scope "/media_sources", MediaSources do scope "/media_sources", MediaSources do
resources "/channels", ChannelController resources "/sources", SourceController
end end
end end

View file

@ -0,0 +1,45 @@
defmodule Pinchflat.Repo.Migrations.RenameChannelAndRelatedFields do
use Ecto.Migration
def change do
# Creation
create table(:sources) do
add :name, :string, null: false
add :collection_type, :string, null: false
add :collection_id, :string, null: false
add :original_url, :string, null: false
add :media_profile_id, references(:media_profiles, on_delete: :restrict), null: false
add :index_frequency_minutes, :integer, default: 60 * 24, null: false
timestamps(type: :utc_datetime)
end
alter table(:media_items) do
add :source_id, references(:sources, on_delete: :restrict), null: false
end
alter table(:tasks) do
# `restrict` because we need to be sure to delete pending tasks when a source is deleted
add :source_id, references(:sources, on_delete: :restrict), null: true
end
create index(:sources, [:media_profile_id])
create unique_index(:sources, [:collection_id, :media_profile_id])
create index(:media_items, [:source_id])
create unique_index(:media_items, [:media_id, :source_id])
# Deletion
drop index(:media_items, [:channel_id])
drop unique_index(:media_items, [:media_id, :channel_id])
alter table(:tasks) do
# `restrict` because we need to be sure to delete pending tasks when a source is deleted
remove :channel_id, references(:channels, on_delete: :restrict), null: true
end
alter table(:media_items) do
remove :channel_id, references(:channels, on_delete: :restrict), null: true
end
end
end

View file

@ -0,0 +1,21 @@
defmodule Pinchflat.Repo.Migrations.DropChannelsTable do
use Ecto.Migration
def up do
drop table(:channels)
end
def down 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,45 +0,0 @@
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_details/1" do
test "it returns a %ChannelDetails{} with data on success" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, "{\"channel\": \"TheUselessTrials\", \"channel_id\": \"UCQH2\"}"}
end)
assert {:ok, res} = Channel.get_channel_details(@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, ot ->
assert opts == [playlist_end: 1]
assert ot == "%(.{channel,channel_id})j"
{:ok, "{}"}
end)
assert {:ok, _} = Channel.get_channel_details(@channel_url)
end
test "it returns an error if the runner returns an error" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:error, "Big issue", 1} end)
assert {:error, "Big issue", 1} = Channel.get_channel_details(@channel_url)
end
test "it returns an error if the output is not JSON" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "Not JSON"} end)
assert {:error, %Jason.DecodeError{}} = Channel.get_channel_details(@channel_url)
end
end
end

View file

@ -2,13 +2,10 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoCollectionTest do
use ExUnit.Case, async: true use ExUnit.Case, async: true
import Mox import Mox
alias Pinchflat.MediaClient.SourceDetails
alias Pinchflat.MediaClient.Backends.YtDlp.VideoCollection alias Pinchflat.MediaClient.Backends.YtDlp.VideoCollection
@channel_url "https://www.youtube.com/@TheUselessTrials" @channel_url "https://www.youtube.com/c/TheUselessTrials"
defmodule VideoCollectionUser do
use VideoCollection
end
setup :verify_on_exit! setup :verify_on_exit!
@ -16,7 +13,7 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoCollectionTest 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, _ot -> {: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"]} = VideoCollection.get_video_ids(@channel_url)
end end
test "it passes the expected default args" do test "it passes the expected default args" do
@ -27,7 +24,7 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoCollectionTest do
{:ok, ""} {:ok, ""}
end) end)
assert {:ok, _} = VideoCollectionUser.get_video_ids(@channel_url) assert {:ok, _} = VideoCollection.get_video_ids(@channel_url)
end end
test "it passes the expected custom args" do test "it passes the expected custom args" do
@ -37,13 +34,47 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoCollectionTest do
{:ok, ""} {:ok, ""}
end) end)
assert {:ok, _} = VideoCollectionUser.get_video_ids(@channel_url, [:custom_arg]) assert {:ok, _} = VideoCollection.get_video_ids(@channel_url, [:custom_arg])
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, _ot -> {: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} = VideoCollection.get_video_ids(@channel_url)
end
end
describe "get_source_details/1" do
test "it returns a %SourceDetails{} with data on success" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, "{\"channel\": \"TheUselessTrials\", \"channel_id\": \"UCQH2\"}"}
end)
assert {:ok, res} = VideoCollection.get_source_details(@channel_url)
assert %SourceDetails{id: "UCQH2", name: "TheUselessTrials"} = res
end
test "it passes the expected args to the backend runner" do
expect(YtDlpRunnerMock, :run, fn @channel_url, opts, ot ->
assert opts == [:skip_download, playlist_end: 1]
assert ot == "%(.{channel,channel_id})j"
{:ok, "{}"}
end)
assert {:ok, _} = VideoCollection.get_source_details(@channel_url)
end
test "it returns an error if the runner returns an error" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:error, "Big issue", 1} end)
assert {:error, "Big issue", 1} = VideoCollection.get_source_details(@channel_url)
end
test "it returns an error if the output is not JSON" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "Not JSON"} end)
assert {:error, %Jason.DecodeError{}} = VideoCollection.get_source_details(@channel_url)
end end
end end
end end

View file

@ -1,8 +1,8 @@
defmodule Pinchflat.MediaClient.ChannelDetailsTest do defmodule Pinchflat.MediaClient.SourceDetailsTest do
use ExUnit.Case, async: true use ExUnit.Case, async: true
import Mox import Mox
alias Pinchflat.MediaClient.ChannelDetails alias Pinchflat.MediaClient.SourceDetails
@channel_url "https://www.youtube.com/c/TheUselessTrials" @channel_url "https://www.youtube.com/c/TheUselessTrials"
@ -10,21 +10,21 @@ defmodule Pinchflat.MediaClient.ChannelDetailsTest do
describe "new/2" do describe "new/2" do
test "it returns a struct with the given values" do test "it returns a struct with the given values" do
assert %ChannelDetails{id: "UCQH2", name: "TheUselessTrials"} = assert %SourceDetails{id: "UCQH2", name: "TheUselessTrials"} =
ChannelDetails.new("UCQH2", "TheUselessTrials") SourceDetails.new("UCQH2", "TheUselessTrials")
end end
end end
describe "get_channel_details/2" do describe "get_source_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, ot -> expect(YtDlpRunnerMock, :run, fn @channel_url, opts, ot ->
assert opts == [playlist_end: 1] assert opts == [:skip_download, playlist_end: 1]
assert ot == "%(.{channel,channel_id})j" assert ot == "%(.{channel,channel_id})j"
{:ok, "{\"channel\": \"TheUselessTrials\", \"channel_id\": \"UCQH2\"}"} {:ok, "{\"channel\": \"TheUselessTrials\", \"channel_id\": \"UCQH2\"}"}
end) end)
assert {:ok, _} = ChannelDetails.get_channel_details(@channel_url) assert {:ok, _} = SourceDetails.get_source_details(@channel_url)
end end
test "it returns a struct composed of the returned data" do test "it returns a struct composed of the returned data" do
@ -32,8 +32,8 @@ defmodule Pinchflat.MediaClient.ChannelDetailsTest do
{:ok, "{\"channel\": \"TheUselessTrials\", \"channel_id\": \"UCQH2\"}"} {:ok, "{\"channel\": \"TheUselessTrials\", \"channel_id\": \"UCQH2\"}"}
end) end)
assert {:ok, res} = ChannelDetails.get_channel_details(@channel_url) assert {:ok, res} = SourceDetails.get_source_details(@channel_url)
assert %ChannelDetails{id: "UCQH2", name: "TheUselessTrials"} = res assert %SourceDetails{id: "UCQH2", name: "TheUselessTrials"} = res
end end
end end
@ -46,7 +46,7 @@ defmodule Pinchflat.MediaClient.ChannelDetailsTest do
{:ok, ""} {:ok, ""}
end) end)
assert {:ok, _} = ChannelDetails.get_video_ids(@channel_url) assert {:ok, _} = SourceDetails.get_video_ids(@channel_url)
end end
test "it returns a list of strings" do test "it returns a list of strings" do
@ -54,7 +54,7 @@ defmodule Pinchflat.MediaClient.ChannelDetailsTest do
{:ok, "video1\nvideo2\nvideo3"} {:ok, "video1\nvideo2\nvideo3"}
end) end)
assert {:ok, ["video1", "video2", "video3"]} = ChannelDetails.get_video_ids(@channel_url) assert {:ok, ["video1", "video2", "video3"]} = SourceDetails.get_video_ids(@channel_url)
end end
end end
end end

View file

@ -11,7 +11,7 @@ defmodule Pinchflat.MediaClient.VideoDownloaderTest do
media_item = media_item =
Repo.preload( Repo.preload(
media_item_fixture(%{title: nil, media_filepath: nil}), media_item_fixture(%{title: nil, media_filepath: nil}),
[:metadata, channel: :media_profile] [:metadata, source: :media_profile]
) )
{:ok, %{media_item: media_item}} {:ok, %{media_item: media_item}}

View file

@ -7,82 +7,85 @@ defmodule Pinchflat.MediaSourceTest do
alias Pinchflat.MediaSource alias Pinchflat.MediaSource
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.MediaSource.Channel alias Pinchflat.MediaSource.Source
alias Pinchflat.Workers.MediaIndexingWorker alias Pinchflat.Workers.MediaIndexingWorker
@invalid_channel_attrs %{name: nil, channel_id: nil} @invalid_source_attrs %{name: nil, collection_id: nil}
setup :verify_on_exit! setup :verify_on_exit!
describe "list_channels/0" do describe "list_sources/0" do
test "it returns all channels" do test "it returns all sources" do
channel = channel_fixture() source = source_fixture()
assert MediaSource.list_channels() == [channel] assert MediaSource.list_sources() == [source]
end end
end end
describe "get_channel!/1" do describe "get_source!/1" do
test "it returns the channel with given id" do test "it returns the source with given id" do
channel = channel_fixture() source = source_fixture()
assert MediaSource.get_channel!(channel.id) == channel assert MediaSource.get_source!(source.id) == source
end end
end end
describe "create_channel/1" do describe "create_source/1" do
test "creates a channel and adds name + ID from runner response" do test "creates a source and adds name + ID from runner response" do
expect(YtDlpRunnerMock, :run, &runner_function_mock/3) 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,
original_url: "https://www.youtube.com/channel/abc123" original_url: "https://www.youtube.com/channel/abc123",
collection_type: "channel"
} }
assert {:ok, %Channel{} = channel} = MediaSource.create_channel(valid_attrs) assert {:ok, %Source{} = source} = MediaSource.create_source(valid_attrs)
assert channel.name == "some name" assert source.name == "some name"
assert String.starts_with?(channel.channel_id, "some_channel_id_") assert String.starts_with?(source.collection_id, "some_source_id_")
end end
test "creation with invalid data returns error changeset" do test "creation with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = MediaSource.create_channel(@invalid_channel_attrs) assert {:error, %Ecto.Changeset{}} = MediaSource.create_source(@invalid_source_attrs)
end end
test "creation enforces uniqueness of channel_id scoped to the media_profile" do test "creation enforces uniqueness of source_id scoped to the media_profile" do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot ->
{:ok, {:ok,
Phoenix.json_library().encode!(%{ Phoenix.json_library().encode!(%{
channel: "some name", channel: "some name",
channel_id: "some_channel_id_12345678" channel_id: "some_source_id_12345678"
})} })}
end) end)
valid_once_attrs = %{ valid_once_attrs = %{
media_profile_id: media_profile_fixture().id, media_profile_id: media_profile_fixture().id,
original_url: "https://www.youtube.com/channel/abc123" original_url: "https://www.youtube.com/channel/abc123",
collection_type: "channel"
} }
assert {:ok, %Channel{}} = MediaSource.create_channel(valid_once_attrs) assert {:ok, %Source{}} = MediaSource.create_source(valid_once_attrs)
assert {:error, %Ecto.Changeset{}} = MediaSource.create_channel(valid_once_attrs) assert {:error, %Ecto.Changeset{}} = MediaSource.create_source(valid_once_attrs)
end end
test "creation lets you duplicate channel_ids as long as the media profile is different" do test "creation lets you duplicate collection_ids as long as the media profile is different" do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot ->
{:ok, {:ok,
Phoenix.json_library().encode!(%{ Phoenix.json_library().encode!(%{
channel: "some name", channel: "some name",
channel_id: "some_channel_id_12345678" channel_id: "some_source_id_12345678"
})} })}
end) end)
valid_attrs = %{ valid_attrs = %{
name: "some name", name: "some name",
original_url: "https://www.youtube.com/channel/abc123" original_url: "https://www.youtube.com/channel/abc123",
collection_type: "channel"
} }
channel_1_attrs = Map.merge(valid_attrs, %{media_profile_id: media_profile_fixture().id}) source_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}) source_2_attrs = Map.merge(valid_attrs, %{media_profile_id: media_profile_fixture().id})
assert {:ok, %Channel{}} = MediaSource.create_channel(channel_1_attrs) assert {:ok, %Source{}} = MediaSource.create_source(source_1_attrs)
assert {:ok, %Channel{}} = MediaSource.create_channel(channel_2_attrs) assert {:ok, %Source{}} = MediaSource.create_source(source_2_attrs)
end end
test "creation will schedule the indexing task" do test "creation will schedule the indexing task" do
@ -90,12 +93,13 @@ defmodule Pinchflat.MediaSourceTest do
valid_attrs = %{ valid_attrs = %{
media_profile_id: media_profile_fixture().id, media_profile_id: media_profile_fixture().id,
original_url: "https://www.youtube.com/channel/abc123" original_url: "https://www.youtube.com/channel/abc123",
collection_type: "channel"
} }
assert {:ok, %Channel{} = channel} = MediaSource.create_channel(valid_attrs) assert {:ok, %Source{} = source} = MediaSource.create_source(valid_attrs)
assert_enqueued(worker: MediaIndexingWorker, args: %{"id" => channel.id}) assert_enqueued(worker: MediaIndexingWorker, args: %{"id" => source.id})
end end
end end
@ -103,181 +107,181 @@ defmodule Pinchflat.MediaSourceTest do
setup do setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "video1\nvideo2\nvideo3"} end) stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "video1\nvideo2\nvideo3"} end)
{:ok, [channel: channel_fixture()]} {:ok, [source: source_fixture()]}
end end
test "it creates a media_item record for each media ID returned", %{channel: channel} do test "it creates a media_item record for each media ID returned", %{source: source} do
assert media_items = MediaSource.index_media_items(channel) assert media_items = MediaSource.index_media_items(source)
assert Enum.count(media_items) == 3 assert Enum.count(media_items) == 3
assert ["video1", "video2", "video3"] == Enum.map(media_items, & &1.media_id) assert ["video1", "video2", "video3"] == Enum.map(media_items, & &1.media_id)
assert Enum.all?(media_items, fn %MediaItem{} -> true end) assert Enum.all?(media_items, fn %MediaItem{} -> true end)
end end
test "it attaches all media_items to the given channel", %{channel: channel} do test "it attaches all media_items to the given source", %{source: source} do
channel_id = channel.id source_id = source.id
assert media_items = MediaSource.index_media_items(channel) assert media_items = MediaSource.index_media_items(source)
assert Enum.count(media_items) == 3 assert Enum.count(media_items) == 3
assert Enum.all?(media_items, fn %MediaItem{channel_id: ^channel_id} -> true end) assert Enum.all?(media_items, fn %MediaItem{source_id: ^source_id} -> true end)
end end
test "it won't duplicate media_items based on media_id and channel", %{channel: channel} do test "it won't duplicate media_items based on media_id and source", %{source: source} do
_first_run = MediaSource.index_media_items(channel) _first_run = MediaSource.index_media_items(source)
_duplicate_run = MediaSource.index_media_items(channel) _duplicate_run = MediaSource.index_media_items(source)
media_items = Repo.preload(channel, :media_items).media_items media_items = Repo.preload(source, :media_items).media_items
assert Enum.count(media_items) == 3 assert Enum.count(media_items) == 3
end end
test "it can duplicate media_ids for different channels", %{channel: channel} do test "it can duplicate media_ids for different sources", %{source: source} do
other_channel = channel_fixture() other_source = source_fixture()
media_items = MediaSource.index_media_items(channel) media_items = MediaSource.index_media_items(source)
media_items_other_channel = MediaSource.index_media_items(other_channel) media_items_other_source = MediaSource.index_media_items(other_source)
assert Enum.count(media_items) == 3 assert Enum.count(media_items) == 3
assert Enum.count(media_items_other_channel) == 3 assert Enum.count(media_items_other_source) == 3
assert Enum.map(media_items, & &1.media_id) == assert Enum.map(media_items, & &1.media_id) ==
Enum.map(media_items_other_channel, & &1.media_id) Enum.map(media_items_other_source, & &1.media_id)
end end
test "it returns a list of media_items or changesets", %{channel: channel} do test "it returns a list of media_items or changesets", %{source: source} do
first_run = MediaSource.index_media_items(channel) first_run = MediaSource.index_media_items(source)
duplicate_run = MediaSource.index_media_items(channel) duplicate_run = MediaSource.index_media_items(source)
assert Enum.all?(first_run, fn %MediaItem{} -> true end) assert Enum.all?(first_run, fn %MediaItem{} -> true end)
assert Enum.all?(duplicate_run, fn %Ecto.Changeset{} -> true end) assert Enum.all?(duplicate_run, fn %Ecto.Changeset{} -> true end)
end end
end end
describe "update_channel/2" do describe "update_source/2" do
test "updates with valid data updates the channel" do test "updates with valid data updates the source" do
channel = channel_fixture() source = source_fixture()
update_attrs = %{name: "some updated name"} update_attrs = %{name: "some updated name"}
assert {:ok, %Channel{} = channel} = MediaSource.update_channel(channel, update_attrs) assert {:ok, %Source{} = source} = MediaSource.update_source(source, update_attrs)
assert channel.name == "some updated name" assert source.name == "some updated name"
end end
test "updating the original_url will re-fetch the channel details" do test "updating the original_url will re-fetch the source details" do
expect(YtDlpRunnerMock, :run, &runner_function_mock/3) expect(YtDlpRunnerMock, :run, &runner_function_mock/3)
channel = channel_fixture() source = source_fixture()
update_attrs = %{original_url: "https://www.youtube.com/channel/abc123"} update_attrs = %{original_url: "https://www.youtube.com/channel/abc123"}
assert {:ok, %Channel{} = channel} = MediaSource.update_channel(channel, update_attrs) assert {:ok, %Source{} = source} = MediaSource.update_source(source, update_attrs)
assert channel.name == "some name" assert source.name == "some name"
assert String.starts_with?(channel.channel_id, "some_channel_id_") assert String.starts_with?(source.collection_id, "some_source_id_")
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 source details" do
expect(YtDlpRunnerMock, :run, 0, &runner_function_mock/3) expect(YtDlpRunnerMock, :run, 0, &runner_function_mock/3)
channel = channel_fixture() source = source_fixture()
update_attrs = %{name: "some updated name"} update_attrs = %{name: "some updated name"}
assert {:ok, %Channel{}} = MediaSource.update_channel(channel, update_attrs) assert {:ok, %Source{}} = MediaSource.update_source(source, update_attrs)
end end
test "updating the index frequency will re-schedule the indexing task" do test "updating the index frequency will re-schedule the indexing task" do
channel = channel_fixture() source = source_fixture()
update_attrs = %{index_frequency_minutes: 123} update_attrs = %{index_frequency_minutes: 123}
assert {:ok, %Channel{} = channel} = MediaSource.update_channel(channel, update_attrs) assert {:ok, %Source{} = source} = MediaSource.update_source(source, update_attrs)
assert channel.index_frequency_minutes == 123 assert source.index_frequency_minutes == 123
assert_enqueued(worker: MediaIndexingWorker, args: %{"id" => channel.id}) assert_enqueued(worker: MediaIndexingWorker, args: %{"id" => source.id})
end end
test "not updating the index frequency will not re-schedule the indexing task" do test "not updating the index frequency will not re-schedule the indexing task" do
channel = channel_fixture() source = source_fixture()
update_attrs = %{name: "some updated name"} update_attrs = %{name: "some updated name"}
assert {:ok, %Channel{}} = MediaSource.update_channel(channel, update_attrs) assert {:ok, %Source{}} = MediaSource.update_source(source, update_attrs)
refute_enqueued(worker: MediaIndexingWorker, args: %{"id" => channel.id}) refute_enqueued(worker: MediaIndexingWorker, args: %{"id" => source.id})
end end
test "updates with invalid data returns error changeset" do test "updates with invalid data returns error changeset" do
channel = channel_fixture() source = source_fixture()
assert {:error, %Ecto.Changeset{}} = assert {:error, %Ecto.Changeset{}} =
MediaSource.update_channel(channel, @invalid_channel_attrs) MediaSource.update_source(source, @invalid_source_attrs)
assert channel == MediaSource.get_channel!(channel.id) assert source == MediaSource.get_source!(source.id)
end end
end end
describe "delete_channel/1" do describe "delete_source/1" do
test "it deletes the channel" do test "it deletes the source" do
channel = channel_fixture() source = source_fixture()
assert {:ok, %Channel{}} = MediaSource.delete_channel(channel) assert {:ok, %Source{}} = MediaSource.delete_source(source)
assert_raise Ecto.NoResultsError, fn -> MediaSource.get_channel!(channel.id) end assert_raise Ecto.NoResultsError, fn -> MediaSource.get_source!(source.id) end
end end
test "it returns a channel changeset" do test "it returns a source changeset" do
channel = channel_fixture() source = source_fixture()
assert %Ecto.Changeset{} = MediaSource.change_channel(channel) assert %Ecto.Changeset{} = MediaSource.change_source(source)
end end
test "deletion also deletes all associated tasks" do test "deletion also deletes all associated tasks" do
channel = channel_fixture() source = source_fixture()
task = task_fixture(channel_id: channel.id) task = task_fixture(source_id: source.id)
assert {:ok, %Channel{}} = MediaSource.delete_channel(channel) assert {:ok, %Source{}} = MediaSource.delete_source(source)
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end end
end end
describe "change_channel/2" do describe "change_source/2" do
test "it returns a changeset" do test "it returns a changeset" do
channel = channel_fixture() source = source_fixture()
assert %Ecto.Changeset{} = MediaSource.change_channel(channel) assert %Ecto.Changeset{} = MediaSource.change_source(source)
end end
end end
describe "change_channel_from_url/2" do describe "change_source_from_url/2" do
test "it returns a changeset" do test "it returns a changeset" do
stub(YtDlpRunnerMock, :run, &runner_function_mock/3) stub(YtDlpRunnerMock, :run, &runner_function_mock/3)
channel = channel_fixture() source = source_fixture()
assert %Ecto.Changeset{} = MediaSource.change_channel_from_url(channel, %{}) assert %Ecto.Changeset{} = MediaSource.change_source_from_url(source, %{})
end end
test "it does not fetch channel details if the original_url isn't in the changeset" do test "it does not fetch source details if the original_url isn't in the changeset" do
expect(YtDlpRunnerMock, :run, 0, &runner_function_mock/3) expect(YtDlpRunnerMock, :run, 0, &runner_function_mock/3)
changeset = MediaSource.change_channel_from_url(%Channel{}, %{name: "some updated name"}) changeset = MediaSource.change_source_from_url(%Source{}, %{name: "some updated name"})
assert %Ecto.Changeset{} = changeset assert %Ecto.Changeset{} = changeset
end end
test "it fetches channel details if the original_url is in the changeset" do test "it fetches source details if the original_url is in the changeset" do
expect(YtDlpRunnerMock, :run, &runner_function_mock/3) expect(YtDlpRunnerMock, :run, &runner_function_mock/3)
changeset = changeset =
MediaSource.change_channel_from_url(%Channel{}, %{ MediaSource.change_source_from_url(%Source{}, %{
original_url: "https://www.youtube.com/channel/abc123" original_url: "https://www.youtube.com/channel/abc123"
}) })
assert %Ecto.Changeset{} = changeset assert %Ecto.Changeset{} = changeset
end end
test "it adds channel details to the changeset, keeping the orignal details" do test "it adds source details to the changeset, keeping the orignal details" do
expect(YtDlpRunnerMock, :run, &runner_function_mock/3) 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
changeset = changeset =
MediaSource.change_channel_from_url(%Channel{}, %{ MediaSource.change_source_from_url(%Source{}, %{
original_url: "https://www.youtube.com/channel/abc123", original_url: "https://www.youtube.com/channel/abc123",
media_profile_id: media_profile.id media_profile_id: media_profile.id
}) })
assert %Ecto.Changeset{} = changeset assert %Ecto.Changeset{} = changeset
assert String.starts_with?(changeset.changes.channel_id, "some_channel_id_") assert String.starts_with?(changeset.changes.collection_id, "some_source_id_")
assert %{ assert %{
name: "some name", name: "some name",
@ -292,12 +296,12 @@ defmodule Pinchflat.MediaSourceTest do
end) end)
changeset = changeset =
MediaSource.change_channel_from_url(%Channel{}, %{ MediaSource.change_source_from_url(%Source{}, %{
original_url: "https://www.youtube.com/channel/abc123" original_url: "https://www.youtube.com/channel/abc123"
}) })
assert %Ecto.Changeset{} = changeset assert %Ecto.Changeset{} = changeset
assert errors_on(changeset).original_url == ["could not fetch channel details from URL"] assert errors_on(changeset).original_url == ["could not fetch source details from URL"]
end end
end end
@ -306,7 +310,7 @@ defmodule Pinchflat.MediaSourceTest do
:ok, :ok,
Phoenix.json_library().encode!(%{ Phoenix.json_library().encode!(%{
channel: "some name", channel: "some name",
channel_id: "some_channel_id_#{:rand.uniform(1_000_000)}" channel_id: "some_source_id_#{:rand.uniform(1_000_000)}"
}) })
} }
end end

View file

@ -30,23 +30,23 @@ defmodule Pinchflat.MediaTest do
end end
describe "list_pending_media_items_for/1" do describe "list_pending_media_items_for/1" do
test "it returns pending media_items for a given channel" do test "it returns pending media_items for a given source" do
channel = channel_fixture() source = source_fixture()
media_item = media_item_fixture(%{channel_id: channel.id, media_filepath: nil}) media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil})
assert Media.list_pending_media_items_for(channel) == [media_item] assert Media.list_pending_media_items_for(source) == [media_item]
end end
test "it does not return media_items with media_filepath" do test "it does not return media_items with media_filepath" do
channel = channel_fixture() source = source_fixture()
_media_item = _media_item =
media_item_fixture(%{ media_item_fixture(%{
channel_id: channel.id, source_id: source.id,
media_filepath: "/video/#{Faker.File.file_name(:video)}" media_filepath: "/video/#{Faker.File.file_name(:video)}"
}) })
assert Media.list_pending_media_items_for(channel) == [] assert Media.list_pending_media_items_for(source) == []
end end
end end
@ -63,7 +63,7 @@ defmodule Pinchflat.MediaTest do
media_id: Faker.String.base64(12), media_id: Faker.String.base64(12),
title: Faker.Commerce.product_name(), title: Faker.Commerce.product_name(),
media_filepath: "/video/#{Faker.File.file_name(:video)}", media_filepath: "/video/#{Faker.File.file_name(:video)}",
channel_id: channel_fixture().id source_id: source_fixture().id
} }
assert {:ok, %MediaItem{} = media_item} = Media.create_media_item(valid_attrs) assert {:ok, %MediaItem{} = media_item} = Media.create_media_item(valid_attrs)
@ -85,7 +85,7 @@ defmodule Pinchflat.MediaTest do
media_id: Faker.String.base64(12), media_id: Faker.String.base64(12),
title: Faker.Commerce.product_name(), title: Faker.Commerce.product_name(),
media_filepath: "/video/#{Faker.File.file_name(:video)}", media_filepath: "/video/#{Faker.File.file_name(:video)}",
channel_id: channel_fixture().id source_id: source_fixture().id
} }
assert {:ok, %MediaItem{} = media_item} = Media.update_media_item(media_item, update_attrs) assert {:ok, %MediaItem{} = media_item} = Media.update_media_item(media_item, update_attrs)

View file

@ -1,45 +0,0 @@
defmodule Pinchflat.Tasks.ChannelTasksTest do
use Pinchflat.DataCase
import Pinchflat.TasksFixtures
import Pinchflat.MediaSourceFixtures
alias Pinchflat.Tasks.Task
alias Pinchflat.Tasks.ChannelTasks
alias Pinchflat.Workers.MediaIndexingWorker
describe "kickoff_indexing_task/1" do
test "it does not schedule a job if the interval is <= 0" do
channel = channel_fixture(index_frequency_minutes: -1)
assert {:ok, :should_not_index} = ChannelTasks.kickoff_indexing_task(channel)
refute_enqueued(worker: MediaIndexingWorker, args: %{"id" => channel.id})
end
test "it schedules a job if the interval is > 0" do
channel = channel_fixture(index_frequency_minutes: 1)
assert {:ok, _} = ChannelTasks.kickoff_indexing_task(channel)
assert_enqueued(worker: MediaIndexingWorker, args: %{"id" => channel.id})
end
test "it creates and attaches a task if the interval is > 0" do
channel = channel_fixture(index_frequency_minutes: 1)
assert {:ok, %Task{} = task} = ChannelTasks.kickoff_indexing_task(channel)
assert task.channel_id == channel.id
end
test "it deletes any pending tasks for the channel" do
channel = channel_fixture()
task = task_fixture(channel_id: channel.id)
assert {:ok, _} = ChannelTasks.kickoff_indexing_task(channel)
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end
end
end

View file

@ -0,0 +1,45 @@
defmodule Pinchflat.Tasks.SourceTasksTest do
use Pinchflat.DataCase
import Pinchflat.TasksFixtures
import Pinchflat.MediaSourceFixtures
alias Pinchflat.Tasks.Task
alias Pinchflat.Tasks.SourceTasks
alias Pinchflat.Workers.MediaIndexingWorker
describe "kickoff_indexing_task/1" do
test "it does not schedule a job if the interval is <= 0" do
source = source_fixture(index_frequency_minutes: -1)
assert {:ok, :should_not_index} = SourceTasks.kickoff_indexing_task(source)
refute_enqueued(worker: MediaIndexingWorker, args: %{"id" => source.id})
end
test "it schedules a job if the interval is > 0" do
source = source_fixture(index_frequency_minutes: 1)
assert {:ok, _} = SourceTasks.kickoff_indexing_task(source)
assert_enqueued(worker: MediaIndexingWorker, args: %{"id" => source.id})
end
test "it creates and attaches a task if the interval is > 0" do
source = source_fixture(index_frequency_minutes: 1)
assert {:ok, %Task{} = task} = SourceTasks.kickoff_indexing_task(source)
assert task.source_id == source.id
end
test "it deletes any pending tasks for the source" do
source = source_fixture()
task = task_fixture(source_id: source.id)
assert {:ok, _} = SourceTasks.kickoff_indexing_task(source)
assert_raise Ecto.NoResultsError, fn -> Repo.reload!(task) end
end
end
end

View file

@ -21,11 +21,11 @@ defmodule Pinchflat.TasksTest do
end end
test "it does not delete the other record when a job gets deleted" do test "it does not delete the other record when a job gets deleted" do
task = Repo.preload(task_fixture(), [:channel, :job]) task = Repo.preload(task_fixture(), [:source, :job])
{:ok, _} = Repo.delete(task.job) {:ok, _} = Repo.delete(task.job)
assert Repo.reload!(task.channel) assert Repo.reload!(task.source)
end end
end end
@ -40,14 +40,14 @@ defmodule Pinchflat.TasksTest do
test "it lets you specify which record type/ID to join on" do test "it lets you specify which record type/ID to join on" do
task = task_fixture() task = task_fixture()
assert Tasks.list_tasks_for(:channel_id, task.channel_id) == [task] assert Tasks.list_tasks_for(:source_id, task.source_id) == [task]
end end
test "it lets you specify which job states to include" do test "it lets you specify which job states to include" do
task = task_fixture() task = task_fixture()
assert Tasks.list_tasks_for(:channel_id, task.channel_id, [:available]) == [task] assert Tasks.list_tasks_for(:source_id, task.source_id, [:available]) == [task]
assert Tasks.list_tasks_for(:channel_id, task.channel_id, [:cancelled]) == [] assert Tasks.list_tasks_for(:source_id, task.source_id, [:cancelled]) == []
end end
end end
@ -55,14 +55,14 @@ defmodule Pinchflat.TasksTest do
test "it lists pending tasks" do test "it lists pending tasks" do
task = task_fixture() task = task_fixture()
assert Tasks.list_pending_tasks_for(:channel_id, task.channel_id) == [task] assert Tasks.list_pending_tasks_for(:source_id, task.source_id) == [task]
end end
test "it does not list non-pending tasks" do test "it does not list non-pending tasks" do
task = Repo.preload(task_fixture(), :job) task = Repo.preload(task_fixture(), :job)
:ok = Oban.cancel_job(task.job) :ok = Oban.cancel_job(task.job)
assert Tasks.list_pending_tasks_for(:channel_id, task.channel_id) == [] assert Tasks.list_pending_tasks_for(:source_id, task.source_id) == []
end end
end end
@ -84,14 +84,14 @@ defmodule Pinchflat.TasksTest do
assert {:error, %Ecto.Changeset{}} = Tasks.create_task(@invalid_attrs) assert {:error, %Ecto.Changeset{}} = Tasks.create_task(@invalid_attrs)
end end
test "accepts a job and channel" do test "accepts a job and source" do
job = job_fixture() job = job_fixture()
channel = channel_fixture() source = source_fixture()
assert {:ok, %Task{} = task} = Tasks.create_task(job, channel) assert {:ok, %Task{} = task} = Tasks.create_task(job, source)
assert task.job_id == job.id assert task.job_id == job.id
assert task.channel_id == channel.id assert task.source_id == source.id
end end
test "accepts a job and media item" do test "accepts a job and media item" do
@ -115,25 +115,25 @@ defmodule Pinchflat.TasksTest do
end end
test "it creates a task record if successful" do test "it creates a task record if successful" do
channel = channel_fixture() source = source_fixture()
assert {:ok, %Task{} = task} = Tasks.create_job_with_task(TestJobWorker.new(%{}), channel) assert {:ok, %Task{} = task} = Tasks.create_job_with_task(TestJobWorker.new(%{}), source)
assert task.channel_id == channel.id assert task.source_id == source.id
end end
test "it returns an error if the job already exists" do test "it returns an error if the job already exists" do
channel = channel_fixture() source = source_fixture()
job = TestJobWorker.new(%{foo: "bar"}, unique: [period: :infinity]) job = TestJobWorker.new(%{foo: "bar"}, unique: [period: :infinity])
assert {:ok, %Task{}} = Tasks.create_job_with_task(job, channel) assert {:ok, %Task{}} = Tasks.create_job_with_task(job, source)
assert {:error, :duplicate_job} = Tasks.create_job_with_task(job, channel) assert {:error, :duplicate_job} = Tasks.create_job_with_task(job, source)
end end
test "it returns an error if the job fails to enqueue" do test "it returns an error if the job fails to enqueue" do
channel = channel_fixture() source = source_fixture()
assert {:error, %Ecto.Changeset{}} = Tasks.create_job_with_task(%Ecto.Changeset{}, channel) assert {:error, %Ecto.Changeset{}} = Tasks.create_job_with_task(%Ecto.Changeset{}, source)
end end
end end
@ -155,11 +155,11 @@ defmodule Pinchflat.TasksTest do
end end
describe "delete_tasks_for/1" do describe "delete_tasks_for/1" do
test "it deletes tasks attached to a channel" do test "it deletes tasks attached to a source" do
channel = channel_fixture() source = source_fixture()
task = task_fixture(channel_id: channel.id) task = task_fixture(source_id: source.id)
assert :ok = Tasks.delete_tasks_for(channel) assert :ok = Tasks.delete_tasks_for(source)
assert_raise Ecto.NoResultsError, fn -> Tasks.get_task!(task.id) end assert_raise Ecto.NoResultsError, fn -> Tasks.get_task!(task.id) end
end end
@ -173,20 +173,20 @@ defmodule Pinchflat.TasksTest do
end end
describe "delete_pending_tasks_for/1" do describe "delete_pending_tasks_for/1" do
test "it deletes pending tasks attached to a channel" do test "it deletes pending tasks attached to a source" do
channel = channel_fixture() source = source_fixture()
task = task_fixture(channel_id: channel.id) task = task_fixture(source_id: source.id)
assert :ok = Tasks.delete_pending_tasks_for(channel) assert :ok = Tasks.delete_pending_tasks_for(source)
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 does not delete non-pending tasks" do test "it does not delete non-pending tasks" do
channel = channel_fixture() source = source_fixture()
task = Repo.preload(task_fixture(channel_id: channel.id), :job) task = Repo.preload(task_fixture(source_id: source.id), :job)
:ok = Oban.cancel_job(task.job) :ok = Oban.cancel_job(task.job)
assert :ok = Tasks.delete_pending_tasks_for(channel) assert :ok = Tasks.delete_pending_tasks_for(source)
assert Tasks.get_task!(task.id) assert Tasks.get_task!(task.id)
end end

View file

@ -12,36 +12,36 @@ defmodule Pinchflat.Workers.MediaIndexingWorkerTest do
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 source shouldn't be indexed" do
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot -> {:ok, ""} end) expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot -> {:ok, ""} end)
channel = channel_fixture(index_frequency_minutes: -1) source = source_fixture(index_frequency_minutes: -1)
perform_job(MediaIndexingWorker, %{id: channel.id}) perform_job(MediaIndexingWorker, %{id: source.id})
end end
test "it does not reschedule if the channel shouldn't be indexed" do test "it does not reschedule if the source shouldn't be indexed" do
expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot -> {:ok, ""} end) expect(YtDlpRunnerMock, :run, 0, fn _url, _opts, _ot -> {:ok, ""} end)
channel = channel_fixture(index_frequency_minutes: -1) source = source_fixture(index_frequency_minutes: -1)
perform_job(MediaIndexingWorker, %{id: channel.id}) perform_job(MediaIndexingWorker, %{id: source.id})
refute_enqueued(worker: MediaIndexingWorker, args: %{"id" => channel.id}) refute_enqueued(worker: MediaIndexingWorker, args: %{"id" => source.id})
end end
test "it indexes the channel if it should be indexed" do test "it indexes the source if it should be indexed" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
channel = channel_fixture(index_frequency_minutes: 10) source = source_fixture(index_frequency_minutes: 10)
perform_job(MediaIndexingWorker, %{id: channel.id}) perform_job(MediaIndexingWorker, %{id: source.id})
end end
test "it kicks off a download job for each pending media item" do test "it kicks off a download job for each pending media item" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "video1"} end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "video1"} end)
channel = channel_fixture(index_frequency_minutes: 10) source = source_fixture(index_frequency_minutes: 10)
perform_job(MediaIndexingWorker, %{id: channel.id}) perform_job(MediaIndexingWorker, %{id: source.id})
assert [_] = all_enqueued(worker: VideoDownloadWorker) assert [_] = all_enqueued(worker: VideoDownloadWorker)
end end
@ -49,9 +49,9 @@ defmodule Pinchflat.Workers.MediaIndexingWorkerTest do
test "it starts a job for any pending media item even if it's from another run" do 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) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "video1"} end)
channel = channel_fixture(index_frequency_minutes: 10) source = source_fixture(index_frequency_minutes: 10)
media_item_fixture(%{channel_id: channel.id, media_filepath: nil}) media_item_fixture(%{source_id: source.id, media_filepath: nil})
perform_job(MediaIndexingWorker, %{id: channel.id}) perform_job(MediaIndexingWorker, %{id: source.id})
assert [_, _] = all_enqueued(worker: VideoDownloadWorker) assert [_, _] = all_enqueued(worker: VideoDownloadWorker)
end end
@ -59,8 +59,8 @@ defmodule Pinchflat.Workers.MediaIndexingWorkerTest do
test "it does not kick off a job for media items that could not be saved" do 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) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "video1\nvideo1"} end)
channel = channel_fixture(index_frequency_minutes: 10) source = source_fixture(index_frequency_minutes: 10)
perform_job(MediaIndexingWorker, %{id: channel.id}) perform_job(MediaIndexingWorker, %{id: source.id})
# Only one job should be enqueued, since the second video is a duplicate # Only one job should be enqueued, since the second video is a duplicate
assert [_] = all_enqueued(worker: VideoDownloadWorker) assert [_] = all_enqueued(worker: VideoDownloadWorker)
@ -69,41 +69,41 @@ defmodule Pinchflat.Workers.MediaIndexingWorkerTest do
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, fn _url, _opts, _ot -> {:ok, ""} end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
channel = channel_fixture(index_frequency_minutes: 10) source = source_fixture(index_frequency_minutes: 10)
perform_job(MediaIndexingWorker, %{id: channel.id}) perform_job(MediaIndexingWorker, %{id: source.id})
assert_enqueued( assert_enqueued(
worker: MediaIndexingWorker, worker: MediaIndexingWorker,
args: %{"id" => channel.id}, args: %{"id" => source.id},
scheduled_at: now_plus(channel.index_frequency_minutes, :minutes) scheduled_at: now_plus(source.index_frequency_minutes, :minutes)
) )
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, fn _url, _opts, _ot -> {:ok, ""} end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
channel = channel_fixture(index_frequency_minutes: 10) source = source_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
assert_changed([from: 0, to: 1], task_count_fetcher, fn -> assert_changed([from: 0, to: 1], task_count_fetcher, fn ->
perform_job(MediaIndexingWorker, %{id: channel.id}) perform_job(MediaIndexingWorker, %{id: source.id})
end) end)
end end
test "it creates the basic media_item records" do test "it creates the basic media_item records" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "video1\nvideo2"} end) expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "video1\nvideo2"} end)
channel = channel_fixture(index_frequency_minutes: 10) source = source_fixture(index_frequency_minutes: 10)
media_item_fetcher = fn -> media_item_fetcher = fn ->
channel source
|> Repo.preload(:media_items) |> Repo.preload(:media_items)
|> Map.get(:media_items) |> Map.get(:media_items)
|> Enum.map(fn media_item -> media_item.media_id end) |> Enum.map(fn media_item -> media_item.media_id end)
end end
assert_changed([from: [], to: ["video1", "video2"]], media_item_fetcher, fn -> assert_changed([from: [], to: ["video1", "video2"]], media_item_fetcher, fn ->
perform_job(MediaIndexingWorker, %{id: channel.id}) perform_job(MediaIndexingWorker, %{id: source.id})
end) end)
end end
end end

View file

@ -12,7 +12,7 @@ defmodule Pinchflat.Workers.VideoDownloadWorkerTest do
media_item = media_item =
Repo.preload( Repo.preload(
media_item_fixture(%{media_filepath: nil}), media_item_fixture(%{media_filepath: nil}),
[:metadata, channel: :media_profile] [:metadata, source: :media_profile]
) )
{:ok, %{media_item: media_item}} {:ok, %{media_item: media_item}}

View file

@ -1,119 +0,0 @@
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/3)
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/3)
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, _ot) 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,120 @@
defmodule PinchflatWeb.SourceControllerTest 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,
collection_type: "channel",
original_url: "https://www.youtube.com/source/abc123"
},
update_attrs: %{
original_url: "https://www.youtube.com/source/321xyz"
},
invalid_attrs: %{original_url: nil, media_profile_id: nil}
}
}
end
setup :verify_on_exit!
describe "index" do
test "lists all sources", %{conn: conn} do
conn = get(conn, ~p"/media_sources/sources")
assert html_response(conn, 200) =~ "Listing Sources"
end
end
describe "new source" do
test "renders form", %{conn: conn} do
conn = get(conn, ~p"/media_sources/sources/new")
assert html_response(conn, 200) =~ "New Source"
end
end
describe "create source" do
test "redirects to show when data is valid", %{conn: conn, create_attrs: create_attrs} do
expect(YtDlpRunnerMock, :run, 1, &runner_function_mock/3)
conn = post(conn, ~p"/media_sources/sources", source: create_attrs)
assert %{id: id} = redirected_params(conn)
assert redirected_to(conn) == ~p"/media_sources/sources/#{id}"
conn = get(conn, ~p"/media_sources/sources/#{id}")
assert html_response(conn, 200) =~ "Source #{id}"
end
test "renders errors when data is invalid", %{conn: conn, invalid_attrs: invalid_attrs} do
conn = post(conn, ~p"/media_sources/sources", source: invalid_attrs)
assert html_response(conn, 200) =~ "New Source"
end
end
describe "edit source" do
setup [:create_source]
test "renders form for editing chosen source", %{conn: conn, source: source} do
conn = get(conn, ~p"/media_sources/sources/#{source}/edit")
assert html_response(conn, 200) =~ "Edit Source"
end
end
describe "update source" do
setup [:create_source]
test "redirects when data is valid", %{conn: conn, source: source, update_attrs: update_attrs} do
expect(YtDlpRunnerMock, :run, 1, &runner_function_mock/3)
conn = put(conn, ~p"/media_sources/sources/#{source}", source: update_attrs)
assert redirected_to(conn) == ~p"/media_sources/sources/#{source}"
conn = get(conn, ~p"/media_sources/sources/#{source}")
assert html_response(conn, 200) =~ "https://www.youtube.com/source/321xyz"
end
test "renders errors when data is invalid", %{
conn: conn,
source: source,
invalid_attrs: invalid_attrs
} do
conn = put(conn, ~p"/media_sources/sources/#{source}", source: invalid_attrs)
assert html_response(conn, 200) =~ "Edit Source"
end
end
describe "delete source" do
setup [:create_source]
test "deletes chosen source", %{conn: conn, source: source} do
conn = delete(conn, ~p"/media_sources/sources/#{source}")
assert redirected_to(conn) == ~p"/media_sources/sources"
assert_error_sent 404, fn ->
get(conn, ~p"/media_sources/sources/#{source}")
end
end
end
defp create_source(_) do
source = source_fixture()
%{source: source}
end
defp runner_function_mock(_url, _opts, _ot) do
{
:ok,
Phoenix.json_library().encode!(%{
channel: "some name",
channel_id: "some_source_id_#{:rand.uniform(1_000_000)}"
})
}
end
end

View file

@ -16,7 +16,7 @@ defmodule Pinchflat.MediaFixtures do
media_id: Faker.String.base64(12), media_id: Faker.String.base64(12),
title: Faker.Commerce.product_name(), title: Faker.Commerce.product_name(),
media_filepath: "/video/#{Faker.File.file_name(:video)}", media_filepath: "/video/#{Faker.File.file_name(:video)}",
channel_id: MediaSourceFixtures.channel_fixture().id source_id: MediaSourceFixtures.source_fixture().id
}) })
|> Pinchflat.Media.create_media_item() |> Pinchflat.Media.create_media_item()

View file

@ -6,18 +6,19 @@ defmodule Pinchflat.MediaSourceFixtures do
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.ProfilesFixtures alias Pinchflat.ProfilesFixtures
alias Pinchflat.MediaSource.Channel alias Pinchflat.MediaSource.Source
@doc """ @doc """
Generate a channel. Generate a source.
""" """
def channel_fixture(attrs \\ %{}) do def source_fixture(attrs \\ %{}) do
{:ok, channel} = {:ok, source} =
%Channel{} %Source{}
|> Channel.changeset( |> Source.changeset(
Enum.into(attrs, %{ Enum.into(attrs, %{
name: "Channel ##{:rand.uniform(1_000_000)}", name: "Source ##{:rand.uniform(1_000_000)}",
channel_id: Base.encode16(:crypto.hash(:md5, "#{:rand.uniform(1_000_000)}")), collection_id: Base.encode16(:crypto.hash(:md5, "#{:rand.uniform(1_000_000)}")),
collection_type: "channel",
original_url: "https://www.youtube.com/channel/#{Faker.String.base64(12)}", original_url: "https://www.youtube.com/channel/#{Faker.String.base64(12)}",
media_profile_id: ProfilesFixtures.media_profile_fixture().id, media_profile_id: ProfilesFixtures.media_profile_fixture().id,
index_frequency_minutes: 60 index_frequency_minutes: 60
@ -25,6 +26,6 @@ defmodule Pinchflat.MediaSourceFixtures do
) )
|> Repo.insert() |> Repo.insert()
channel source
end end
end end

View file

@ -14,7 +14,7 @@ defmodule Pinchflat.TasksFixtures do
{:ok, task} = {:ok, task} =
attrs attrs
|> Enum.into(%{ |> Enum.into(%{
channel_id: MediaSourceFixtures.channel_fixture().id, source_id: MediaSourceFixtures.source_fixture().id,
job_id: JobFixtures.job_fixture().id job_id: JobFixtures.job_fixture().id
}) })
|> Pinchflat.Tasks.create_task() |> Pinchflat.Tasks.create_task()