diff --git a/config/config.exs b/config/config.exs
index 76f4b01..4c26b3b 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -20,8 +20,9 @@ config :pinchflat,
tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]),
# 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.
- basic_auth_username: System.get_env("BASIC_AUTH_USERNAME"),
- basic_auth_password: System.get_env("BASIC_AUTH_PASSWORD"),
+ basic_auth_username: "",
+ basic_auth_password: "",
+ expose_feed_endpoints: false,
file_watcher_poll_interval: 1000
# Configures the endpoint
diff --git a/config/runtime.exs b/config/runtime.exs
index d2a9335..1f4150e 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -47,6 +47,9 @@ if config_env() == :prod do
metadata_path = System.get_env("METADATA_PATH", Path.join([config_path, "metadata"]))
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
acceptable_log_levels = ~w(debug info)a
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,
extras_directory: extras_path,
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,
database: db_path,
@@ -121,54 +125,4 @@ if config_env() == :prod do
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
diff --git a/lib/pinchflat/boot/pre_job_startup_tasks.ex b/lib/pinchflat/boot/pre_job_startup_tasks.ex
index e1bb4c0..0e28d94 100644
--- a/lib/pinchflat/boot/pre_job_startup_tasks.ex
+++ b/lib/pinchflat/boot/pre_job_startup_tasks.ex
@@ -14,8 +14,6 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
alias Pinchflat.Repo
alias Pinchflat.Settings
- alias Pinchflat.Sources.Source
- alias Pinchflat.Media.MediaItem
alias Pinchflat.Filesystem.FilesystemHelpers
def start_link(opts \\ []) do
@@ -36,7 +34,6 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
reset_executing_jobs()
create_blank_cookie_file()
apply_default_settings()
- backfill_uuids()
{:ok, state}
end
@@ -68,18 +65,4 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
Settings.fetch!(:onboarding, true)
Settings.fetch!(:pro_enabled, false)
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
diff --git a/lib/pinchflat/podcasts/podcast_helpers.ex b/lib/pinchflat/podcasts/podcast_helpers.ex
new file mode 100644
index 0000000..33e328e
--- /dev/null
+++ b/lib/pinchflat/podcasts/podcast_helpers.ex
@@ -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
diff --git a/lib/pinchflat/podcasts/rss_feed_builder.ex b/lib/pinchflat/podcasts/rss_feed_builder.ex
new file mode 100644
index 0000000..54eb104
--- /dev/null
+++ b/lib/pinchflat/podcasts/rss_feed_builder.ex
@@ -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
+ """
+
+
+
+ #{safe(source.custom_name)}
+ #{source.original_url}
+ #{safe(source.description)}
+ TV & Film
+ Generated by Pinchflat
+ en-us
+ #{Calendar.strftime(source.updated_at, @datetime_format)}
+ #{Calendar.strftime(source.inserted_at, @datetime_format)}
+
+ yes
+ #{source.uuid}
+
+ #{feed_image_path}
+ #{safe(source.custom_name)}
+ #{source.original_url}
+
+ #{safe(source.custom_name)}
+ #{safe(source.custom_name)}
+ yes
+
+ false
+
+
+ #{Enum.join(media_item_xml, "\n")}
+
+
+
+ """
+ end
+
+ defp build_media_item_xml(source, media_item, url_base) do
+ """
+
+ #{media_item.uuid}
+ #{safe(media_item.title)}
+ #{media_item.original_url}
+ #{safe(media_item.description)}
+ #{generate_upload_date(media_item)}
+
+ #{safe(source.custom_name)}
+ #{safe(media_item.title)}
+
+ false
+
+ """
+ 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
diff --git a/lib/pinchflat/utils/datetime_utils.ex b/lib/pinchflat/utils/datetime_utils.ex
new file mode 100644
index 0000000..7fce7c6
--- /dev/null
+++ b/lib/pinchflat/utils/datetime_utils.ex
@@ -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
diff --git a/lib/pinchflat_web.ex b/lib/pinchflat_web.ex
index e7fbc32..ef58d00 100644
--- a/lib/pinchflat_web.ex
+++ b/lib/pinchflat_web.ex
@@ -21,7 +21,7 @@ defmodule PinchflatWeb do
def router do
quote do
- use Phoenix.Router, helpers: false
+ use Phoenix.Router, helpers: true
# Import common connection and controller functions to use in pipelines
import Plug.Conn
diff --git a/lib/pinchflat_web/components/core_components.ex b/lib/pinchflat_web/components/core_components.ex
index 52295d0..ee4c4da 100644
--- a/lib/pinchflat_web/components/core_components.ex
+++ b/lib/pinchflat_web/components/core_components.ex
@@ -14,7 +14,7 @@ defmodule PinchflatWeb.CoreComponents do
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
@@ -654,10 +654,11 @@ defmodule PinchflatWeb.CoreComponents do
"""
attr :name, :string, required: true
attr :class, :string, default: nil
+ attr :rest, :global
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
-
+
"""
end
diff --git a/lib/pinchflat_web/components/custom_components/button_components.ex b/lib/pinchflat_web/components/custom_components/button_components.ex
index c6f6e01..a80bb75 100644
--- a/lib/pinchflat_web/components/custom_components/button_components.ex
+++ b/lib/pinchflat_web/components/custom_components/button_components.ex
@@ -1,6 +1,8 @@
defmodule PinchflatWeb.CustomComponents.ButtonComponents do
@moduledoc false
- use Phoenix.Component
+ use Phoenix.Component, global_prefixes: ~w(x-)
+
+ alias PinchflatWeb.CoreComponents
@doc """
Render a button
@@ -39,4 +41,52 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
"""
end
+
+ @doc """
+ Render a dropdown based off a button
+
+ ## Examples
+
+ <.button_dropdown text="Actions">
+ <:option>TEST
+
+ """
+ attr :text, :string, required: true
+ attr :class, :string, default: ""
+
+ slot :option, required: true
+
+ def button_dropdown(assigns) do
+ ~H"""
+
+
+ <%= @text %>
+
+
+
+
+
+
+ <%= render_slot(option) %>
+
+
+
+
+
+ """
+ end
end
diff --git a/lib/pinchflat_web/components/custom_components/tab_components.ex b/lib/pinchflat_web/components/custom_components/tab_components.ex
index 1b0ae22..3e6876d 100644
--- a/lib/pinchflat_web/components/custom_components/tab_components.ex
+++ b/lib/pinchflat_web/components/custom_components/tab_components.ex
@@ -9,24 +9,31 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
attr :title, :string, required: true
end
+ slot :tab_append, required: false
+
def tabbed_layout(assigns) do
~H"""
diff --git a/lib/pinchflat_web/controllers/media_items/media_item_controller.ex b/lib/pinchflat_web/controllers/media_items/media_item_controller.ex
index bf44c77..31a9ef4 100644
--- a/lib/pinchflat_web/controllers/media_items/media_item_controller.ex
+++ b/lib/pinchflat_web/controllers/media_items/media_item_controller.ex
@@ -1,6 +1,8 @@
defmodule PinchflatWeb.MediaItems.MediaItemController do
use PinchflatWeb, :controller
+ require Logger
+
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Media.MediaItem
@@ -36,7 +38,7 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
#
# Uses the UUID instead of the ID to avoid enumeration attacks
# 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)
if File.exists?(media_item.media_filepath) do
@@ -45,6 +47,7 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
case parse_range(conn, file_size) do
{: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
conn
@@ -55,6 +58,8 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|> send_file(206, media_item.media_filepath, start_pos, length)
{:error, :invalid_range} ->
+ Logger.debug("Invalid range request for media item: #{media_item.uuid} - serving full file")
+
conn
|> put_resp_content_type(mime_type)
|> put_resp_header("content-length", to_string(file_size))
diff --git a/lib/pinchflat_web/controllers/media_items/media_item_html/show.html.heex b/lib/pinchflat_web/controllers/media_items/media_item_html/show.html.heex
index 7d56c51..fb81b1a 100644
--- a/lib/pinchflat_web/controllers/media_items/media_item_html/show.html.heex
+++ b/lib/pinchflat_web/controllers/media_items/media_item_html/show.html.heex
@@ -11,6 +11,20 @@
<.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
+
+
+
+
+
<:tab title="Attributes">
<%= if media_file_exists?(@media_item) do %>
@@ -28,18 +42,6 @@
<.list_items_from_map map={Map.from_struct(@media_item)} />
-
-
- <.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
-
-
-
<:tab title="Tasks">
<%= if match?([_|_], @media_item.tasks) do %>
diff --git a/lib/pinchflat_web/controllers/media_profiles/media_profile_html/media_profile_form.html.heex b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/media_profile_form.html.heex
index 9d07eb6..66073dd 100644
--- a/lib/pinchflat_web/controllers/media_profiles/media_profile_html/media_profile_form.html.heex
+++ b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/media_profile_form.html.heex
@@ -38,7 +38,7 @@
presets: {
default: 'Default',
media_center: 'TV Shows',
- audio: 'Audio',
+ audio: 'Music',
archiving: 'Archiving'
}
}">
diff --git a/lib/pinchflat_web/controllers/media_profiles/media_profile_html/show.html.heex b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/show.html.heex
index 0cff841..0160e86 100644
--- a/lib/pinchflat_web/controllers/media_profiles/media_profile_html/show.html.heex
+++ b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/show.html.heex
@@ -19,33 +19,35 @@
<.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
+
+
+ <: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
+
+
+
+
+
<:tab title="Attributes">