Download video(s) (first iteration) (#5)

* Updated package.json (and made an excuse to make a branch)

* Video filepath parser (#6)

* Restructured files; Added parser placeholder

* More restructuring

* Added basic parser for hydrating template strings

* Improved docs

* More docs

* Initial implementation of media profiles (#7)

* [WIP] Added basic video download method

* [WIP] Very-WIP first steps at parsing options and downloading

* Made my options safe by default and removed special safe versions

* Ran html generator for mediaprofile model - leaving as-is for now

* Addressed a bunch of TODO comments

* 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

* Index a channel (#9)

* Ran a MediaItem generator; Reformatted to my liking

* [WIP] added basic index function

* setup oban

* Added basic Oban job for indexing

* Added in workers for indexing; hooked them into record creation flow

* Added a task model with a phx generator

* Tied together tasks with jobs and channels

* Download indexed videos (#10)

* Clarified documentation

* more comments

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

* Added metadata model and parsing

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

* Added tests for video download worker

* Hooked up video downloading to the channel indexing pipeline

* Adds tasks for media items

* Updated video metadata parser to extract the title

* Ran linting
This commit is contained in:
Kieran
2024-01-30 20:15:59 -08:00
committed by GitHub
parent fee39151a8
commit a5e7c48f70
96 changed files with 9358 additions and 158 deletions
+1
View File
@@ -10,6 +10,7 @@ defmodule Pinchflat.Application do
children = [
PinchflatWeb.Telemetry,
Pinchflat.Repo,
{Oban, Application.fetch_env!(:pinchflat, Oban)},
{DNSCluster, query: Application.get_env(:pinchflat, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Pinchflat.PubSub},
# Start the Finch HTTP client for sending emails
@@ -1,7 +0,0 @@
defmodule Pinchflat.DownloaderBackends.BackendCommandRunner do
@moduledoc """
A behaviour for running CLI commands against a downloader backend
"""
@callback run(binary(), keyword()) :: {:ok, binary()} | {:error, binary(), integer()}
end
@@ -1,58 +0,0 @@
defmodule Pinchflat.DownloaderBackends.YtDlp.CommandRunner do
@moduledoc """
Runs yt-dlp commands using the `System.cmd/3` function
"""
alias Pinchflat.Utils.StringUtils
alias Pinchflat.DownloaderBackends.BackendCommandRunner
@behaviour BackendCommandRunner
@doc """
Runs a yt-dlp command and returns the string output
"""
@impl BackendCommandRunner
def run(url, command_opts) do
command = backend_executable()
formatted_command_opts = parse_options(command_opts) ++ [url]
case System.cmd(command, formatted_command_opts, stderr_to_stdout: true) do
{output, 0} -> {:ok, output}
{output, status} -> {:error, output, status}
end
end
# We want to satisfy the following behaviours:
#
# 1. If the key is an atom, convert it to a string and convert it to kebab case (for convenience)
# 2. If the key is a string, assume we want it as-is and don't convert it
# 3. If the key is accompanied by a value, append the value to the list
# 4. If the key is not accompanied by a value, assume it's a flag and PREpend it to the list
defp parse_options(command_opts) do
Enum.reduce(command_opts, [], &parse_option/2)
end
defp parse_option({k, v}, acc) when is_atom(k) do
stringified_key = StringUtils.to_kebab_case(Atom.to_string(k))
parse_option({"--#{stringified_key}", v}, acc)
end
defp parse_option({k, v}, acc) when is_binary(k) do
acc ++ [k, to_string(v)]
end
defp parse_option(arg, acc) when is_atom(arg) do
stringified_arg = StringUtils.to_kebab_case(Atom.to_string(arg))
parse_option("--#{stringified_arg}", acc)
end
defp parse_option(arg, acc) when is_binary(arg) do
[arg | acc]
end
defp backend_executable do
Application.get_env(:pinchflat, :yt_dlp_executable)
end
end
@@ -1,21 +0,0 @@
defmodule Pinchflat.DownloaderBackends.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
+75
View File
@@ -0,0 +1,75 @@
defmodule Pinchflat.Media do
@moduledoc """
The Media context.
"""
import Ecto.Query, warn: false
alias Pinchflat.Repo
alias Pinchflat.Tasks
alias Pinchflat.Media.MediaItem
alias Pinchflat.MediaSource.Channel
@doc """
Returns the list of media_items. Returns [%MediaItem{}, ...].
"""
def list_media_items do
Repo.all(MediaItem)
end
@doc """
Returns a list of pending media_items for a given channel, where
pending means the `video_filepath` is `nil`.
Returns [%MediaItem{}, ...].
"""
def list_pending_media_items_for(%Channel{} = channel) do
from(
m in MediaItem,
where: m.channel_id == ^channel.id and is_nil(m.video_filepath)
)
|> Repo.all()
end
@doc """
Gets a single media_item.
Returns %MediaItem{}. Raises `Ecto.NoResultsError` if the Media item does not exist.
"""
def get_media_item!(id), do: Repo.get!(MediaItem, id)
@doc """
Creates a media_item. Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}.
"""
def create_media_item(attrs) do
%MediaItem{}
|> MediaItem.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a media_item. Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}.
"""
def update_media_item(%MediaItem{} = media_item, attrs) do
media_item
|> MediaItem.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a media_item and its associated tasks.
Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}.
"""
def delete_media_item(%MediaItem{} = media_item) do
Tasks.delete_tasks_for(media_item)
Repo.delete(media_item)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking media_item changes.
"""
def change_media_item(%MediaItem{} = media_item, attrs \\ %{}) do
MediaItem.changeset(media_item, attrs)
end
end
+38
View File
@@ -0,0 +1,38 @@
defmodule Pinchflat.Media.MediaItem do
@moduledoc """
The MediaItem schema.
"""
use Ecto.Schema
import Ecto.Changeset
alias Pinchflat.Tasks.Task
alias Pinchflat.MediaSource.Channel
alias Pinchflat.Media.MediaMetadata
@required_fields ~w(media_id channel_id)a
@allowed_fields ~w(title media_id video_filepath channel_id)a
schema "media_items" do
field :title, :string
field :media_id, :string
field :video_filepath, :string
belongs_to :channel, Channel
has_one :metadata, MediaMetadata, on_replace: :update
has_many :tasks, Task
timestamps(type: :utc_datetime)
end
@doc false
def changeset(media_item, attrs) do
media_item
|> cast(attrs, @allowed_fields)
|> cast_assoc(:metadata, with: &MediaMetadata.changeset/2, required: false)
|> validate_required(@required_fields)
|> unique_constraint([:media_id, :channel_id])
end
end
+28
View File
@@ -0,0 +1,28 @@
defmodule Pinchflat.Media.MediaMetadata do
@moduledoc """
The MediaMetadata schema.
Look. Don't @ me about Metadata vs. Metadatum. I'm very sensitive.
"""
use Ecto.Schema
import Ecto.Changeset
alias Pinchflat.Media.MediaItem
schema "media_metadata" do
field :client_response, :map
belongs_to :media_item, MediaItem
timestamps(type: :utc_datetime)
end
@doc false
def changeset(media_metadata, attrs) do
media_metadata
|> cast(attrs, [:client_response])
|> validate_required([:client_response])
|> unique_constraint([:media_item_id])
end
end
@@ -0,0 +1,7 @@
defmodule Pinchflat.MediaClient.Backends.BackendCommandRunner do
@moduledoc """
A behaviour for running CLI commands against a downloader backend
"""
@callback run(binary(), keyword(), binary()) :: {:ok, binary()} | {:error, binary(), integer()}
end
@@ -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_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
@@ -0,0 +1,90 @@
defmodule Pinchflat.MediaClient.Backends.YtDlp.CommandRunner do
@moduledoc """
Runs yt-dlp commands using the `System.cmd/3` function
"""
require Logger
alias Pinchflat.Utils.StringUtils
alias Pinchflat.MediaClient.Backends.BackendCommandRunner
@behaviour BackendCommandRunner
@doc """
Runs a yt-dlp command and returns the string output. Saves the output to
a file and then returns its contents because yt-dlp will return warnings
to stdout even if the command is successful, but these will break JSON parsing.
Returns {:ok, binary()} | {:error, output, status}.
IDEA: Indexing takes a long time, but the output is actually streamed to stdout.
Maybe we could listen to that stream instead so we can index videos as they're discovered.
See: https://stackoverflow.com/a/49061086/5665799
"""
@impl BackendCommandRunner
def run(url, command_opts, output_template) do
command = backend_executable()
# These must stay in exactly this order, hence why I'm giving it its own variable.
# Also, can't use RAM file since yt-dlp needs a concrete filepath.
json_output_path = generate_json_output_path()
print_to_file_opts = [{:print_to_file, output_template}, json_output_path]
formatted_command_opts = [url] ++ parse_options(command_opts ++ print_to_file_opts)
Logger.debug("[yt-dlp] called with: #{Enum.join(formatted_command_opts, " ")}")
case System.cmd(command, formatted_command_opts, stderr_to_stdout: true) do
{_, 0} ->
# IDEA: consider deleting the file after reading it
# (even on error? especially on error?)
File.read(json_output_path)
{output, status} ->
{:error, output, status}
end
end
defp generate_json_output_path do
metadata_directory = Application.get_env(:pinchflat, :metadata_directory)
filepath = Path.join([metadata_directory, "#{StringUtils.random_string(64)}.json"])
# Ensure the file can be created and written to BEFORE we run the `yt-dlp` command
:ok = File.mkdir_p!(Path.dirname(filepath))
:ok = File.write(filepath, "")
filepath
end
# We want to satisfy the following behaviours:
#
# 1. If the key is an atom, convert it to a string and convert it to kebab case (for convenience)
# 2. If the key is a string, assume we want it as-is and don't convert it
# 3. If the key is accompanied by a value, append the value to the list
# 4. If the key is not accompanied by a value, assume it's a flag and PREpend it to the list
defp parse_options(command_opts) do
Enum.reduce(command_opts, [], &parse_option/2)
end
defp parse_option({k, v}, acc) when is_atom(k) do
stringified_key = StringUtils.to_kebab_case(Atom.to_string(k))
parse_option({"--#{stringified_key}", v}, acc)
end
defp parse_option({k, v}, acc) when is_binary(k) do
acc ++ [k, to_string(v)]
end
defp parse_option(arg, acc) when is_atom(arg) do
stringified_arg = StringUtils.to_kebab_case(Atom.to_string(arg))
parse_option("--#{stringified_arg}", acc)
end
defp parse_option(arg, acc) when is_binary(arg) do
acc ++ [arg]
end
defp backend_executable do
Application.get_env(:pinchflat, :yt_dlp_executable)
end
end
@@ -0,0 +1,27 @@
defmodule Pinchflat.MediaClient.Backends.YtDlp.MetadataParser do
@moduledoc """
yt-dlp offers a LOT of metadata in its JSON response, some of which
needs to be extracted and included in various models.
For now, also squirrel all of it away in the `media_metadata` table.
I might revisit this or pare it down later, but I'd rather need it
and not have it, ya know?
"""
@doc """
Parses the given JSON response from yt-dlp and returns a map of
the needful media_item attributes, along with anything needed for
its associations.
Returns map()
"""
def parse_for_media_item(metadata) do
%{
title: metadata["title"],
video_filepath: metadata["filepath"],
metadata: %{
client_response: metadata
}
}
end
end
@@ -0,0 +1,26 @@
defmodule Pinchflat.MediaClient.Backends.YtDlp.Video do
@moduledoc """
Contains utilities for working with singular videos
"""
@doc """
Downloads a single video (and possibly its 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] ++ command_opts
with {:ok, output} <- backend_runner().run(url, opts, "after_move:%()j"),
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
{:ok, parsed_json}
else
err -> err
end
end
defp backend_runner do
Application.get_env(:pinchflat, :yt_dlp_runner)
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]
case runner.run(url, opts, "%(id)s") do
{:ok, output} -> {:ok, String.split(output, "\n", trim: true)}
res -> res
end
end
end
end
end
@@ -0,0 +1,41 @@
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
@@ -0,0 +1,70 @@
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
to download the video with the desired options.
Technically hardcodes the yt-dlp backend for now, but should leave
it open-ish for future expansion (just in case).
"""
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Media.MediaItem
alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.MediaClient.Backends.YtDlp.Video, as: YtDlpVideo
alias Pinchflat.Profiles.Options.YtDlp.OptionBuilder, as: YtDlpOptionBuilder
alias Pinchflat.MediaClient.Backends.YtDlp.MetadataParser, as: YtDlpMetadataParser
@doc """
Downloads a video for a media item, updating the media item based on the metadata
returned by the backend. Also saves the entire metadata response to the associated
media_metadata record.
Returns {:ok, %MediaItem{}} | {:error, any, ...any}
"""
def download_for_media_item(%MediaItem{} = media_item, backend \\ :yt_dlp) do
item_with_preloads = Repo.preload(media_item, [:metadata, channel: :media_profile])
media_profile = item_with_preloads.channel.media_profile
case download_for_media_profile(media_item.media_id, media_profile, backend) do
{:ok, parsed_json} ->
parser = metadata_parser(backend)
parsed_attrs = parser.parse_for_media_item(parsed_json)
# Don't forgor to use preloaded associations or updates to
# associations won't work!
Media.update_media_item(item_with_preloads, parsed_attrs)
err ->
err
end
end
defp download_for_media_profile(url, %MediaProfile{} = media_profile, backend) do
option_builder = option_builder(backend)
video_backend = video_backend(backend)
{:ok, options} = option_builder.build(media_profile)
video_backend.download(url, options)
end
defp option_builder(backend) do
case backend do
:yt_dlp -> YtDlpOptionBuilder
end
end
defp video_backend(backend) do
case backend do
:yt_dlp -> YtDlpVideo
end
end
defp metadata_parser(backend) do
case backend do
:yt_dlp -> YtDlpMetadataParser
end
end
end
+163
View File
@@ -0,0 +1,163 @@
defmodule Pinchflat.MediaSource do
@moduledoc """
The MediaSource context.
"""
import Ecto.Query, warn: false
alias Pinchflat.Repo
alias Pinchflat.Tasks
alias Pinchflat.Media
alias Pinchflat.Tasks.ChannelTasks
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. May attempt to pull additional channel details from the
original_url (if provided). Will attempt to start indexing the channel's
media if successfully inserted.
Returns {:ok, %Channel{}} | {:error, %Ecto.Changeset{}}
"""
def create_channel(attrs) do
%Channel{}
|> change_channel_from_url(attrs)
|> commit_and_start_indexing()
end
@doc """
Given a media source, creates (indexes) the media by creating media_items for each
media ID in the source.
Returns [%MediaItem{}, ...] | [%Ecto.Changeset{}, ...]
"""
def index_media_items(%Channel{} = channel) do
{:ok, media_ids} = ChannelDetails.get_video_ids(channel.original_url)
media_ids
|> Enum.map(fn media_id ->
attrs = %{channel_id: channel.id, media_id: media_id}
case Media.create_media_item(attrs) do
{:ok, media_item} -> media_item
{:error, changeset} -> changeset
end
end)
end
@doc """
Updates a channel. May attempt to pull additional channel details from the
original_url (if changed). May attempt to start indexing the channel's
media if the indexing frequency has been changed.
Existing indexing tasks will be cancelled if the indexing frequency has been
changed (logic in `ChannelTasks.kickoff_indexing_task`)
Returns {:ok, %Channel{}} | {:error, %Ecto.Changeset{}}
"""
def update_channel(%Channel{} = channel, attrs) do
channel
|> change_channel_from_url(attrs)
|> commit_and_start_indexing()
end
@doc """
Deletes a channel and it's associated tasks (of any state).
Returns {:ok, %Channel{}} | {:error, %Ecto.Changeset{}}
"""
def delete_channel(%Channel{} = channel) do
Tasks.delete_tasks_for(channel)
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
defp commit_and_start_indexing(changeset) do
case Repo.insert_or_update(changeset) do
{:ok, %Channel{} = channel} ->
maybe_run_indexing_task(changeset, channel)
{:ok, channel}
err ->
err
end
end
defp maybe_run_indexing_task(changeset, channel) do
case changeset.data do
# If the changeset is new (not persisted), attempt indexing no matter what
%{__meta__: %{state: :built}} ->
ChannelTasks.kickoff_indexing_task(channel)
# If the record has been persisted, only attempt indexing if the
# indexing frequency has been changed
%{__meta__: %{state: :loaded}} ->
if Map.has_key?(changeset.changes, :index_frequency_minutes) do
ChannelTasks.kickoff_indexing_task(channel)
end
end
end
end
+37
View File
@@ -0,0 +1,37 @@
defmodule Pinchflat.MediaSource.Channel do
@moduledoc """
The Channel schema.
"""
use Ecto.Schema
import Ecto.Changeset
alias Pinchflat.Media.MediaItem
alias Pinchflat.Profiles.MediaProfile
@allowed_fields ~w(name channel_id index_frequency_minutes original_url media_profile_id)a
@required_fields @allowed_fields -- ~w(index_frequency_minutes)a
schema "channels" do
field :name, :string
field :channel_id, :string
field :index_frequency_minutes, :integer
# 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
has_many :media_items, MediaItem
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
+56
View File
@@ -0,0 +1,56 @@
defmodule Pinchflat.Profiles do
@moduledoc """
The Profiles context.
"""
import Ecto.Query, warn: false
alias Pinchflat.Repo
alias Pinchflat.Profiles.MediaProfile
@doc """
Returns the list of media_profiles. Returns [%MediaProfile{}, ...]
"""
def list_media_profiles do
Repo.all(MediaProfile)
end
@doc """
Gets a single media_profile.
Returns %MediaProfile{}. Raises `Ecto.NoResultsError` if the Media profile does not exist.
"""
def get_media_profile!(id), do: Repo.get!(MediaProfile, id)
@doc """
Creates a media_profile. Returns {:ok, %MediaProfile{}} | {:error, %Ecto.Changeset{}}
"""
def create_media_profile(attrs) do
%MediaProfile{}
|> MediaProfile.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a media_profile. Returns {:ok, %MediaProfile{}} | {:error, %Ecto.Changeset{}}
"""
def update_media_profile(%MediaProfile{} = media_profile, attrs) do
media_profile
|> MediaProfile.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a media_profile. Returns {:ok, %MediaProfile{}} | {:error, %Ecto.Changeset{}}
"""
def delete_media_profile(%MediaProfile{} = media_profile) do
Repo.delete(media_profile)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking media_profile changes.
"""
def change_media_profile(%MediaProfile{} = media_profile, attrs \\ %{}) do
MediaProfile.changeset(media_profile, attrs)
end
end
+27
View File
@@ -0,0 +1,27 @@
defmodule Pinchflat.Profiles.MediaProfile do
@moduledoc """
A media profile is a set of settings that can be applied to many media sources
"""
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
@doc false
def changeset(media_profile, attrs) do
media_profile
|> cast(attrs, [:name, :output_path_template])
|> validate_required([:name, :output_path_template])
|> unique_constraint(:name)
end
end
@@ -0,0 +1,39 @@
defmodule Pinchflat.Profiles.Options.YtDlp.OptionBuilder do
@moduledoc """
Builds the options for yt-dlp based on the given media profile.
IDEA: consider making this a behaviour so I can add other backends later
"""
alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.Profiles.Options.YtDlp.OutputPathBuilder
@doc """
Builds the options for yt-dlp based on the given media profile.
IDEA: consider adding the ability to pass in a second argument to override
these options
"""
def build(%MediaProfile{} = media_profile) do
{:ok, output_path} = OutputPathBuilder.build(media_profile.output_path_template)
# 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,
[
:embed_metadata,
:embed_thumbnail,
:embed_subs,
:no_progress,
sub_langs: "en.*",
output: Path.join(base_directory(), output_path)
]}
end
defp base_directory do
Application.get_env(:pinchflat, :media_directory)
end
end
@@ -0,0 +1,64 @@
defmodule Pinchflat.Profiles.Options.YtDlp.OutputPathBuilder do
@moduledoc """
Builds yt-dlp-friendly output paths for downloaded media
IDEA: consider making this a behaviour so I can add other backends later
"""
alias Pinchflat.RenderedString.Parser, as: TemplateParser
@doc """
Builds the actual final filepath from a given template.
Translates liquid-style templates into yt-dlp-style templates,
leaving yt-dlp syntax intact.
"""
def build(template_string) do
TemplateParser.parse(template_string, full_yt_dlp_options_map())
end
defp full_yt_dlp_options_map do
Map.merge(
standard_yt_dlp_option_map(),
custom_yt_dlp_option_map()
)
end
defp standard_yt_dlp_option_map do
%{
# Uppercase "S" means "safe" - ie: filepath-safe
"id" => "%(id)S",
"ext" => "%(ext)S",
"title" => "%(title)S",
"fulltitle" => "%(fulltitle)S",
"uploader" => "%(uploader)S",
"creator" => "%(creator)S",
"upload_date" => "%(upload_date)S",
"release_date" => "%(release_date)S",
"duration" => "%(duration)S",
# For videos classified as an episode of a series:
"series" => "%(series)S",
"season" => "%(season)S",
"season_number" => "%(season_number)S",
"episode" => "%(episode)S",
"episode_number" => "%(episode_number)S",
"episode_id" => "%(episode_id)S",
# For videos classified as music:
"track" => "%(track)S",
"track_number" => "%(track_number)S",
"artist" => "%(artist)S",
"album" => "%(album)S",
"album_type" => "%(album_type)S",
"genre" => "%(genre)S"
}
end
defp custom_yt_dlp_option_map do
%{
# Individual parts of the upload date
"upload_year" => "%(upload_date>%Y)S",
"upload_month" => "%(upload_date>%m)S",
"upload_day" => "%(upload_date>%d)S"
}
end
end
+52
View File
@@ -0,0 +1,52 @@
defmodule Pinchflat.RenderedString.Base do
@moduledoc """
A base module for parsing rendered strings, designed as a macro to be used
in other modules. See https://elixirforum.com/t/help-to-parse-a-template-with-nimbleparsec/47980
NOTE: if the needs here get any more complicated, look into using a Liquid
template parser. No need to reinvent the wheel any more than I already have.
NOTE: this is effectively tested by the `Pinchflat.RenderedString.Parser`'s tests
"""
defmacro __using__(_opts) do
quote location: :keep do
import NimbleParsec
opening_tag = string("{{")
closing_tag = string("}}")
optional_whitespaces = ascii_string(~c[ \t\n\r], min: 0)
# Capture everything up to the opening object
text =
lookahead_not(opening_tag)
# ... as long as it's a character
|> utf8_char([])
# ... and there's at least one character
|> times(min: 1)
# ... and then convert it to a string
|> reduce({List, :to_string, []})
# ... finally bag it and tag it
|> unwrap_and_tag(:text)
identifier =
utf8_string([?a..?z, ?A..?Z, ?_, ?0..?9], min: 1)
|> reduce({Enum, :join, []})
|> unwrap_and_tag(:identifier)
defparsecp(:expression, identifier)
# when spotting interpolation, ignore the opening tag and any whitespace
interpolation =
ignore(concat(opening_tag, optional_whitespaces))
# ... then parse the expression (identifier)
|> parsec(:expression)
# ... then ignore any whitespace and the closing tags after the expression
|> ignore(concat(optional_whitespaces, closing_tag))
# ... once again we bag it and tag it
|> unwrap_and_tag(:interpolation)
defparsec(:do_parse, choice([interpolation, text]) |> repeat() |> eos())
end
end
end
+35
View File
@@ -0,0 +1,35 @@
defmodule Pinchflat.RenderedString.Parser do
@moduledoc """
Parses liquid-ish-style strings into a rendered string
Used for turning filepath templates into real filepaths
"""
use Pinchflat.RenderedString.Base
@doc """
Parses a string into a rendered string, using the provided variables.
Variable identifiers are surrounded by {{ and }}. The variable keys MUST be strings.
If an identifier is not found in the provided variables, it will be removed from the string.
"""
def parse(string, variables) do
# `do_parse` comes from `RenderedString.Base`
case do_parse(string) do
{:ok, parsed, _, _, _, _} ->
{:ok, build_string(parsed, variables)}
{:error, message, _, _, _, _} ->
{:error, message}
end
end
defp build_string(parsed, variables) do
Enum.reduce(parsed, "", fn element, acc ->
case element do
{:text, text} -> acc <> text
{:interpolation, {:identifier, identifier}} -> acc <> to_string(variables[identifier])
end
end)
end
end
+14
View File
@@ -2,4 +2,18 @@ defmodule Pinchflat.Repo do
use Ecto.Repo,
otp_app: :pinchflat,
adapter: Ecto.Adapters.Postgres
@doc """
It's not immediately obvious if an Oban job qualifies as unique, so this method
attempts creating a job and checks for the `conflict?` field in the returned job.
Returns {:ok, %Oban.Job{}} | {:duplicate, %Oban.Job{}} | {:error, any()}.
"""
def insert_unique_job(job_struct) do
case Oban.insert(job_struct) do
{:ok, %Oban.Job{conflict?: false} = job} -> {:ok, job}
{:ok, %Oban.Job{conflict?: true} = job} -> {:duplicate, job}
err -> err
end
end
end
+148
View File
@@ -0,0 +1,148 @@
defmodule Pinchflat.Tasks do
@moduledoc """
The Tasks context.
"""
import Ecto.Query, warn: false
alias Pinchflat.Repo
alias Pinchflat.Tasks.Task
alias Pinchflat.Media.MediaItem
alias Pinchflat.MediaSource.Channel
@doc """
Returns the list of tasks. Returns [%Task{}, ...]
"""
def list_tasks do
Repo.all(Task)
end
@doc """
Returns the list of tasks for a given record type and ID. Optionally allows you to specify
which job states to include.
Returns [%Task{}, ...]
"""
def list_tasks_for(attached_record_type, attached_record_id, job_states \\ Oban.Job.states()) do
stringified_states = Enum.map(job_states, &to_string/1)
Repo.all(
from t in Task,
join: j in assoc(t, :job),
where: field(t, ^attached_record_type) == ^attached_record_id,
where: j.state in ^stringified_states
)
end
@doc """
Returns the list of pending tasks for a given record type and ID.
Returns [%Task{}, ...]
"""
def list_pending_tasks_for(attached_record_type, attached_record_id) do
list_tasks_for(
attached_record_type,
attached_record_id,
[:available, :scheduled, :retryable]
)
end
@doc """
Gets a single task.
Returns %Task{}. Raises `Ecto.NoResultsError` if the Task does not exist.
"""
def get_task!(id), do: Repo.get!(Task, id)
@doc """
Creates a task.
Accepts map() | %Oban.Job{}, %Channel{} | %Oban.Job{}, %MediaItem{}.
Returns {:ok, %Task{}} | {:error, %Ecto.Changeset{}}.
"""
def create_task(attrs) do
%Task{}
|> Task.changeset(attrs)
|> Repo.insert()
end
# This function's signature is designed to help simplify
# usage of `create_job_with_task/2`
def create_task(%Oban.Job{} = job, attached_record) do
attached_record_attr =
case attached_record do
%Channel{} = channel -> %{channel_id: channel.id}
%MediaItem{} = media_item -> %{media_item_id: media_item.id}
end
%Task{}
|> Task.changeset(Map.merge(%{job_id: job.id}, attached_record_attr))
|> Repo.insert()
end
@doc """
Creates a job from given attrs, creating a task with an attached record
if successful. Returns an error if the job already exists.
Returns {:ok, %Task{}} | {:error, :duplicate_job} | {:error, %Ecto.Changeset{}}.
"""
def create_job_with_task(job_attrs, task_attached_record) do
case Repo.insert_unique_job(job_attrs) do
{:ok, job} -> create_task(job, task_attached_record)
{:duplicate, _} -> {:error, :duplicate_job}
err -> err
end
end
@doc """
Deletes a task. Also cancels any attached job.
Returns {:ok, %Task{}} | {:error, %Ecto.Changeset{}}.
"""
def delete_task(%Task{} = task) do
:ok = Oban.cancel_job(task.job_id)
Repo.delete(task)
end
@doc """
Deletes all tasks attached to a given record, cancelling any attached jobs.
Returns :ok
"""
def delete_tasks_for(attached_record) do
tasks =
case attached_record do
%Channel{} = channel -> list_tasks_for(:channel_id, channel.id)
%MediaItem{} = media_item -> list_tasks_for(:media_item_id, media_item.id)
end
Enum.each(tasks, fn task ->
delete_task(task)
end)
end
@doc """
Deletes all _pending_ tasks attached to a given record, cancelling any attached jobs.
Returns :ok
"""
def delete_pending_tasks_for(attached_record) do
tasks =
case attached_record do
%Channel{} = channel -> list_pending_tasks_for(:channel_id, channel.id)
%MediaItem{} = media_item -> list_pending_tasks_for(:media_item_id, media_item.id)
end
Enum.each(tasks, fn task ->
delete_task(task)
end)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking task changes.
"""
def change_task(%Task{} = task, attrs \\ %{}) do
Task.changeset(task, attrs)
end
end
+33
View File
@@ -0,0 +1,33 @@
defmodule Pinchflat.Tasks.ChannelTasks do
@moduledoc """
This module contains methods for managing tasks (workers) related to channels.
"""
alias Pinchflat.Tasks
alias Pinchflat.MediaSource.Channel
alias Pinchflat.Workers.MediaIndexingWorker
@doc """
Starts tasks for indexing a channel's media.
Returns {:ok, :should_not_index} | {:ok, %Task{}}.
"""
def kickoff_indexing_task(%Channel{} = channel) do
Tasks.delete_pending_tasks_for(channel)
if channel.index_frequency_minutes <= 0 do
{:ok, :should_not_index}
else
channel
|> Map.take([:id])
# Schedule this one immediately, but future ones will be on an interval
|> MediaIndexingWorker.new()
|> Tasks.create_job_with_task(channel)
|> case do
# This should never return {:error, :duplicate_job} since we just deleted
# any pending tasks. I'm being assertive about it so it's obvious if I'm wrong
{:ok, task} -> {:ok, task}
end
end
end
end
+26
View File
@@ -0,0 +1,26 @@
defmodule Pinchflat.Tasks.Task do
@moduledoc """
The Task schema.
"""
use Ecto.Schema
import Ecto.Changeset
alias Pinchflat.Media.MediaItem
alias Pinchflat.MediaSource.Channel
schema "tasks" do
belongs_to :job, Oban.Job
belongs_to :channel, Channel
belongs_to :media_item, MediaItem
timestamps(type: :utc_datetime)
end
@doc false
def changeset(task, attrs) do
task
|> cast(attrs, [:job_id, :channel_id, :media_item_id])
|> validate_required([:job_id])
end
end
@@ -5,10 +5,23 @@ defmodule Pinchflat.Utils.StringUtils do
@doc """
Converts a string to kebab-case (ie: `hello world` -> `hello-world`)
Returns binary()
"""
def to_kebab_case(string) do
string
|> String.replace(~r/[\s_]/, "-")
|> String.downcase()
end
@doc """
Returns a random string of the given length. Base 16 encoded, lower case.
Returns binary()
"""
def random_string(length \\ 32) do
:crypto.strong_rand_bytes(length)
|> Base.encode16(case: :lower)
|> String.slice(0..(length - 1))
end
end
@@ -0,0 +1,80 @@
defmodule Pinchflat.Workers.MediaIndexingWorker do
@moduledoc false
use Oban.Worker,
queue: :media_indexing,
unique: [period: :infinity, states: [:available, :scheduled, :retryable]],
tags: ["media_source", "media_indexing"]
alias __MODULE__
alias Pinchflat.Media
alias Pinchflat.Tasks
alias Pinchflat.MediaSource
alias Pinchflat.Workers.VideoDownloadWorker
@impl Oban.Worker
@doc """
The ID is that of a channel _record_, not a YouTube channel ID. Indexes
the provided channel, kicks off downloads for each new MediaItem, and
reschedules the job to run again in the future (as determined by the
channel's `index_frequency_minutes` field).
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_.
This has some benefits but also side effects to be aware of:
- Benefit: No chance for jobs to overlap if a job takes longer than the
scheduled interval. Less likely to hit API rate limits.
- Side effect: Intervals are "soft" and _always_ walk forward. This may cause
user confusion since a 30-minute job scheduled for every hour will
actually run every 1 hour and 30 minutes. The tradeoff of not inundating
the API with requests and also not overlapping jobs is worth it, IMO.
NOTE: Since indexing can take a LONG time, I should check what happens if an
application restart occurs while a job is running. Will the job be lost?
IDEA: Should I use paging and do indexing in chunks? Is that even faster?
Returns :ok | {:ok, %Task{}}
"""
def perform(%Oban.Job{args: %{"id" => channel_id}}) do
channel = MediaSource.get_channel!(channel_id)
if channel.index_frequency_minutes <= 0 do
:ok
else
index_media_and_reschedule(channel)
end
end
defp index_media_and_reschedule(channel) do
MediaSource.index_media_items(channel)
enqueue_video_downloads(channel)
channel
|> Map.take([:id])
|> MediaIndexingWorker.new(schedule_in: channel.index_frequency_minutes * 60)
|> Tasks.create_job_with_task(channel)
|> case do
{:ok, task} -> {:ok, task}
{:error, :duplicate_job} -> {:ok, :job_exists}
end
end
# NOTE: this starts a download for each media item that is pending,
# not just the ones that were indexed in this job run. This should ensure
# that any stragglers are caught if, for some reason, they weren't enqueued
# or somehow got de-queued.
#
# I'm not sure of a case where this would happen, but it's cheap insurance.
defp enqueue_video_downloads(channel) do
channel
|> Media.list_pending_media_items_for()
|> Enum.each(fn media_item ->
media_item
|> Map.take([:id])
|> VideoDownloadWorker.new()
|> Tasks.create_job_with_task(media_item)
end)
end
end
@@ -0,0 +1,26 @@
defmodule Pinchflat.Workers.VideoDownloadWorker do
@moduledoc false
use Oban.Worker,
queue: :media_fetching,
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
tags: ["media_item", "media_fetching"]
alias Pinchflat.Media
alias Pinchflat.MediaClient.VideoDownloader
@impl Oban.Worker
@doc """
For a given media item, download the video and save the metadata.
Returns {:ok, %MediaItem{}} | {:error, any, ...any}
"""
def perform(%Oban.Job{args: %{"id" => media_item_id}}) do
media_item = Media.get_media_item!(media_item_id)
case VideoDownloader.download_for_media_item(media_item) do
{:ok, _} -> {:ok, media_item}
err -> err
end
end
end
@@ -0,0 +1,62 @@
defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
use PinchflatWeb, :controller
alias Pinchflat.Profiles
alias Pinchflat.Profiles.MediaProfile
def index(conn, _params) do
media_profiles = Profiles.list_media_profiles()
render(conn, :index, media_profiles: media_profiles)
end
def new(conn, _params) do
changeset = Profiles.change_media_profile(%MediaProfile{})
render(conn, :new, changeset: changeset)
end
def create(conn, %{"media_profile" => media_profile_params}) do
case Profiles.create_media_profile(media_profile_params) do
{:ok, media_profile} ->
conn
|> put_flash(:info, "Media profile created successfully.")
|> redirect(to: ~p"/media_profiles/#{media_profile}")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new, changeset: changeset)
end
end
def show(conn, %{"id" => id}) do
media_profile = Profiles.get_media_profile!(id)
render(conn, :show, media_profile: media_profile)
end
def edit(conn, %{"id" => id}) do
media_profile = Profiles.get_media_profile!(id)
changeset = Profiles.change_media_profile(media_profile)
render(conn, :edit, media_profile: media_profile, changeset: changeset)
end
def update(conn, %{"id" => id, "media_profile" => media_profile_params}) do
media_profile = Profiles.get_media_profile!(id)
case Profiles.update_media_profile(media_profile, media_profile_params) do
{:ok, media_profile} ->
conn
|> put_flash(:info, "Media profile updated successfully.")
|> redirect(to: ~p"/media_profiles/#{media_profile}")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :edit, media_profile: media_profile, changeset: changeset)
end
end
def delete(conn, %{"id" => id}) do
media_profile = Profiles.get_media_profile!(id)
{:ok, _media_profile} = Profiles.delete_media_profile(media_profile)
conn
|> put_flash(:info, "Media profile deleted successfully.")
|> redirect(to: ~p"/media_profiles")
end
end
@@ -0,0 +1,13 @@
defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
use PinchflatWeb, :html
embed_templates "media_profile_html/*"
@doc """
Renders a media_profile form.
"""
attr :changeset, Ecto.Changeset, required: true
attr :action, :string, required: true
def media_profile_form(assigns)
end
@@ -0,0 +1,8 @@
<.header>
Edit Media profile <%= @media_profile.id %>
<:subtitle>Use this form to manage media_profile records in your database.</:subtitle>
</.header>
<.media_profile_form changeset={@changeset} action={~p"/media_profiles/#{@media_profile}"} />
<.back navigate={~p"/media_profiles"}>Back to media_profiles</.back>
@@ -0,0 +1,30 @@
<.header>
Listing Media profiles
<:actions>
<.link href={~p"/media_profiles/new"}>
<.button>New Media profile</.button>
</.link>
</:actions>
</.header>
<.table
id="media_profiles"
rows={@media_profiles}
row_click={&JS.navigate(~p"/media_profiles/#{&1}")}
>
<:col :let={media_profile} label="Name"><%= media_profile.name %></:col>
<:col :let={media_profile} label="Output path template">
<%= media_profile.output_path_template %>
</:col>
<:action :let={media_profile}>
<div class="sr-only">
<.link navigate={~p"/media_profiles/#{media_profile}"}>Show</.link>
</div>
<.link navigate={~p"/media_profiles/#{media_profile}/edit"}>Edit</.link>
</:action>
<:action :let={media_profile}>
<.link href={~p"/media_profiles/#{media_profile}"} method="delete" data-confirm="Are you sure?">
Delete
</.link>
</:action>
</.table>
@@ -0,0 +1,10 @@
<.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[:name]} type="text" label="Name" />
<.input field={f[:output_path_template]} type="text" label="Output path template" />
<:actions>
<.button>Save Media profile</.button>
</:actions>
</.simple_form>
@@ -0,0 +1,8 @@
<.header>
New Media profile
<:subtitle>Use this form to manage media_profile records in your database.</:subtitle>
</.header>
<.media_profile_form changeset={@changeset} action={~p"/media_profiles"} />
<.back navigate={~p"/media_profiles"}>Back to media_profiles</.back>
@@ -0,0 +1,16 @@
<.header>
Media profile <%= @media_profile.id %>
<:subtitle>This is a media_profile record from your database.</:subtitle>
<:actions>
<.link href={~p"/media_profiles/#{@media_profile}/edit"}>
<.button>Edit media_profile</.button>
</.link>
</:actions>
</.header>
<.list>
<:item title="Name"><%= @media_profile.name %></:item>
<:item title="Output path template"><%= @media_profile.output_path_template %></:item>
</.list>
<.back navigate={~p"/media_profiles"}>Back to media_profiles</.back>
@@ -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
@@ -0,0 +1,27 @@
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)
def friendly_index_frequencies do
[
{"Never", -1},
{"1 Hour", 60},
{"3 Hours", 3 * 60},
{"6 Hours", 6 * 60},
{"12 Hours", 12 * 60},
{"Daily (recommended)", 24 * 60},
{"Weekly", 7 * 24 * 60},
{"Monthly", 30 * 24 * 60}
]
end
end
@@ -0,0 +1,25 @@
<.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" />
<.input
field={f[:index_frequency_minutes]}
options={friendly_index_frequencies()}
type="select"
label="Index Frequency"
/>
<: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>
+6
View File
@@ -18,6 +18,12 @@ defmodule PinchflatWeb.Router do
pipe_through :browser
get "/", PageController, :home
resources "/media_profiles", MediaProfiles.MediaProfileController
scope "/media_sources", MediaSources do
resources "/channels", ChannelController
end
end
# Other scopes may use custom stacks.