Redo indexing mechanism (#16)
* Bumped up the line length because I fear no man * Refactored indexing Previously, indexing worked by collecting the video IDs of only videos that matched indexing criteria. This new model instead stores ALL videos for a given source, but will only _download_ videos that meet that criteria. This lets us backfill without indexing, makes it easier to add in other backends, lets us download one-off videos for a source that don't quite meet criteria, you name it. * Updated media finders to respect format filters; Added credo file
This commit is contained in:
+56
-5
@@ -19,15 +19,31 @@ defmodule Pinchflat.Media do
|
||||
|
||||
@doc """
|
||||
Returns a list of pending media_items for a given source, where
|
||||
pending means the `media_filepath` is `nil`.
|
||||
pending means the `media_filepath` is `nil` AND the media_item
|
||||
matches the format selection rules of the parent media_profile.
|
||||
|
||||
See `build_format_clauses` but tl;dr is it _may_ filter based
|
||||
on shorts or livestreams depending on the media_profile settings.
|
||||
|
||||
Returns [%MediaItem{}, ...].
|
||||
"""
|
||||
def list_pending_media_items_for(%Source{} = source) do
|
||||
from(
|
||||
m in MediaItem,
|
||||
where: m.source_id == ^source.id and is_nil(m.media_filepath)
|
||||
)
|
||||
media_profile = Repo.preload(source, :media_profile).media_profile
|
||||
|
||||
MediaItem
|
||||
|> where([mi], mi.source_id == ^source.id and is_nil(mi.media_filepath))
|
||||
|> where(^build_format_clauses(media_profile))
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a list of downloaded media_items for a given source.
|
||||
|
||||
Returns [%MediaItem{}, ...].
|
||||
"""
|
||||
def list_downloaded_media_items_for(%Source{} = source) do
|
||||
MediaItem
|
||||
|> where([mi], mi.source_id == ^source.id and not is_nil(mi.media_filepath))
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@@ -72,4 +88,39 @@ defmodule Pinchflat.Media do
|
||||
def change_media_item(%MediaItem{} = media_item, attrs \\ %{}) do
|
||||
MediaItem.changeset(media_item, attrs)
|
||||
end
|
||||
|
||||
defp build_format_clauses(media_profile) do
|
||||
mapped_struct = Map.from_struct(media_profile)
|
||||
|
||||
Enum.reduce(mapped_struct, dynamic(true), fn attr, dynamic ->
|
||||
case {attr, media_profile} do
|
||||
{{:shorts_behaviour, :only}, %{livestream_behaviour: :only}} ->
|
||||
dynamic([mi], ^dynamic and (mi.livestream == true or fragment("? ILIKE ?", mi.original_url, "%/shorts/%")))
|
||||
|
||||
# Technically redundant, but makes the other clauses easier to parse
|
||||
# (redundant because this condition is the same as the condition above, just flipped)
|
||||
{{:livestream_behaviour, :only}, %{shorts_behaviour: :only}} ->
|
||||
dynamic
|
||||
|
||||
{{:shorts_behaviour, :only}, _} ->
|
||||
# return records with /shorts/ in the original_url
|
||||
dynamic([mi], ^dynamic and fragment("? ILIKE ?", mi.original_url, "%/shorts/%"))
|
||||
|
||||
{{:livestream_behaviour, :only}, _} ->
|
||||
# return records with livestream: true
|
||||
dynamic([mi], ^dynamic and mi.livestream == true)
|
||||
|
||||
{{:shorts_behaviour, :exclude}, %{livestream_behaviour: lb}} when lb != :only ->
|
||||
# return records without /shorts/ in the original_url
|
||||
dynamic([mi], ^dynamic and fragment("? NOT ILIKE ?", mi.original_url, "%/shorts/%"))
|
||||
|
||||
{{:livestream_behaviour, :exclude}, %{shorts_behaviour: sb}} when sb != :only ->
|
||||
# return records with livestream: false
|
||||
dynamic([mi], ^dynamic and mi.livestream == false)
|
||||
|
||||
_ ->
|
||||
dynamic
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,17 +13,21 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
@allowed_fields ~w(
|
||||
title
|
||||
media_id
|
||||
original_url
|
||||
livestream
|
||||
media_filepath
|
||||
source_id
|
||||
subtitle_filepaths
|
||||
thumbnail_filepath
|
||||
metadata_filepath
|
||||
)a
|
||||
@required_fields ~w(media_id source_id)a
|
||||
@required_fields ~w(title original_url livestream media_id source_id)a
|
||||
|
||||
schema "media_items" do
|
||||
field :title, :string
|
||||
field :media_id, :string
|
||||
field :original_url, :string
|
||||
field :livestream, :boolean, default: false
|
||||
field :media_filepath, :string
|
||||
field :thumbnail_filepath, :string
|
||||
field :metadata_filepath, :string
|
||||
|
||||
@@ -4,18 +4,26 @@ defmodule Pinchflat.MediaClient.Backends.YtDlp.VideoCollection do
|
||||
videos (aka: a source [ie: channels, playlists]).
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Returns a list of strings representing the video ids in the collection.
|
||||
alias Pinchflat.Utils.FunctionUtils
|
||||
|
||||
Returns {:ok, [binary()]} | {:error, any, ...}.
|
||||
@doc """
|
||||
Returns a list of maps representing the videos in the collection.
|
||||
|
||||
Returns {:ok, [map()]} | {:error, any, ...}.
|
||||
"""
|
||||
def get_video_ids(url, command_opts \\ []) do
|
||||
def get_media_attributes(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
|
||||
case runner.run(url, opts, "%(.{id,title,was_live,original_url})j") do
|
||||
{:ok, output} ->
|
||||
output
|
||||
|> String.split("\n", trim: true)
|
||||
|> Enum.map(&Phoenix.json_library().decode!/1)
|
||||
|> FunctionUtils.wrap_ok()
|
||||
|
||||
res ->
|
||||
res
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -6,11 +6,8 @@ defmodule Pinchflat.MediaClient.SourceDetails do
|
||||
it open-ish for future expansion (just in case).
|
||||
"""
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.MediaSource.Source
|
||||
|
||||
alias Pinchflat.MediaClient.Backends.YtDlp.VideoCollection, as: YtDlpSource
|
||||
alias Pinchflat.Profiles.Options.YtDlp.IndexOptionBuilder, as: YtDlpIndexOptionBuilder
|
||||
|
||||
@doc """
|
||||
Gets a source's ID and name from its URL using the given backend.
|
||||
@@ -22,24 +19,19 @@ defmodule Pinchflat.MediaClient.SourceDetails do
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a list of video IDs for the given source URL OR source record using the given backend.
|
||||
Returns a list of basic video data mapsfor the given source URL OR
|
||||
source record using the given backend.
|
||||
|
||||
If passing a source record, the call to the backend may have custom options applied based on
|
||||
the `option_builder`.
|
||||
|
||||
Returns {:ok, list(binary())} | {:error, any, ...}.
|
||||
Returns {:ok, [map()]} | {:error, any, ...}.
|
||||
"""
|
||||
def get_video_ids(sourceable, backend \\ :yt_dlp)
|
||||
def get_media_attributes(sourceable, backend \\ :yt_dlp)
|
||||
|
||||
def get_video_ids(%Source{} = source, backend) do
|
||||
media_profile = Repo.preload(source, :media_profile).media_profile
|
||||
{:ok, options} = option_builder(backend).build(media_profile)
|
||||
|
||||
source_module(backend).get_video_ids(source.collection_id, options)
|
||||
def get_media_attributes(%Source{} = source, backend) do
|
||||
source_module(backend).get_media_attributes(source.collection_id)
|
||||
end
|
||||
|
||||
def get_video_ids(source_url, backend) when is_binary(source_url) do
|
||||
source_module(backend).get_video_ids(source_url)
|
||||
def get_media_attributes(source_url, backend) when is_binary(source_url) do
|
||||
source_module(backend).get_media_attributes(source_url)
|
||||
end
|
||||
|
||||
defp source_module(backend) do
|
||||
@@ -47,10 +39,4 @@ defmodule Pinchflat.MediaClient.SourceDetails do
|
||||
:yt_dlp -> YtDlpSource
|
||||
end
|
||||
end
|
||||
|
||||
defp option_builder(backend) do
|
||||
case backend do
|
||||
:yt_dlp -> YtDlpIndexOptionBuilder
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,7 +7,6 @@ defmodule Pinchflat.MediaSource do
|
||||
alias Pinchflat.Repo
|
||||
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Tasks.SourceTasks
|
||||
alias Pinchflat.MediaSource.Source
|
||||
alias Pinchflat.MediaClient.SourceDetails
|
||||
@@ -39,26 +38,6 @@ defmodule Pinchflat.MediaSource do
|
||||
|> 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(%Source{} = source) do
|
||||
{:ok, media_ids} = SourceDetails.get_video_ids(source.original_url)
|
||||
|
||||
media_ids
|
||||
|> Enum.map(fn media_id ->
|
||||
attrs = %{source_id: source.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 source. May attempt to pull additional source details from the
|
||||
original_url (if changed). May attempt to start indexing the source's
|
||||
@@ -101,6 +80,9 @@ defmodule Pinchflat.MediaSource do
|
||||
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.
|
||||
|
||||
NOTE: When operating in the ideal path, this effectively adds an API call
|
||||
to the source creation/update process. Should be used only when needed.
|
||||
|
||||
IDEA: Maybe I could discern `collection_type` based on the original URL?
|
||||
It also seems like it's a channel when the returned yt-dlp channel_id is the
|
||||
same as the playlist_id - maybe could use that?
|
||||
|
||||
@@ -45,7 +45,9 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
||||
# through the entire collection to determine if a video is a short or
|
||||
# a livestream.
|
||||
# NOTE: these can BOTH be set to :only which will download shorts and
|
||||
# livestreams _only_ and ignore regular videos.
|
||||
# livestreams _only_ and ignore regular videos. The redundant case
|
||||
# is when one is set to :only and the other is set to :exclude.
|
||||
# See `build_format_clauses` in the Media context for more.
|
||||
field :shorts_behaviour, Ecto.Enum, values: [:include, :exclude, :only], default: :include
|
||||
field :livestream_behaviour, Ecto.Enum, values: [:include, :exclude, :only], default: :include
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
defmodule Pinchflat.Profiles.Options.YtDlp.IndexOptionBuilder do
|
||||
@moduledoc """
|
||||
Builds the options for yt-dlp to index a media source based on the given media profile.
|
||||
"""
|
||||
|
||||
alias Pinchflat.Profiles.MediaProfile
|
||||
|
||||
@doc """
|
||||
Builds the options for yt-dlp to index a media source based on the given media profile.
|
||||
"""
|
||||
def build(%MediaProfile{} = media_profile) do
|
||||
built_options = release_type_options(media_profile)
|
||||
|
||||
{:ok, built_options}
|
||||
end
|
||||
|
||||
defp release_type_options(media_profile) do
|
||||
mapped_struct = Map.from_struct(media_profile)
|
||||
|
||||
# Appending multiple match filters treats them as an OR condition,
|
||||
# so we have to be careful around combining `only` and `exclude` options.
|
||||
# eg: only shorts + exclude livestreams = "any video that is a short OR is not a livestream"
|
||||
# which will return all shorts AND normal videos.
|
||||
Enum.reduce(mapped_struct, [], fn attr, acc ->
|
||||
case {attr, media_profile} do
|
||||
{{:shorts_behaviour, :only}, _} ->
|
||||
acc ++ [match_filter: "original_url*=/shorts/"]
|
||||
|
||||
{{:livestream_behaviour, :only}, _} ->
|
||||
acc ++ [match_filter: "was_live"]
|
||||
|
||||
# Since match_filter is an OR (see above), `exclude`s must be ignored entirely if the
|
||||
# other type is set to `only`. There is also special behaviour if they're both excludes,
|
||||
# hence why these check against `:include` alone.
|
||||
{{:shorts_behaviour, :exclude}, %{livestream_behaviour: :include}} ->
|
||||
acc ++ [match_filter: "original_url!*=/shorts/"]
|
||||
|
||||
{{:livestream_behaviour, :exclude}, %{shorts_behaviour: :include}} ->
|
||||
acc ++ [match_filter: "!was_live"]
|
||||
|
||||
# Again, since it's an OR, there's a special syntax if they're both excluded
|
||||
# to make it an AND. Note that I'm not checking for the other permutation of
|
||||
# both excluding since this MUST get hit so adding the other version would double up.
|
||||
{{:livestream_behaviour, :exclude}, %{shorts_behaviour: :exclude}} ->
|
||||
acc ++ [match_filter: "!was_live & original_url!*=/shorts/"]
|
||||
|
||||
_ ->
|
||||
acc
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,7 @@ defmodule Pinchflat.Tasks.SourceTasks do
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.MediaSource.Source
|
||||
alias Pinchflat.MediaClient.SourceDetails
|
||||
alias Pinchflat.Workers.MediaIndexingWorker
|
||||
alias Pinchflat.Workers.VideoDownloadWorker
|
||||
|
||||
@@ -33,6 +34,32 @@ defmodule Pinchflat.Tasks.SourceTasks do
|
||||
end
|
||||
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(%Source{} = source) do
|
||||
{:ok, media_attributes} = SourceDetails.get_media_attributes(source.original_url)
|
||||
|
||||
media_attributes
|
||||
|> Enum.map(fn media_attrs ->
|
||||
attrs = %{
|
||||
source_id: source.id,
|
||||
title: media_attrs["title"],
|
||||
media_id: media_attrs["id"],
|
||||
original_url: media_attrs["original_url"],
|
||||
livestream: media_attrs["was_live"]
|
||||
}
|
||||
|
||||
case Media.create_media_item(attrs) do
|
||||
{:ok, media_item} -> media_item
|
||||
{:error, changeset} -> changeset
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts tasks for downloading videos for any of a sources _pending_ media items.
|
||||
Jobs are not enqueued if the source is set to not download media. This will return :ok.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
defmodule Pinchflat.Utils.FunctionUtils do
|
||||
@moduledoc """
|
||||
Utility functions for working with functions
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Wraps the provided term in an :ok tuple. Useful for fulfilling a contract, but
|
||||
other usage should be assessed to see if it's the right fit.
|
||||
|
||||
Returns {:ok, term}
|
||||
"""
|
||||
def wrap_ok(value) do
|
||||
{:ok, value}
|
||||
end
|
||||
end
|
||||
@@ -47,7 +47,7 @@ defmodule Pinchflat.Workers.MediaIndexingWorker do
|
||||
end
|
||||
|
||||
defp index_media_and_reschedule(source) do
|
||||
MediaSource.index_media_items(source)
|
||||
SourceTasks.index_media_items(source)
|
||||
SourceTasks.enqueue_pending_media_downloads(source)
|
||||
|
||||
source
|
||||
|
||||
@@ -168,8 +168,7 @@ defmodule PinchflatWeb.CoreComponents do
|
||||
phx-connected={hide("#server-error")}
|
||||
hidden
|
||||
>
|
||||
Hang in there while we get back on track
|
||||
<.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
|
||||
Hang in there while we get back on track <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
|
||||
</.flash>
|
||||
</div>
|
||||
"""
|
||||
@@ -245,8 +244,7 @@ defmodule PinchflatWeb.CoreComponents do
|
||||
values: ~w(checkbox color date datetime-local email file hidden month number password
|
||||
toggle range radio search select tel text textarea time url week)
|
||||
|
||||
attr :field, Phoenix.HTML.FormField,
|
||||
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
|
||||
attr :errors, :list, default: []
|
||||
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
|
||||
@@ -254,8 +252,7 @@ defmodule PinchflatWeb.CoreComponents do
|
||||
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
|
||||
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
|
||||
|
||||
attr :rest, :global,
|
||||
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
|
||||
attr :rest, :global, include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
|
||||
multiple pattern placeholder readonly required rows size step)
|
||||
|
||||
slot :inner_block
|
||||
@@ -279,15 +276,7 @@ defmodule PinchflatWeb.CoreComponents do
|
||||
<div phx-feedback-for={@name}>
|
||||
<label class="flex items-center gap-4 text-sm leading-6">
|
||||
<input type="hidden" name={@name} value="false" />
|
||||
<input
|
||||
type="checkbox"
|
||||
id={@id}
|
||||
name={@name}
|
||||
value="true"
|
||||
checked={@checked}
|
||||
class="rounded focus:ring-0"
|
||||
{@rest}
|
||||
/>
|
||||
<input type="checkbox" id={@id} name={@name} value="true" checked={@checked} class="rounded focus:ring-0" {@rest} />
|
||||
<%= @label %>
|
||||
</label>
|
||||
<.help :if={@help}><%= @help %></.help>
|
||||
@@ -318,8 +307,7 @@ defmodule PinchflatWeb.CoreComponents do
|
||||
{@rest}
|
||||
/>
|
||||
<div class="inline-block cursor-pointer" @click="enabled = !enabled">
|
||||
<div x-bind:class="enabled && '!bg-primary'" class="block h-8 w-14 rounded-full bg-black">
|
||||
</div>
|
||||
<div x-bind:class="enabled && '!bg-primary'" class="block h-8 w-14 rounded-full bg-black"></div>
|
||||
<div
|
||||
x-bind:class="enabled && '!right-1 !translate-x-full'"
|
||||
class="absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-white transition"
|
||||
@@ -529,10 +517,7 @@ defmodule PinchflatWeb.CoreComponents do
|
||||
<td :if={@action != []} class="relative w-14 p-0">
|
||||
<div class="relative whitespace-nowrap py-4 text-right text-sm font-medium">
|
||||
<span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl" />
|
||||
<span
|
||||
:for={action <- @action}
|
||||
class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
||||
>
|
||||
<span :for={action <- @action} class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700">
|
||||
<%= render_slot(action, @row_item.(row)) %>
|
||||
</span>
|
||||
</div>
|
||||
@@ -607,10 +592,7 @@ defmodule PinchflatWeb.CoreComponents do
|
||||
def back(assigns) do
|
||||
~H"""
|
||||
<div class="mt-16">
|
||||
<.link
|
||||
navigate={@navigate}
|
||||
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
||||
>
|
||||
<.link navigate={@navigate} class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700">
|
||||
<.icon name="hero-arrow-left-solid" class="h-3 w-3" />
|
||||
<%= render_slot(@inner_block) %>
|
||||
</.link>
|
||||
@@ -651,8 +633,7 @@ defmodule PinchflatWeb.CoreComponents do
|
||||
JS.show(js,
|
||||
to: selector,
|
||||
transition:
|
||||
{"transition-all transform ease-out duration-300",
|
||||
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
|
||||
{"transition-all transform ease-out duration-300", "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
|
||||
"opacity-100 translate-y-0 sm:scale-100"}
|
||||
)
|
||||
end
|
||||
|
||||
@@ -17,11 +17,7 @@
|
||||
<ul class="mb-6 flex flex-col gap-1.5">
|
||||
<.sidebar_item icon="hero-tv" text="Sources" navigate={~p"/sources"} />
|
||||
|
||||
<.sidebar_item
|
||||
icon="hero-adjustments-vertical"
|
||||
text="Media Profiles"
|
||||
navigate={~p"/media_profiles"}
|
||||
/>
|
||||
<.sidebar_item icon="hero-adjustments-vertical" text="Media Profiles" navigate={~p"/media_profiles"} />
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -8,11 +8,7 @@
|
||||
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<div class="flex flex-col gap-10">
|
||||
<.source_form
|
||||
changeset={@changeset}
|
||||
media_profiles={@media_profiles}
|
||||
action={~p"/sources/#{@source}"}
|
||||
/>
|
||||
<.source_form changeset={@changeset} media_profiles={@media_profiles} action={~p"/sources/#{@source}"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,16 +29,10 @@
|
||||
</.link>
|
||||
</:col>
|
||||
<:col :let={source} label="" class="flex place-content-evenly">
|
||||
<.link
|
||||
navigate={~p"/sources/#{source.id}"}
|
||||
class="hover:text-secondary duration-200 ease-in-out mx-0.5"
|
||||
>
|
||||
<.link navigate={~p"/sources/#{source.id}"} class="hover:text-secondary duration-200 ease-in-out mx-0.5">
|
||||
<.icon name="hero-eye" />
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/sources/#{source.id}/edit"}
|
||||
class="hover:text-secondary duration-200 ease-in-out mx-0.5"
|
||||
>
|
||||
<.link navigate={~p"/sources/#{source.id}/edit"} class="hover:text-secondary duration-200 ease-in-out mx-0.5">
|
||||
<.icon name="hero-pencil-square" />
|
||||
</.link>
|
||||
</:col>
|
||||
|
||||
@@ -3,12 +3,7 @@
|
||||
Oops, something went wrong! Please check the errors below.
|
||||
</.error>
|
||||
|
||||
<.input
|
||||
field={f[:original_url]}
|
||||
type="text"
|
||||
label="Source URL"
|
||||
help="URL of a channel or playlist (required)"
|
||||
/>
|
||||
<.input field={f[:original_url]} type="text" label="Source URL" help="URL of a channel or playlist (required)" />
|
||||
|
||||
<.input field={f[:friendly_name]} type="text" label="Friendly Name" />
|
||||
|
||||
@@ -19,12 +14,7 @@
|
||||
label="Media Profile"
|
||||
/>
|
||||
|
||||
<.input
|
||||
field={f[:collection_type]}
|
||||
options={friendly_collection_types()}
|
||||
type="select"
|
||||
label="Source Type"
|
||||
/>
|
||||
<.input field={f[:collection_type]} options={friendly_collection_types()} type="select" label="Source Type" />
|
||||
|
||||
<.input
|
||||
field={f[:index_frequency_minutes]}
|
||||
|
||||
@@ -136,11 +136,7 @@
|
||||
href="https://twitter.com/elixirphoenix"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600">
|
||||
<path d="M5.403 14c5.283 0 8.172-4.617 8.172-8.62 0-.131 0-.262-.008-.391A6.033 6.033 0 0 0 15 3.419a5.503 5.503 0 0 1-1.65.477 3.018 3.018 0 0 0 1.263-1.676 5.579 5.579 0 0 1-1.824.736 2.832 2.832 0 0 0-1.63-.916 2.746 2.746 0 0 0-1.821.319A2.973 2.973 0 0 0 8.076 3.78a3.185 3.185 0 0 0-.182 1.938 7.826 7.826 0 0 1-3.279-.918 8.253 8.253 0 0 1-2.64-2.247 3.176 3.176 0 0 0-.315 2.208 3.037 3.037 0 0 0 1.203 1.836A2.739 2.739 0 0 1 1.56 6.22v.038c0 .7.23 1.377.65 1.919.42.54 1.004.912 1.654 1.05-.423.122-.866.14-1.297.052.184.602.541 1.129 1.022 1.506a2.78 2.78 0 0 0 1.662.598 5.656 5.656 0 0 1-2.007 1.074A5.475 5.475 0 0 1 1 12.64a7.827 7.827 0 0 0 4.403 1.358" />
|
||||
</svg>
|
||||
Follow on Twitter
|
||||
@@ -151,11 +147,7 @@
|
||||
href="https://elixirforum.com"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600">
|
||||
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
|
||||
</svg>
|
||||
Discuss on the Elixir Forum
|
||||
@@ -166,11 +158,7 @@
|
||||
href="https://web.libera.chat/#elixir"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
@@ -190,11 +178,7 @@
|
||||
href="https://discord.gg/elixir"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600">
|
||||
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
|
||||
</svg>
|
||||
Join our Discord server
|
||||
@@ -205,11 +189,7 @@
|
||||
href="https://fly.io/docs/elixir/getting-started/"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600">
|
||||
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
|
||||
</svg>
|
||||
Deploy your application
|
||||
|
||||
Reference in New Issue
Block a user