Generate rss feeds (#123)
* Added plug to strip file extensions * [VERY WIP] basic podcast RSS setup * [WIP] got basic podcast RSS working * [WIP] more expanding on RSS * [WIP] Working on refactoring feed * Added UUID backfill to a migration * [WIP] Moar refactoring * [WIP] Adding UI for getting RSS feed * Many tests * Added conditional routing for feed URLs * Removed the need for url_base to be set * Updated preset name * Rendered certain fields HTML-safe; Added logging to confirm range request support * Fixed incorrect scheme issue * Updated env var * Updated other UI to use dropdown * removed commented code
This commit is contained in:
parent
62636832bf
commit
bbe641d2af
29 changed files with 895 additions and 155 deletions
|
|
@ -20,8 +20,9 @@ config :pinchflat,
|
||||||
tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]),
|
tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]),
|
||||||
# Setting BASIC_AUTH_USERNAME and BASIC_AUTH_PASSWORD implies you want to use basic auth.
|
# Setting BASIC_AUTH_USERNAME and BASIC_AUTH_PASSWORD implies you want to use basic auth.
|
||||||
# If either is unset, basic auth will not be used.
|
# If either is unset, basic auth will not be used.
|
||||||
basic_auth_username: System.get_env("BASIC_AUTH_USERNAME"),
|
basic_auth_username: "",
|
||||||
basic_auth_password: System.get_env("BASIC_AUTH_PASSWORD"),
|
basic_auth_password: "",
|
||||||
|
expose_feed_endpoints: false,
|
||||||
file_watcher_poll_interval: 1000
|
file_watcher_poll_interval: 1000
|
||||||
|
|
||||||
# Configures the endpoint
|
# Configures the endpoint
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,9 @@ if config_env() == :prod do
|
||||||
metadata_path = System.get_env("METADATA_PATH", Path.join([config_path, "metadata"]))
|
metadata_path = System.get_env("METADATA_PATH", Path.join([config_path, "metadata"]))
|
||||||
extras_path = System.get_env("EXTRAS_PATH", Path.join([config_path, "extras"]))
|
extras_path = System.get_env("EXTRAS_PATH", Path.join([config_path, "extras"]))
|
||||||
|
|
||||||
|
# For running PF as a podcast host on self-hosted environments
|
||||||
|
expose_feed_endpoints = String.length(System.get_env("EXPOSE_FEED_ENDPOINTS", "")) > 0
|
||||||
|
|
||||||
# We want to force _some_ level of useful logging in production
|
# We want to force _some_ level of useful logging in production
|
||||||
acceptable_log_levels = ~w(debug info)a
|
acceptable_log_levels = ~w(debug info)a
|
||||||
log_level = String.to_existing_atom(System.get_env("LOG_LEVEL", "info"))
|
log_level = String.to_existing_atom(System.get_env("LOG_LEVEL", "info"))
|
||||||
|
|
@ -64,7 +67,8 @@ if config_env() == :prod do
|
||||||
metadata_directory: metadata_path,
|
metadata_directory: metadata_path,
|
||||||
extras_directory: extras_path,
|
extras_directory: extras_path,
|
||||||
tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]),
|
tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]),
|
||||||
dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY")
|
dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"),
|
||||||
|
expose_feed_endpoints: expose_feed_endpoints
|
||||||
|
|
||||||
config :pinchflat, Pinchflat.Repo,
|
config :pinchflat, Pinchflat.Repo,
|
||||||
database: db_path,
|
database: db_path,
|
||||||
|
|
@ -121,54 +125,4 @@ if config_env() == :prod do
|
||||||
formatter: Logger.Formatter.new()
|
formatter: Logger.Formatter.new()
|
||||||
}}
|
}}
|
||||||
]
|
]
|
||||||
|
|
||||||
# ## SSL Support
|
|
||||||
#
|
|
||||||
# To get SSL working, you will need to add the `https` key
|
|
||||||
# to your endpoint configuration:
|
|
||||||
#
|
|
||||||
# config :pinchflat, PinchflatWeb.Endpoint,
|
|
||||||
# https: [
|
|
||||||
# ...,
|
|
||||||
# port: 443,
|
|
||||||
# cipher_suite: :strong,
|
|
||||||
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
|
|
||||||
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
|
|
||||||
# ]
|
|
||||||
#
|
|
||||||
# The `cipher_suite` is set to `:strong` to support only the
|
|
||||||
# latest and more secure SSL ciphers. This means old browsers
|
|
||||||
# and clients may not be supported. You can set it to
|
|
||||||
# `:compatible` for wider support.
|
|
||||||
#
|
|
||||||
# `:keyfile` and `:certfile` expect an absolute path to the key
|
|
||||||
# and cert in disk or a relative path inside priv, for example
|
|
||||||
# "priv/ssl/server.key". For all supported SSL configuration
|
|
||||||
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
|
|
||||||
#
|
|
||||||
# We also recommend setting `force_ssl` in your endpoint, ensuring
|
|
||||||
# no data is ever sent via http, always redirecting to https:
|
|
||||||
#
|
|
||||||
# config :pinchflat, PinchflatWeb.Endpoint,
|
|
||||||
# force_ssl: [hsts: true]
|
|
||||||
#
|
|
||||||
# Check `Plug.SSL` for all available options in `force_ssl`.
|
|
||||||
|
|
||||||
# ## Configuring the mailer
|
|
||||||
#
|
|
||||||
# In production you need to configure the mailer to use a different adapter.
|
|
||||||
# Also, you may need to configure the Swoosh API client of your choice if you
|
|
||||||
# are not using SMTP. Here is an example of the configuration:
|
|
||||||
#
|
|
||||||
# config :pinchflat, Pinchflat.Mailer,
|
|
||||||
# adapter: Swoosh.Adapters.Mailgun,
|
|
||||||
# api_key: System.get_env("MAILGUN_API_KEY"),
|
|
||||||
# domain: System.get_env("MAILGUN_DOMAIN")
|
|
||||||
#
|
|
||||||
# For this example you need include a HTTP client required by Swoosh API client.
|
|
||||||
# Swoosh supports Hackney and Finch out of the box:
|
|
||||||
#
|
|
||||||
# config :swoosh, :api_client, Swoosh.ApiClient.Hackney
|
|
||||||
#
|
|
||||||
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Settings
|
alias Pinchflat.Settings
|
||||||
alias Pinchflat.Sources.Source
|
|
||||||
alias Pinchflat.Media.MediaItem
|
|
||||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||||
|
|
||||||
def start_link(opts \\ []) do
|
def start_link(opts \\ []) do
|
||||||
|
|
@ -36,7 +34,6 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
||||||
reset_executing_jobs()
|
reset_executing_jobs()
|
||||||
create_blank_cookie_file()
|
create_blank_cookie_file()
|
||||||
apply_default_settings()
|
apply_default_settings()
|
||||||
backfill_uuids()
|
|
||||||
|
|
||||||
{:ok, state}
|
{:ok, state}
|
||||||
end
|
end
|
||||||
|
|
@ -68,18 +65,4 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
||||||
Settings.fetch!(:onboarding, true)
|
Settings.fetch!(:onboarding, true)
|
||||||
Settings.fetch!(:pro_enabled, false)
|
Settings.fetch!(:pro_enabled, false)
|
||||||
end
|
end
|
||||||
|
|
||||||
# TODO: turn into a migration
|
|
||||||
defp backfill_uuids do
|
|
||||||
# This is a one-time backfill to ensure that all media items have a UUID
|
|
||||||
# This is important for the RSS feed and the streaming endpoint
|
|
||||||
source_query = from(m in Source, where: is_nil(m.uuid), update: [set: [uuid: fragment("gen_random_uuid()")]])
|
|
||||||
media_item_query = from(m in MediaItem, where: is_nil(m.uuid), update: [set: [uuid: fragment("gen_random_uuid()")]])
|
|
||||||
|
|
||||||
{source_count, _} = Repo.update_all(source_query, [])
|
|
||||||
{media_item_count, _} = Repo.update_all(media_item_query, [])
|
|
||||||
|
|
||||||
Logger.info("Backfilled UUIDs for #{source_count} sources.")
|
|
||||||
Logger.info("Backfilled UUIDs for #{media_item_count} media items.")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
75
lib/pinchflat/podcasts/podcast_helpers.ex
Normal file
75
lib/pinchflat/podcasts/podcast_helpers.ex
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
defmodule Pinchflat.Podcasts.PodcastHelpers do
|
||||||
|
@moduledoc """
|
||||||
|
Methods for fetching postcast-related data from a source
|
||||||
|
or its media items
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Pinchflat.Repo
|
||||||
|
alias Pinchflat.Media
|
||||||
|
alias Pinchflat.Metadata.MediaMetadata
|
||||||
|
alias Pinchflat.Metadata.SourceMetadata
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns a list of media items that have been downloaded to disk
|
||||||
|
and have been proven to still exist there.
|
||||||
|
|
||||||
|
Useful for podcasts since we don't want to serve media that
|
||||||
|
has been deleted or moved, but it's also fairly generally useful
|
||||||
|
so I could see this being moved in the future.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- limit: integer - the maximum number of media items to return
|
||||||
|
|
||||||
|
Returns: [%MediaItem{}]
|
||||||
|
"""
|
||||||
|
def persisted_media_items_for(source, opts \\ []) do
|
||||||
|
limit = Keyword.get(opts, :limit, 500)
|
||||||
|
|
||||||
|
source
|
||||||
|
|> Media.list_downloaded_media_items_for(limit: limit)
|
||||||
|
|> Enum.filter(fn media_item -> File.exists?(media_item.media_filepath) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Selects a cover image for a source based on the source's metadata
|
||||||
|
and the metadata of the media items associated with the source. Also
|
||||||
|
ensures images exist on disk.
|
||||||
|
|
||||||
|
Only one media item should need to be returned since this is using the
|
||||||
|
internal metadata which, so long as the media_item was _downloaded_, should
|
||||||
|
be guaranteed to exist.
|
||||||
|
|
||||||
|
Prefers the source's poster, then fanart, then the media item's thumbnail.
|
||||||
|
|
||||||
|
Returns: {:ok, filepath} | {:error, :no_suitable_image}
|
||||||
|
"""
|
||||||
|
def select_cover_image(source, media_items) do
|
||||||
|
source_with_preloads = Repo.preload(source, :metadata)
|
||||||
|
|
||||||
|
source_with_preloads
|
||||||
|
|> get_images_by_preference(media_items)
|
||||||
|
|> Enum.reject(&is_nil(&1))
|
||||||
|
|> Enum.find(&File.exists?/1)
|
||||||
|
|> case do
|
||||||
|
nil -> {:error, :no_suitable_image}
|
||||||
|
filepath -> {:ok, filepath}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_images_by_preference(source_with_preloads, []) do
|
||||||
|
source_metadata = source_with_preloads.metadata || %SourceMetadata{}
|
||||||
|
|
||||||
|
[
|
||||||
|
source_metadata.poster_filepath,
|
||||||
|
source_metadata.fanart_filepath
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_images_by_preference(source_with_preloads, [media_item | _]) do
|
||||||
|
media_item_with_preloads = Repo.preload(media_item, :metadata)
|
||||||
|
media_item_metadata = media_item_with_preloads.metadata || %MediaMetadata{}
|
||||||
|
source_images = get_images_by_preference(source_with_preloads, [])
|
||||||
|
|
||||||
|
source_images ++ [media_item_metadata.thumbnail_filepath]
|
||||||
|
end
|
||||||
|
end
|
||||||
138
lib/pinchflat/podcasts/rss_feed_builder.ex
Normal file
138
lib/pinchflat/podcasts/rss_feed_builder.ex
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
||||||
|
@moduledoc """
|
||||||
|
Methods for building an RSS feed for a source and its media items.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@datetime_format "%a, %d %b %Y %H:%M:%S %z"
|
||||||
|
|
||||||
|
alias Pinchflat.Utils.DatetimeUtils
|
||||||
|
alias Pinchflat.Podcasts.PodcastHelpers
|
||||||
|
alias PinchflatWeb.Router.Helpers, as: Routes
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Builds an RSS feed for a given source and its media items.
|
||||||
|
Only MediaItems that have been persisted will be included in the feed.
|
||||||
|
|
||||||
|
## Options:
|
||||||
|
- `:limit` - The maximum number of media items to include in the feed. Defaults to 300.
|
||||||
|
|
||||||
|
Returns an XML document as a string.
|
||||||
|
"""
|
||||||
|
def build(source, opts \\ []) do
|
||||||
|
limit = Keyword.get(opts, :limit, 300)
|
||||||
|
url_base = Keyword.get(opts, :url_base, PinchflatWeb.Endpoint.url())
|
||||||
|
|
||||||
|
media_items = PodcastHelpers.persisted_media_items_for(source, limit: limit)
|
||||||
|
build_source_xml(source, media_items, url_base)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_source_xml(source, media_items, url_base) do
|
||||||
|
media_item_xml = Enum.map(media_items, &build_media_item_xml(source, &1, url_base))
|
||||||
|
# "caching" the image path since it requires some DB calls and is used twice
|
||||||
|
feed_image_path = feed_image_path(url_base, source, media_items)
|
||||||
|
|
||||||
|
# Useful: resources:
|
||||||
|
# - https://validator.w3.org/feed/#validate_by_input
|
||||||
|
# - https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md
|
||||||
|
# - https://podba.se/validate
|
||||||
|
"""
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0"
|
||||||
|
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
|
||||||
|
xmlns:podcast="https://podcastindex.org/namespace/1.0"
|
||||||
|
xmlns:atom="http://www.w3.org/2005/Atom">
|
||||||
|
<channel>
|
||||||
|
<title>#{safe(source.custom_name)}</title>
|
||||||
|
<link>#{source.original_url}</link>
|
||||||
|
<description>#{safe(source.description)}</description>
|
||||||
|
<category>TV & Film</category>
|
||||||
|
<generator>Generated by Pinchflat</generator>
|
||||||
|
<language>en-us</language>
|
||||||
|
<lastBuildDate>#{Calendar.strftime(source.updated_at, @datetime_format)}</lastBuildDate>
|
||||||
|
<pubDate>#{Calendar.strftime(source.inserted_at, @datetime_format)}</pubDate>
|
||||||
|
<atom:link href="#{generate_self_link(url_base, source)}" rel="self" type="application/rss+xml" />
|
||||||
|
<podcast:locked>yes</podcast:locked>
|
||||||
|
<podcast:guid>#{source.uuid}</podcast:guid>
|
||||||
|
<image>
|
||||||
|
<url>#{feed_image_path}</url>
|
||||||
|
<title>#{safe(source.custom_name)}</title>
|
||||||
|
<link>#{source.original_url}</link>
|
||||||
|
</image>
|
||||||
|
<itunes:author>#{safe(source.custom_name)}</itunes:author>
|
||||||
|
<itunes:subtitle>#{safe(source.custom_name)}</itunes:subtitle>
|
||||||
|
<itunes:block>yes</itunes:block>
|
||||||
|
<itunes:image href="#{feed_image_path}"></itunes:image>
|
||||||
|
<itunes:explicit>false</itunes:explicit>
|
||||||
|
<itunes:category text="TV & Film"></itunes:category>
|
||||||
|
|
||||||
|
#{Enum.join(media_item_xml, "\n")}
|
||||||
|
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_media_item_xml(source, media_item, url_base) do
|
||||||
|
"""
|
||||||
|
<item>
|
||||||
|
<guid isPermaLink="false">#{media_item.uuid}</guid>
|
||||||
|
<title>#{safe(media_item.title)}</title>
|
||||||
|
<link>#{media_item.original_url}</link>
|
||||||
|
<description>#{safe(media_item.description)}</description>
|
||||||
|
<pubDate>#{generate_upload_date(media_item)}</pubDate>
|
||||||
|
<enclosure
|
||||||
|
url="#{media_stream_path(url_base, media_item)}"
|
||||||
|
length="#{media_item.media_size_bytes}"
|
||||||
|
type="#{MIME.from_path(media_item.media_filepath)}"
|
||||||
|
/>
|
||||||
|
<itunes:author>#{safe(source.custom_name)}</itunes:author>
|
||||||
|
<itunes:subtitle>#{safe(media_item.title)}</itunes:subtitle>
|
||||||
|
<itunes:summary><![CDATA[#{media_item.description}]]></itunes:summary>
|
||||||
|
<itunes:explicit>false</itunes:explicit>
|
||||||
|
</item>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp safe(nil), do: ""
|
||||||
|
|
||||||
|
defp safe(value) do
|
||||||
|
value
|
||||||
|
|> Phoenix.HTML.html_escape()
|
||||||
|
|> Phoenix.HTML.safe_to_string()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp generate_self_link(url_base, source) do
|
||||||
|
Path.join(url_base, "#{podcast_route(:rss_feed, source.uuid)}.xml")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp media_stream_path(url_base, media_item) do
|
||||||
|
extension = Path.extname(media_item.media_filepath)
|
||||||
|
|
||||||
|
Path.join(url_base, "#{media_route(:stream, media_item.uuid)}#{extension}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp feed_image_path(url_base, source, media_items) do
|
||||||
|
case PodcastHelpers.select_cover_image(source, media_items) do
|
||||||
|
{:error, _} ->
|
||||||
|
""
|
||||||
|
|
||||||
|
{:ok, filepath} ->
|
||||||
|
extension = Path.extname(filepath)
|
||||||
|
Path.join(url_base, "#{podcast_route(:feed_image, source.uuid)}#{extension}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp generate_upload_date(media_item) do
|
||||||
|
media_item.upload_date
|
||||||
|
|> DatetimeUtils.date_to_datetime()
|
||||||
|
|> Calendar.strftime(@datetime_format)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp podcast_route(action, params) do
|
||||||
|
Routes.podcast_path(PinchflatWeb.Endpoint, action, params)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp media_route(action, params) do
|
||||||
|
Routes.media_item_path(PinchflatWeb.Endpoint, action, params)
|
||||||
|
end
|
||||||
|
end
|
||||||
17
lib/pinchflat/utils/datetime_utils.ex
Normal file
17
lib/pinchflat/utils/datetime_utils.ex
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
defmodule Pinchflat.Utils.DatetimeUtils do
|
||||||
|
@moduledoc """
|
||||||
|
Utility methods for working with dates and datetimes
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Converts a Date to a DateTime
|
||||||
|
|
||||||
|
Returns %DateTime{}
|
||||||
|
"""
|
||||||
|
def date_to_datetime(date) do
|
||||||
|
date
|
||||||
|
|> Date.to_gregorian_days()
|
||||||
|
|> Kernel.*(86_400)
|
||||||
|
|> DateTime.from_gregorian_seconds()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -21,7 +21,7 @@ defmodule PinchflatWeb do
|
||||||
|
|
||||||
def router do
|
def router do
|
||||||
quote do
|
quote do
|
||||||
use Phoenix.Router, helpers: false
|
use Phoenix.Router, helpers: true
|
||||||
|
|
||||||
# Import common connection and controller functions to use in pipelines
|
# Import common connection and controller functions to use in pipelines
|
||||||
import Plug.Conn
|
import Plug.Conn
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ defmodule PinchflatWeb.CoreComponents do
|
||||||
|
|
||||||
Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
|
Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
|
||||||
"""
|
"""
|
||||||
use Phoenix.Component
|
use Phoenix.Component, global_prefixes: ~w(x-)
|
||||||
|
|
||||||
import PinchflatWeb.Gettext
|
import PinchflatWeb.Gettext
|
||||||
|
|
||||||
|
|
@ -654,10 +654,11 @@ defmodule PinchflatWeb.CoreComponents do
|
||||||
"""
|
"""
|
||||||
attr :name, :string, required: true
|
attr :name, :string, required: true
|
||||||
attr :class, :string, default: nil
|
attr :class, :string, default: nil
|
||||||
|
attr :rest, :global
|
||||||
|
|
||||||
def icon(%{name: "hero-" <> _} = assigns) do
|
def icon(%{name: "hero-" <> _} = assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<span class={[@name, @class]} />
|
<span class={[@name, @class]} {@rest} />
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
defmodule PinchflatWeb.CustomComponents.ButtonComponents do
|
defmodule PinchflatWeb.CustomComponents.ButtonComponents do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
use Phoenix.Component
|
use Phoenix.Component, global_prefixes: ~w(x-)
|
||||||
|
|
||||||
|
alias PinchflatWeb.CoreComponents
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Render a button
|
Render a button
|
||||||
|
|
@ -39,4 +41,52 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
|
||||||
</button>
|
</button>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Render a dropdown based off a button
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.button_dropdown text="Actions">
|
||||||
|
<:option>TEST</:option>
|
||||||
|
</.button_dropdown>
|
||||||
|
"""
|
||||||
|
attr :text, :string, required: true
|
||||||
|
attr :class, :string, default: ""
|
||||||
|
|
||||||
|
slot :option, required: true
|
||||||
|
|
||||||
|
def button_dropdown(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div x-data="{ dropdownOpen: false }" class={["relative flex", @class]}>
|
||||||
|
<span
|
||||||
|
x-on:click.prevent="dropdownOpen = !dropdownOpen"
|
||||||
|
class={[
|
||||||
|
"cursor-pointer inline-flex gap-2.5 rounded-md bg-primary px-5.5 py-3",
|
||||||
|
"font-medium text-white hover:bg-opacity-95"
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<%= @text %>
|
||||||
|
<CoreComponents.icon
|
||||||
|
name="hero-chevron-down"
|
||||||
|
class="fill-current duration-200 ease-linear mt-1"
|
||||||
|
x-bind:class="dropdownOpen && 'rotate-180'"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
x-show="dropdownOpen"
|
||||||
|
x-on:click.outside="dropdownOpen = false"
|
||||||
|
class="absolute left-0 top-full z-40 mt-2 w-full rounded-md bg-black py-3 shadow-card"
|
||||||
|
>
|
||||||
|
<ul class="flex flex-col">
|
||||||
|
<li :for={option <- @option}>
|
||||||
|
<span class="flex px-5 py-2 font-medium text-bodydark2 hover:text-white cursor-pointer">
|
||||||
|
<%= render_slot(option) %>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -9,24 +9,31 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
|
||||||
attr :title, :string, required: true
|
attr :title, :string, required: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
slot :tab_append, required: false
|
||||||
|
|
||||||
def tabbed_layout(assigns) do
|
def tabbed_layout(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div
|
<div
|
||||||
x-data="{ openTab: 0, activeClasses: 'text-meta-5 border-meta-5', inactiveClasses: 'border-transparent' }"
|
x-data="{ openTab: 0, activeClasses: 'text-meta-5 border-meta-5', inactiveClasses: 'border-transparent' }"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<div class="mb-6 flex flex-wrap gap-5 border-b border-strokedark sm:gap-10">
|
<header class="flex flex-col md:flex-row md:justify-between border-b border-strokedark">
|
||||||
<a
|
<div class="flex flex-wrap gap-5 sm:gap-10">
|
||||||
:for={{tab, idx} <- Enum.with_index(@tab)}
|
<a
|
||||||
href="#"
|
:for={{tab, idx} <- Enum.with_index(@tab)}
|
||||||
@click.prevent={"openTab = #{idx}"}
|
href="#"
|
||||||
x-bind:class={"openTab === #{idx} ? activeClasses : inactiveClasses"}
|
@click.prevent={"openTab = #{idx}"}
|
||||||
class="border-b-2 py-4 text-sm font-medium hover:text-meta-5 md:text-base"
|
x-bind:class={"openTab === #{idx} ? activeClasses : inactiveClasses"}
|
||||||
>
|
class="border-b-2 py-4 w-full sm:w-fit text-sm font-medium hover:text-meta-5 md:text-base"
|
||||||
<span class="text-xl"><%= tab.title %></span>
|
>
|
||||||
</a>
|
<span class="text-xl"><%= tab.title %></span>
|
||||||
</div>
|
</a>
|
||||||
<div>
|
</div>
|
||||||
|
<div class="mx-4 my-4 lg:my-0 flex gap-5 sm:gap-10 items-center">
|
||||||
|
<%= render_slot(@tab_append) %>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="mt-4">
|
||||||
<div :for={{tab, idx} <- Enum.with_index(@tab)} x-show={"openTab === #{idx}"} class="font-medium leading-relaxed">
|
<div :for={{tab, idx} <- Enum.with_index(@tab)} x-show={"openTab === #{idx}"} class="font-medium leading-relaxed">
|
||||||
<%= render_slot(tab) %>
|
<%= render_slot(tab) %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
defmodule PinchflatWeb.MediaItems.MediaItemController do
|
defmodule PinchflatWeb.MediaItems.MediaItemController do
|
||||||
use PinchflatWeb, :controller
|
use PinchflatWeb, :controller
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media
|
alias Pinchflat.Media
|
||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaItem
|
||||||
|
|
@ -36,7 +38,7 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|
||||||
#
|
#
|
||||||
# Uses the UUID instead of the ID to avoid enumeration attacks
|
# Uses the UUID instead of the ID to avoid enumeration attacks
|
||||||
# since streaming is a public endpoint (ie: no auth required)
|
# since streaming is a public endpoint (ie: no auth required)
|
||||||
def stream(conn, %{"id" => uuid}) do
|
def stream(conn, %{"uuid" => uuid}) do
|
||||||
media_item = Repo.get_by!(MediaItem, uuid: uuid)
|
media_item = Repo.get_by!(MediaItem, uuid: uuid)
|
||||||
|
|
||||||
if File.exists?(media_item.media_filepath) do
|
if File.exists?(media_item.media_filepath) do
|
||||||
|
|
@ -45,6 +47,7 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|
||||||
|
|
||||||
case parse_range(conn, file_size) do
|
case parse_range(conn, file_size) do
|
||||||
{:ok, {start_pos, end_pos}} ->
|
{:ok, {start_pos, end_pos}} ->
|
||||||
|
Logger.debug("Streaming media item: #{media_item.uuid} from #{start_pos} to #{end_pos}")
|
||||||
length = end_pos - start_pos + 1
|
length = end_pos - start_pos + 1
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|
|
@ -55,6 +58,8 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|
||||||
|> send_file(206, media_item.media_filepath, start_pos, length)
|
|> send_file(206, media_item.media_filepath, start_pos, length)
|
||||||
|
|
||||||
{:error, :invalid_range} ->
|
{:error, :invalid_range} ->
|
||||||
|
Logger.debug("Invalid range request for media item: #{media_item.uuid} - serving full file")
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> put_resp_content_type(mime_type)
|
|> put_resp_content_type(mime_type)
|
||||||
|> put_resp_header("content-length", to_string(file_size))
|
|> put_resp_header("content-length", to_string(file_size))
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,20 @@
|
||||||
<div class="rounded-sm border border-stroke bg-white py-5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark px-7.5">
|
<div class="rounded-sm border border-stroke bg-white py-5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark px-7.5">
|
||||||
<div class="max-w-full overflow-x-auto">
|
<div class="max-w-full overflow-x-auto">
|
||||||
<.tabbed_layout>
|
<.tabbed_layout>
|
||||||
|
<:tab_append>
|
||||||
|
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
||||||
|
<:option>
|
||||||
|
<.link
|
||||||
|
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?delete_files=true"}
|
||||||
|
method="delete"
|
||||||
|
data-confirm="Are you sure you want to delete this record and all associated files on disk? This cannot be undone."
|
||||||
|
>
|
||||||
|
Delete Files
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
|
</.button_dropdown>
|
||||||
|
</:tab_append>
|
||||||
|
|
||||||
<:tab title="Attributes">
|
<:tab title="Attributes">
|
||||||
<div class="flex flex-col gap-10 dark:text-white">
|
<div class="flex flex-col gap-10 dark:text-white">
|
||||||
<%= if media_file_exists?(@media_item) do %>
|
<%= if media_file_exists?(@media_item) do %>
|
||||||
|
|
@ -28,18 +42,6 @@
|
||||||
|
|
||||||
<.list_items_from_map map={Map.from_struct(@media_item)} />
|
<.list_items_from_map map={Map.from_struct(@media_item)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="flex justify-center my-10">
|
|
||||||
<.link
|
|
||||||
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?delete_files=true"}
|
|
||||||
method="delete"
|
|
||||||
data-confirm="Are you sure you want to delete this record and all associated files on disk? This cannot be undone."
|
|
||||||
>
|
|
||||||
<.button color="bg-meta-1" rounding="rounded-lg">
|
|
||||||
Delete Files
|
|
||||||
</.button>
|
|
||||||
</.link>
|
|
||||||
</section>
|
|
||||||
</:tab>
|
</:tab>
|
||||||
<:tab title="Tasks">
|
<:tab title="Tasks">
|
||||||
<%= if match?([_|_], @media_item.tasks) do %>
|
<%= if match?([_|_], @media_item.tasks) do %>
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
presets: {
|
presets: {
|
||||||
default: 'Default',
|
default: 'Default',
|
||||||
media_center: 'TV Shows',
|
media_center: 'TV Shows',
|
||||||
audio: 'Audio',
|
audio: 'Music',
|
||||||
archiving: 'Archiving'
|
archiving: 'Archiving'
|
||||||
}
|
}
|
||||||
}">
|
}">
|
||||||
|
|
|
||||||
|
|
@ -19,33 +19,35 @@
|
||||||
<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="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="max-w-full overflow-x-auto">
|
||||||
<.tabbed_layout>
|
<.tabbed_layout>
|
||||||
|
<:tab_append>
|
||||||
|
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
||||||
|
<:option>
|
||||||
|
<.link
|
||||||
|
href={~p"/media_profiles/#{@media_profile}"}
|
||||||
|
method="delete"
|
||||||
|
data-confirm="Are you sure you want to delete this profile and all its sources (leaving files in place)? This cannot be undone."
|
||||||
|
>
|
||||||
|
Delete Profile
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<.link
|
||||||
|
href={~p"/media_profiles/#{@media_profile}?delete_files=true"}
|
||||||
|
method="delete"
|
||||||
|
data-confirm="Are you sure you want to delete this profile, all its sources, and its files on disk? This cannot be undone."
|
||||||
|
class="mt-5 md:mt-0"
|
||||||
|
>
|
||||||
|
Delete Profile + Files
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
|
</.button_dropdown>
|
||||||
|
</:tab_append>
|
||||||
|
|
||||||
<:tab title="Attributes">
|
<:tab title="Attributes">
|
||||||
<div class="flex flex-col gap-10 text-white">
|
<div class="flex flex-col gap-10 text-white">
|
||||||
<h3 class="font-bold text-xl">Attributes for "<%= @media_profile.name %>"</h3>
|
<h3 class="font-bold text-xl">Attributes for "<%= @media_profile.name %>"</h3>
|
||||||
<.list_items_from_map map={Map.from_struct(@media_profile)} />
|
<.list_items_from_map map={Map.from_struct(@media_profile)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="flex flex-col md:flex-row items-center md:justify-around my-10">
|
|
||||||
<.link
|
|
||||||
href={~p"/media_profiles/#{@media_profile}"}
|
|
||||||
method="delete"
|
|
||||||
data-confirm="Are you sure you want to delete this profile and all its sources (leaving files in place)? This cannot be undone."
|
|
||||||
>
|
|
||||||
<.button color="bg-meta-1" rounding="rounded-lg">
|
|
||||||
Delete Profile and its Sources
|
|
||||||
</.button>
|
|
||||||
</.link>
|
|
||||||
<.link
|
|
||||||
href={~p"/media_profiles/#{@media_profile}?delete_files=true"}
|
|
||||||
method="delete"
|
|
||||||
data-confirm="Are you sure you want to delete this profile, all its sources, and its files on disk? This cannot be undone."
|
|
||||||
class="mt-5 md:mt-0"
|
|
||||||
>
|
|
||||||
<.button color="bg-meta-1" rounding="rounded-lg">
|
|
||||||
Delete Profile, Sources, and Files
|
|
||||||
</.button>
|
|
||||||
</.link>
|
|
||||||
</section>
|
|
||||||
</:tab>
|
</:tab>
|
||||||
<:tab title="Sources">
|
<:tab title="Sources">
|
||||||
<.table rows={@media_profile.sources} table_class="text-black dark:text-white">
|
<.table rows={@media_profile.sources} table_class="text-black dark:text-white">
|
||||||
|
|
|
||||||
38
lib/pinchflat_web/controllers/podcasts/podcast_controller.ex
Normal file
38
lib/pinchflat_web/controllers/podcasts/podcast_controller.ex
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
defmodule PinchflatWeb.Podcasts.PodcastController do
|
||||||
|
use PinchflatWeb, :controller
|
||||||
|
|
||||||
|
alias Pinchflat.Repo
|
||||||
|
alias Pinchflat.Media
|
||||||
|
alias Pinchflat.Sources.Source
|
||||||
|
alias Pinchflat.Podcasts.RssFeedBuilder
|
||||||
|
alias Pinchflat.Podcasts.PodcastHelpers
|
||||||
|
|
||||||
|
def rss_feed(conn, %{"uuid" => uuid}) do
|
||||||
|
source = Repo.get_by!(Source, uuid: uuid)
|
||||||
|
url_base = url(conn, ~p"/")
|
||||||
|
xml = RssFeedBuilder.build(source, limit: 300, url_base: url_base)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_resp_content_type("application/rss+xml")
|
||||||
|
|> put_resp_header("content-disposition", "inline")
|
||||||
|
|> send_resp(200, xml)
|
||||||
|
end
|
||||||
|
|
||||||
|
def feed_image(conn, %{"uuid" => uuid}) do
|
||||||
|
source = Repo.get_by!(Source, uuid: uuid)
|
||||||
|
# This provides a fallback image if the source has none.
|
||||||
|
# We only need one since we're using the internal metadata image which
|
||||||
|
# we know exists.
|
||||||
|
media_items = Media.list_downloaded_media_items_for(source, limit: 1)
|
||||||
|
|
||||||
|
case PodcastHelpers.select_cover_image(source, media_items) do
|
||||||
|
{:error, _} ->
|
||||||
|
send_resp(conn, 404, "Image not found")
|
||||||
|
|
||||||
|
{:ok, filepath} ->
|
||||||
|
conn
|
||||||
|
|> put_resp_content_type(MIME.from_path(filepath))
|
||||||
|
|> send_file(200, filepath)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -24,4 +24,8 @@ defmodule PinchflatWeb.Sources.SourceHTML do
|
||||||
{"Monthly", 30 * 24 * 60}
|
{"Monthly", 30 * 24 * 60}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def rss_feed_url(conn, source) do
|
||||||
|
url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,45 @@
|
||||||
<div class="rounded-sm border border-stroke bg-white px-5 py-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5">
|
<div class="rounded-sm border border-stroke bg-white px-5 py-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5">
|
||||||
<div class="max-w-full overflow-x-auto">
|
<div class="max-w-full overflow-x-auto">
|
||||||
<.tabbed_layout>
|
<.tabbed_layout>
|
||||||
|
<:tab_append>
|
||||||
|
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
||||||
|
<:option>
|
||||||
|
<span
|
||||||
|
x-data="{ copied: false }"
|
||||||
|
x-on:click={"
|
||||||
|
navigator.clipboard.writeText('#{rss_feed_url(@conn, @source)}');
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => copied = false, 4000);
|
||||||
|
"}
|
||||||
|
>
|
||||||
|
Copy RSS Feed <span x-show="copied"><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
||||||
|
</span>
|
||||||
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<div class="h-px w-full bg-bodydark2"></div>
|
||||||
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<.link
|
||||||
|
href={~p"/sources/#{@source}"}
|
||||||
|
method="delete"
|
||||||
|
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
|
||||||
|
>
|
||||||
|
Delete Source
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
|
<:option>
|
||||||
|
<.link
|
||||||
|
href={~p"/sources/#{@source}?delete_files=true"}
|
||||||
|
method="delete"
|
||||||
|
data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone."
|
||||||
|
class="mt-5 md:mt-0"
|
||||||
|
>
|
||||||
|
Delete Source + Files
|
||||||
|
</.link>
|
||||||
|
</:option>
|
||||||
|
</.button_dropdown>
|
||||||
|
</:tab_append>
|
||||||
|
|
||||||
<:tab title="Attributes">
|
<:tab title="Attributes">
|
||||||
<div class="flex flex-col gap-10 text-white">
|
<div class="flex flex-col gap-10 text-white">
|
||||||
<h3 class="font-bold text-lg">Attributes</h3>
|
<h3 class="font-bold text-lg">Attributes</h3>
|
||||||
|
|
@ -31,28 +70,6 @@
|
||||||
|
|
||||||
<.list_items_from_map map={Map.from_struct(@source)} />
|
<.list_items_from_map map={Map.from_struct(@source)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="flex flex-col md:flex-row items-center md:justify-around mt-10">
|
|
||||||
<.link
|
|
||||||
href={~p"/sources/#{@source}"}
|
|
||||||
method="delete"
|
|
||||||
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
|
|
||||||
>
|
|
||||||
<.button color="bg-meta-1" rounding="rounded-lg">
|
|
||||||
Delete Source
|
|
||||||
</.button>
|
|
||||||
</.link>
|
|
||||||
<.link
|
|
||||||
href={~p"/sources/#{@source}?delete_files=true"}
|
|
||||||
method="delete"
|
|
||||||
data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone."
|
|
||||||
class="mt-5 md:mt-0"
|
|
||||||
>
|
|
||||||
<.button color="bg-meta-1" rounding="rounded-lg">
|
|
||||||
Delete Source and Files
|
|
||||||
</.button>
|
|
||||||
</.link>
|
|
||||||
</section>
|
|
||||||
</:tab>
|
</:tab>
|
||||||
<:tab title="Pending Media">
|
<:tab title="Pending Media">
|
||||||
<%= if match?([_|_], @pending_media) do %>
|
<%= if match?([_|_], @pending_media) do %>
|
||||||
|
|
|
||||||
|
|
@ -47,5 +47,54 @@ defmodule PinchflatWeb.Endpoint do
|
||||||
plug Plug.MethodOverride
|
plug Plug.MethodOverride
|
||||||
plug Plug.Head
|
plug Plug.Head
|
||||||
plug Plug.Session, @session_options
|
plug Plug.Session, @session_options
|
||||||
|
|
||||||
|
plug :override_base_url
|
||||||
|
plug :strip_trailing_extension
|
||||||
|
|
||||||
plug PinchflatWeb.Router
|
plug PinchflatWeb.Router
|
||||||
|
|
||||||
|
# URLs need to be generated using the host of the current page being accessed
|
||||||
|
# for things like Podcast RSS feeds to contain links to the right location.
|
||||||
|
#
|
||||||
|
# Normally you'd set the `url` option in the Endpoint configuration, but
|
||||||
|
# since this is self-hosted and often accessed at multiple different URLs,
|
||||||
|
# that would probably be more difficult for end-users to set up than just
|
||||||
|
# having the application figure it out.
|
||||||
|
defp override_base_url(conn, _opts) do
|
||||||
|
new_scheme =
|
||||||
|
case get_req_header(conn, "x-forwarded-proto") do
|
||||||
|
[scheme] -> scheme
|
||||||
|
_ -> to_string(conn.scheme)
|
||||||
|
end
|
||||||
|
|
||||||
|
new_port = if conn.port in [80, 443], do: "", else: ":#{conn.port}"
|
||||||
|
new_base_url = "#{new_scheme}://#{conn.host}#{new_port}"
|
||||||
|
|
||||||
|
Phoenix.Controller.put_router_url(conn, new_base_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp strip_trailing_extension(%{path_info: []} = conn, _opts), do: conn
|
||||||
|
|
||||||
|
defp strip_trailing_extension(conn, _opts) do
|
||||||
|
path =
|
||||||
|
conn.path_info
|
||||||
|
|> List.last()
|
||||||
|
|> String.split(".")
|
||||||
|
|> Enum.reverse()
|
||||||
|
|
||||||
|
case path do
|
||||||
|
[_] ->
|
||||||
|
conn
|
||||||
|
|
||||||
|
[_format | fragments] ->
|
||||||
|
new_path =
|
||||||
|
fragments
|
||||||
|
|> Enum.reverse()
|
||||||
|
|> Enum.join(".")
|
||||||
|
|
||||||
|
path_fragments = List.replace_at(conn.path_info, -1, new_path)
|
||||||
|
|
||||||
|
%{conn | path_info: path_fragments}
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
defmodule PinchflatWeb.Router do
|
defmodule PinchflatWeb.Router do
|
||||||
use PinchflatWeb, :router
|
use PinchflatWeb, :router
|
||||||
|
|
||||||
|
# IMPORTANT: `strip_trailing_extension` in endpoint.ex removes
|
||||||
|
# the extension from the path
|
||||||
pipeline :browser do
|
pipeline :browser do
|
||||||
plug :basic_auth
|
plug :basic_auth
|
||||||
plug :accepts, ["html"]
|
plug :accepts, ["html"]
|
||||||
|
|
@ -15,6 +17,10 @@ defmodule PinchflatWeb.Router do
|
||||||
plug :accepts, ["json"]
|
plug :accepts, ["json"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
pipeline :feeds do
|
||||||
|
plug :maybe_basic_auth
|
||||||
|
end
|
||||||
|
|
||||||
scope "/", PinchflatWeb do
|
scope "/", PinchflatWeb do
|
||||||
pipe_through :browser
|
pipe_through :browser
|
||||||
|
|
||||||
|
|
@ -28,13 +34,15 @@ defmodule PinchflatWeb.Router do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Routes in here are NOT protected by basic auth. This is necessary for
|
# Routes in here _may not be_ protected by basic auth. This is necessary for
|
||||||
# media streaming to work for RSS podcast feeds.
|
# media streaming to work for RSS podcast feeds.
|
||||||
#
|
|
||||||
# TODO: consider putting the basic auth here behind a config flag
|
|
||||||
# so people that want RSS feeds to work can enable it.
|
|
||||||
scope "/", PinchflatWeb do
|
scope "/", PinchflatWeb do
|
||||||
get "/media/:id/stream", MediaItems.MediaItemController, :stream
|
pipe_through :feeds
|
||||||
|
|
||||||
|
get "/sources/:uuid/feed", Podcasts.PodcastController, :rss_feed
|
||||||
|
get "/sources/:uuid/feed_image", Podcasts.PodcastController, :feed_image
|
||||||
|
|
||||||
|
get "/media/:uuid/stream", MediaItems.MediaItemController, :stream
|
||||||
end
|
end
|
||||||
|
|
||||||
# Enable LiveDashboard and Swoosh mailbox preview in development
|
# Enable LiveDashboard and Swoosh mailbox preview in development
|
||||||
|
|
@ -54,6 +62,14 @@ defmodule PinchflatWeb.Router do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_basic_auth(conn, opts) do
|
||||||
|
if Application.get_env(:pinchflat, :expose_feed_endpoints) do
|
||||||
|
conn
|
||||||
|
else
|
||||||
|
basic_auth(conn, opts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp basic_auth(conn, _opts) do
|
defp basic_auth(conn, _opts) do
|
||||||
username = Application.get_env(:pinchflat, :basic_auth_username)
|
username = Application.get_env(:pinchflat, :basic_auth_username)
|
||||||
password = Application.get_env(:pinchflat, :basic_auth_password)
|
password = Application.get_env(:pinchflat, :basic_auth_password)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
defmodule Pinchflat.Repo.Migrations.BackfillContentUuids do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
execute("UPDATE sources SET uuid = gen_random_uuid() WHERE uuid IS NULL")
|
||||||
|
execute("UPDATE media_items SET uuid = gen_random_uuid() WHERE uuid IS NULL")
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
execute("UPDATE sources SET uuid = NULL")
|
||||||
|
execute("UPDATE media_items SET uuid = NULL")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -57,7 +57,7 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
|
||||||
_url, _opts, ot when ot == @metadata_ot -> {:ok, render_metadata(:channel_source_metadata)}
|
_url, _opts, ot when ot == @metadata_ot -> {:ok, render_metadata(:channel_source_metadata)}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
source = source_fixture()
|
source = source_fixture(%{description: nil})
|
||||||
|
|
||||||
refute source.description
|
refute source.description
|
||||||
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
||||||
|
|
|
||||||
64
test/pinchflat/podcasts/podcast_helpers_test.exs
Normal file
64
test/pinchflat/podcasts/podcast_helpers_test.exs
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
defmodule Pinchflat.Podcasts.PodcastHelpersTest do
|
||||||
|
use Pinchflat.DataCase
|
||||||
|
|
||||||
|
import Pinchflat.SourcesFixtures
|
||||||
|
import Pinchflat.MediaFixtures
|
||||||
|
|
||||||
|
alias Pinchflat.Podcasts.PodcastHelpers
|
||||||
|
|
||||||
|
describe "persisted_media_items_for/2" do
|
||||||
|
test "returns media items with files that exist on-disk" do
|
||||||
|
source = source_fixture()
|
||||||
|
good_media = media_item_with_attachments(%{source_id: source.id})
|
||||||
|
_bad_media = media_item_fixture(%{source_id: source.id, media_filepath: "/tmp/existing_file.mp3"})
|
||||||
|
|
||||||
|
assert [persisted_media] = PodcastHelpers.persisted_media_items_for(source)
|
||||||
|
assert persisted_media.id == good_media.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "lets you specify a limit" do
|
||||||
|
source = source_fixture()
|
||||||
|
_good_media = media_item_with_attachments(%{source_id: source.id})
|
||||||
|
|
||||||
|
assert [] = PodcastHelpers.persisted_media_items_for(source, limit: 0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "select_cover_image/2" do
|
||||||
|
test "returns a source's poster, if present" do
|
||||||
|
source = source_with_metadata_attachments()
|
||||||
|
|
||||||
|
{:ok, res} = PodcastHelpers.select_cover_image(source, [])
|
||||||
|
|
||||||
|
assert res == source.metadata.poster_filepath
|
||||||
|
end
|
||||||
|
|
||||||
|
test "falls back to a source's fanart, if present" do
|
||||||
|
source = source_with_metadata_attachments()
|
||||||
|
|
||||||
|
File.rm(source.metadata.poster_filepath)
|
||||||
|
|
||||||
|
{:ok, res} = PodcastHelpers.select_cover_image(source, [])
|
||||||
|
|
||||||
|
assert res == source.metadata.fanart_filepath
|
||||||
|
end
|
||||||
|
|
||||||
|
test "falls back to a media item's thumbnail, if present" do
|
||||||
|
source = source_with_metadata_attachments()
|
||||||
|
media_item = media_item_with_metadata_attachments(%{source_id: source.id})
|
||||||
|
|
||||||
|
File.rm(source.metadata.poster_filepath)
|
||||||
|
File.rm(source.metadata.fanart_filepath)
|
||||||
|
|
||||||
|
{:ok, res} = PodcastHelpers.select_cover_image(source, [media_item])
|
||||||
|
|
||||||
|
assert res == media_item.metadata.thumbnail_filepath
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error if no artwork can be found" do
|
||||||
|
source = source_fixture()
|
||||||
|
|
||||||
|
assert PodcastHelpers.select_cover_image(source, []) == {:error, :no_suitable_image}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
145
test/pinchflat/podcasts/rss_feed_builder_test.exs
Normal file
145
test/pinchflat/podcasts/rss_feed_builder_test.exs
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
defmodule Pinchflat.Podcasts.RssFeedBuilderTest do
|
||||||
|
use Pinchflat.DataCase
|
||||||
|
|
||||||
|
import Pinchflat.MediaFixtures
|
||||||
|
import Pinchflat.SourcesFixtures
|
||||||
|
|
||||||
|
alias Pinchflat.Podcasts.RssFeedBuilder
|
||||||
|
|
||||||
|
@datetime_format "%a, %d %b %Y %H:%M:%S %z"
|
||||||
|
|
||||||
|
setup do
|
||||||
|
source = source_fixture()
|
||||||
|
|
||||||
|
{:ok, source: source}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "build/2" do
|
||||||
|
test "returns an XML document", %{source: source} do
|
||||||
|
res = RssFeedBuilder.build(source)
|
||||||
|
|
||||||
|
assert String.contains?(res, ~s(<?xml version="1.0" encoding="UTF-8"?>))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "escapes illegal characters" do
|
||||||
|
source = source_fixture(%{custom_name: "A & B"})
|
||||||
|
res = RssFeedBuilder.build(source)
|
||||||
|
|
||||||
|
assert String.contains?(res, ~s(<title>A & B</title>))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can optionally apply a limit to media items", %{source: source} do
|
||||||
|
good_media = media_item_with_attachments(%{source_id: source.id})
|
||||||
|
|
||||||
|
res = RssFeedBuilder.build(source, limit: 0)
|
||||||
|
|
||||||
|
refute String.contains?(res, ~s(<title>#{good_media.title}</title>))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can optionally specify a URL base", %{source: source} do
|
||||||
|
res = RssFeedBuilder.build(source, url_base: "http://example.com")
|
||||||
|
|
||||||
|
assert String.contains?(res, ~s(http://example.com/sources/#{source.uuid}/feed.xml))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "build/2 when testing source XML" do
|
||||||
|
test "returns XML for static source attributes", %{source: source} do
|
||||||
|
res = RssFeedBuilder.build(source)
|
||||||
|
|
||||||
|
assert String.contains?(res, ~s(<title>#{source.custom_name}</title>))
|
||||||
|
assert String.contains?(res, ~s(<link>#{source.original_url}</link>))
|
||||||
|
assert String.contains?(res, ~s(<description>#{source.description}</description>))
|
||||||
|
assert String.contains?(res, ~s(<itunes:author>#{source.custom_name}</itunes:author>))
|
||||||
|
assert String.contains?(res, ~s(<itunes:subtitle>#{source.custom_name}</itunes:subtitle>))
|
||||||
|
assert String.contains?(res, ~s(<description>#{source.description}</description>))
|
||||||
|
assert String.contains?(res, ~s(<podcast:guid>#{source.uuid}</podcast:guid>))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns the lastBuildDate and pubDate based off the source's timestamps", %{source: source} do
|
||||||
|
res = RssFeedBuilder.build(source)
|
||||||
|
|
||||||
|
assert String.contains?(res, ~s(<lastBuildDate>#{format_date(source.updated_at)}</lastBuildDate>))
|
||||||
|
assert String.contains?(res, ~s(<pubDate>#{format_date(source.inserted_at)}</pubDate>))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns a self-link", %{source: source} do
|
||||||
|
res = RssFeedBuilder.build(source)
|
||||||
|
|
||||||
|
assert String.contains?(
|
||||||
|
res,
|
||||||
|
~s(<atom:link href="http://localhost:8945/sources/#{source.uuid}/feed.xml" rel="self" type="application/rss+xml" />)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns a link to the feed image" do
|
||||||
|
source = source_with_metadata_attachments()
|
||||||
|
|
||||||
|
res = RssFeedBuilder.build(source)
|
||||||
|
[_before, image_block, _after] = String.split(res, ~r(</?image>))
|
||||||
|
|
||||||
|
assert String.contains?(image_block, ~s(<url>http://localhost:8945/sources/#{source.uuid}/feed_image.jpg</url>))
|
||||||
|
assert String.contains?(image_block, ~s(<title>#{source.custom_name}</title>))
|
||||||
|
assert String.contains?(image_block, ~s(<link>#{source.original_url}</link>))
|
||||||
|
|
||||||
|
assert String.contains?(
|
||||||
|
res,
|
||||||
|
~s(<itunes:image href="http://localhost:8945/sources/#{source.uuid}/feed_image.jpg"></itunes:image>)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "build/2 when testing media XML" do
|
||||||
|
test "only includes media persisted to disk", %{source: source} do
|
||||||
|
good_media = media_item_with_attachments(%{source_id: source.id})
|
||||||
|
bad_media = media_item_fixture(%{source_id: source.id, media_filepath: "/tmp/existing_file.mp3"})
|
||||||
|
pending_media = media_item_fixture(%{source_id: source.id, media_filepath: nil})
|
||||||
|
|
||||||
|
res = RssFeedBuilder.build(source)
|
||||||
|
|
||||||
|
assert String.contains?(res, ~s(<title>#{good_media.title}</title>))
|
||||||
|
refute String.contains?(res, ~s(<title>#{bad_media.title}</title>))
|
||||||
|
refute String.contains?(res, ~s(<title>#{pending_media.title}</title>))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns XML for static media attributes", %{source: source} do
|
||||||
|
media_item = media_item_with_attachments(%{source_id: source.id})
|
||||||
|
|
||||||
|
res = RssFeedBuilder.build(source)
|
||||||
|
[_before, item_xml, _after] = String.split(res, ~r(</?item>))
|
||||||
|
|
||||||
|
assert String.contains?(item_xml, ~s(<guid isPermaLink="false">#{media_item.uuid}</guid>))
|
||||||
|
assert String.contains?(item_xml, ~s(<title>#{media_item.title}</title>))
|
||||||
|
assert String.contains?(item_xml, ~s(<link>#{media_item.original_url}</link>))
|
||||||
|
assert String.contains?(item_xml, ~s(<description>#{media_item.description}</description>))
|
||||||
|
assert String.contains?(item_xml, ~s(<itunes:author>#{source.custom_name}</itunes:author>))
|
||||||
|
assert String.contains?(item_xml, ~s(<itunes:subtitle>#{media_item.title}</itunes:subtitle>))
|
||||||
|
assert String.contains?(item_xml, ~s(<itunes:summary><![CDATA[#{media_item.description}]]></itunes:summary>))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns pubDate based off the media's upload_date", %{source: source} do
|
||||||
|
media_item_with_attachments(%{source_id: source.id, upload_date: ~D[2020-01-01]})
|
||||||
|
|
||||||
|
res = RssFeedBuilder.build(source)
|
||||||
|
[_before, item_xml, _after] = String.split(res, ~r(</?item>))
|
||||||
|
|
||||||
|
assert String.contains?(item_xml, ~s(<pubDate>Wed, 01 Jan 2020 00:00:00 +0000</pubDate>))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns an enclosure tag with the media's stream URL", %{source: source} do
|
||||||
|
media_item = media_item_with_attachments(%{source_id: source.id, media_size_bytes: 1234})
|
||||||
|
|
||||||
|
res = RssFeedBuilder.build(source)
|
||||||
|
[_before, item_xml, _after] = String.split(res, ~r(</?item>))
|
||||||
|
|
||||||
|
assert String.contains?(item_xml, ~s(<enclosure))
|
||||||
|
assert String.contains?(item_xml, ~s(url="http://localhost:8945/media/#{media_item.uuid}/stream.mp4"))
|
||||||
|
assert String.contains?(item_xml, ~s(length="1234"))
|
||||||
|
assert String.contains?(item_xml, ~s(type="video/mp4"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_date(date) do
|
||||||
|
Calendar.strftime(date, @datetime_format)
|
||||||
|
end
|
||||||
|
end
|
||||||
14
test/pinchflat/utils/datetime_utils_test.exs
Normal file
14
test/pinchflat/utils/datetime_utils_test.exs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
defmodule Pinchflat.Utils.DatetimeUtilsTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Pinchflat.Utils.DatetimeUtils
|
||||||
|
|
||||||
|
describe "date_to_datetime/1" do
|
||||||
|
test "converts a Date to a DateTime" do
|
||||||
|
date = ~D[2022-01-01]
|
||||||
|
datetime = DatetimeUtils.date_to_datetime(date)
|
||||||
|
|
||||||
|
assert datetime == ~U[2022-01-01 00:00:00Z]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
38
test/pinchflat_web/controllers/podcast_controller_test.exs
Normal file
38
test/pinchflat_web/controllers/podcast_controller_test.exs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
defmodule PinchflatWeb.PodcastControllerTest do
|
||||||
|
use PinchflatWeb.ConnCase
|
||||||
|
|
||||||
|
import Pinchflat.SourcesFixtures
|
||||||
|
|
||||||
|
describe "rss_feed" do
|
||||||
|
test "renders the XML document", %{conn: conn} do
|
||||||
|
source = source_fixture()
|
||||||
|
|
||||||
|
conn = get(conn, ~p"/sources/#{source.uuid}/feed" <> ".xml")
|
||||||
|
|
||||||
|
assert conn.status == 200
|
||||||
|
assert {"content-type", "application/rss+xml; charset=utf-8"} in conn.resp_headers
|
||||||
|
assert {"content-disposition", "inline"} in conn.resp_headers
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "feed_image" do
|
||||||
|
test "returns a feed image if one can be found", %{conn: conn} do
|
||||||
|
source = source_with_metadata_attachments()
|
||||||
|
|
||||||
|
conn = get(conn, ~p"/sources/#{source.uuid}/feed_image" <> ".jpg")
|
||||||
|
|
||||||
|
assert conn.status == 200
|
||||||
|
assert {"content-type", "image/jpeg; charset=utf-8"} in conn.resp_headers
|
||||||
|
assert conn.resp_body == File.read!(source.metadata.poster_filepath)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 404 if an image cannot be found", %{conn: conn} do
|
||||||
|
source = source_fixture()
|
||||||
|
|
||||||
|
conn = get(conn, ~p"/sources/#{source.uuid}/feed_image" <> ".jpg")
|
||||||
|
|
||||||
|
assert conn.status == 404
|
||||||
|
assert conn.resp_body == "Image not found"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
defmodule PinchflatWeb.RoutingTest do
|
defmodule PinchflatWeb.RoutingTest do
|
||||||
use PinchflatWeb.ConnCase
|
use PinchflatWeb.ConnCase
|
||||||
|
|
||||||
|
import Pinchflat.SourcesFixtures
|
||||||
|
|
||||||
describe "basic_auth plug" do
|
describe "basic_auth plug" do
|
||||||
setup do
|
setup do
|
||||||
old_username = Application.get_env(:pinchflat, :basic_auth_username)
|
old_username = Application.get_env(:pinchflat, :basic_auth_username)
|
||||||
|
|
@ -54,4 +56,53 @@ defmodule PinchflatWeb.RoutingTest do
|
||||||
assert conn.status == 200
|
assert conn.status == 200
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "maybe_basic_auth plug" do
|
||||||
|
setup do
|
||||||
|
old_username = Application.get_env(:pinchflat, :basic_auth_username)
|
||||||
|
old_password = Application.get_env(:pinchflat, :basic_auth_password)
|
||||||
|
old_expose_feed_endpoints = Application.get_env(:pinchflat, :expose_feed_endpoints)
|
||||||
|
|
||||||
|
source = source_fixture()
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_username, old_username)
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_password, old_password)
|
||||||
|
Application.put_env(:pinchflat, :expose_feed_endpoints, old_expose_feed_endpoints)
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok, source: source}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uses basic auth when expose_feed_endpoints is false", %{source: source} do
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_username, "user")
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_password, "pass")
|
||||||
|
Application.put_env(:pinchflat, :expose_feed_endpoints, false)
|
||||||
|
|
||||||
|
conn = get(build_conn(), "/sources/#{source.uuid}/feed")
|
||||||
|
|
||||||
|
assert conn.status == 401
|
||||||
|
assert {"www-authenticate", "Basic realm=\"Pinchflat\""} in conn.resp_headers
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not use basic auth when expose_feed_endpoints is true", %{source: source} do
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_username, "user")
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_password, "pass")
|
||||||
|
Application.put_env(:pinchflat, :expose_feed_endpoints, true)
|
||||||
|
|
||||||
|
conn = get(build_conn(), "/sources/#{source.uuid}/feed")
|
||||||
|
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not use basic auth when username/password aren't set", %{source: source} do
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_username, nil)
|
||||||
|
Application.put_env(:pinchflat, :basic_auth_password, nil)
|
||||||
|
Application.put_env(:pinchflat, :expose_feed_endpoints, false)
|
||||||
|
|
||||||
|
conn = get(build_conn(), "/sources/#{source.uuid}/feed")
|
||||||
|
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ defmodule Pinchflat.MediaFixtures do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Pinchflat.SourcesFixtures
|
alias Pinchflat.SourcesFixtures
|
||||||
|
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Generate a media_item.
|
Generate a media_item.
|
||||||
|
|
@ -44,6 +45,27 @@ defmodule Pinchflat.MediaFixtures do
|
||||||
media_item_fixture(merged_attrs)
|
media_item_fixture(merged_attrs)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def media_item_with_metadata_attachments(attrs \\ %{}) do
|
||||||
|
metadata_dir =
|
||||||
|
Path.join(Application.get_env(:pinchflat, :metadata_directory), "#{:rand.uniform(1_000_000)}")
|
||||||
|
|
||||||
|
json_gz_filepath = Path.join(metadata_dir, "metadata.json.gz")
|
||||||
|
thumbnail_filepath = Path.join(metadata_dir, "thumbnail.jpg")
|
||||||
|
|
||||||
|
FilesystemHelpers.cp_p!(media_metadata_filepath_fixture(), json_gz_filepath)
|
||||||
|
FilesystemHelpers.cp_p!(thumbnail_filepath_fixture(), thumbnail_filepath)
|
||||||
|
|
||||||
|
merged_attrs =
|
||||||
|
Map.merge(attrs, %{
|
||||||
|
metadata: %{
|
||||||
|
metadata_filepath: json_gz_filepath,
|
||||||
|
thumbnail_filepath: thumbnail_filepath
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
media_item_with_attachments(merged_attrs)
|
||||||
|
end
|
||||||
|
|
||||||
def media_item_with_attachments(attrs \\ %{}) do
|
def media_item_with_attachments(attrs \\ %{}) do
|
||||||
stored_media_filepath =
|
stored_media_filepath =
|
||||||
Path.join([
|
Path.join([
|
||||||
|
|
@ -52,10 +74,7 @@ defmodule Pinchflat.MediaFixtures do
|
||||||
"#{:rand.uniform(1_000_000)}_media.mp4"
|
"#{:rand.uniform(1_000_000)}_media.mp4"
|
||||||
])
|
])
|
||||||
|
|
||||||
fixture_media_filepath = media_filepath_fixture()
|
FilesystemHelpers.cp_p!(media_filepath_fixture(), stored_media_filepath)
|
||||||
|
|
||||||
:ok = File.mkdir_p(Path.dirname(stored_media_filepath))
|
|
||||||
:ok = File.cp(fixture_media_filepath, stored_media_filepath)
|
|
||||||
|
|
||||||
merged_attrs = Map.merge(attrs, %{media_filepath: stored_media_filepath})
|
merged_attrs = Map.merge(attrs, %{media_filepath: stored_media_filepath})
|
||||||
media_item_fixture(merged_attrs)
|
media_item_fixture(merged_attrs)
|
||||||
|
|
@ -105,4 +124,14 @@ defmodule Pinchflat.MediaFixtures do
|
||||||
"example.info.json"
|
"example.info.json"
|
||||||
])
|
])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def media_metadata_filepath_fixture do
|
||||||
|
Path.join([
|
||||||
|
File.cwd!(),
|
||||||
|
"test",
|
||||||
|
"support",
|
||||||
|
"files",
|
||||||
|
"media_metadata.json"
|
||||||
|
])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ defmodule Pinchflat.SourcesFixtures do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.ProfilesFixtures
|
alias Pinchflat.MediaFixtures
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
|
alias Pinchflat.ProfilesFixtures
|
||||||
|
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Generate a source.
|
Generate a source.
|
||||||
|
|
@ -22,6 +24,7 @@ defmodule Pinchflat.SourcesFixtures do
|
||||||
collection_id: Base.encode16(:crypto.hash(:md5, "#{:rand.uniform(1_000_000)}")),
|
collection_id: Base.encode16(:crypto.hash(:md5, "#{:rand.uniform(1_000_000)}")),
|
||||||
collection_type: "channel",
|
collection_type: "channel",
|
||||||
custom_name: "Cool and good internal name!",
|
custom_name: "Cool and good internal name!",
|
||||||
|
description: "This is a description",
|
||||||
original_url: "https://www.youtube.com/channel/#{Faker.String.base64(12)}",
|
original_url: "https://www.youtube.com/channel/#{Faker.String.base64(12)}",
|
||||||
media_profile_id: ProfilesFixtures.media_profile_fixture().id,
|
media_profile_id: ProfilesFixtures.media_profile_fixture().id,
|
||||||
index_frequency_minutes: 60
|
index_frequency_minutes: 60
|
||||||
|
|
@ -48,6 +51,30 @@ defmodule Pinchflat.SourcesFixtures do
|
||||||
source_fixture(merged_attrs)
|
source_fixture(merged_attrs)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def source_with_metadata_attachments(attrs \\ %{}) do
|
||||||
|
metadata_dir =
|
||||||
|
Path.join(Application.get_env(:pinchflat, :metadata_directory), "#{:rand.uniform(1_000_000)}")
|
||||||
|
|
||||||
|
json_gz_filepath = Path.join(metadata_dir, "metadata.json.gz")
|
||||||
|
poster_filepath = Path.join(metadata_dir, "poster.jpg")
|
||||||
|
fanart_filepath = Path.join(metadata_dir, "fanart.jpg")
|
||||||
|
|
||||||
|
FilesystemHelpers.cp_p!(MediaFixtures.media_metadata_filepath_fixture(), json_gz_filepath)
|
||||||
|
FilesystemHelpers.cp_p!(MediaFixtures.thumbnail_filepath_fixture(), poster_filepath)
|
||||||
|
FilesystemHelpers.cp_p!(MediaFixtures.thumbnail_filepath_fixture(), fanart_filepath)
|
||||||
|
|
||||||
|
merged_attrs =
|
||||||
|
Map.merge(attrs, %{
|
||||||
|
metadata: %{
|
||||||
|
metadata_filepath: json_gz_filepath,
|
||||||
|
poster_filepath: poster_filepath,
|
||||||
|
fanart_filepath: fanart_filepath
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
source_fixture(merged_attrs)
|
||||||
|
end
|
||||||
|
|
||||||
def source_attributes_return_fixture do
|
def source_attributes_return_fixture do
|
||||||
source_attributes = [
|
source_attributes = [
|
||||||
%{
|
%{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue