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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
+10
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user