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"""
- -
+
+ +
+ <%= render_slot(@tab_append) %> +
+
+
<%= render_slot(tab) %>
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">

Attributes for "<%= @media_profile.name %>"

<.list_items_from_map map={Map.from_struct(@media_profile)} />
- -
- <.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 - - - <.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 - - -
<:tab title="Sources"> <.table rows={@media_profile.sources} table_class="text-black dark:text-white"> diff --git a/lib/pinchflat_web/controllers/podcasts/podcast_controller.ex b/lib/pinchflat_web/controllers/podcasts/podcast_controller.ex new file mode 100644 index 0000000..aa65c57 --- /dev/null +++ b/lib/pinchflat_web/controllers/podcasts/podcast_controller.ex @@ -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 diff --git a/lib/pinchflat_web/controllers/sources/source_html.ex b/lib/pinchflat_web/controllers/sources/source_html.ex index dcb6b05..b93b754 100644 --- a/lib/pinchflat_web/controllers/sources/source_html.ex +++ b/lib/pinchflat_web/controllers/sources/source_html.ex @@ -24,4 +24,8 @@ defmodule PinchflatWeb.Sources.SourceHTML do {"Monthly", 30 * 24 * 60} ] end + + def rss_feed_url(conn, source) do + url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml" + end end diff --git a/lib/pinchflat_web/controllers/sources/source_html/show.html.heex b/lib/pinchflat_web/controllers/sources/source_html/show.html.heex index 8474d64..3c2934b 100644 --- a/lib/pinchflat_web/controllers/sources/source_html/show.html.heex +++ b/lib/pinchflat_web/controllers/sources/source_html/show.html.heex @@ -19,6 +19,45 @@
<.tabbed_layout> + <:tab_append> + <.button_dropdown text="Actions" class="justify-center w-full sm:w-50"> + <:option> + copied = false, 4000); + "} + > + Copy RSS Feed <.icon name="hero-check" class="ml-2 h-4 w-4" /> + + + <: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 + + + <: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 + + + + + <:tab title="Attributes">

Attributes

@@ -31,28 +70,6 @@ <.list_items_from_map map={Map.from_struct(@source)} />
- -
- <.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 - - - <.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 - - -
<:tab title="Pending Media"> <%= if match?([_|_], @pending_media) do %> diff --git a/lib/pinchflat_web/endpoint.ex b/lib/pinchflat_web/endpoint.ex index a3a2acb..b6ab016 100644 --- a/lib/pinchflat_web/endpoint.ex +++ b/lib/pinchflat_web/endpoint.ex @@ -47,5 +47,54 @@ defmodule PinchflatWeb.Endpoint do plug Plug.MethodOverride plug Plug.Head plug Plug.Session, @session_options + + plug :override_base_url + plug :strip_trailing_extension + 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 diff --git a/lib/pinchflat_web/router.ex b/lib/pinchflat_web/router.ex index eae7109..6b70d03 100644 --- a/lib/pinchflat_web/router.ex +++ b/lib/pinchflat_web/router.ex @@ -1,6 +1,8 @@ defmodule PinchflatWeb.Router do use PinchflatWeb, :router + # IMPORTANT: `strip_trailing_extension` in endpoint.ex removes + # the extension from the path pipeline :browser do plug :basic_auth plug :accepts, ["html"] @@ -15,6 +17,10 @@ defmodule PinchflatWeb.Router do plug :accepts, ["json"] end + pipeline :feeds do + plug :maybe_basic_auth + end + scope "/", PinchflatWeb do pipe_through :browser @@ -28,13 +34,15 @@ defmodule PinchflatWeb.Router do 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. - # - # 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 - 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 # Enable LiveDashboard and Swoosh mailbox preview in development @@ -54,6 +62,14 @@ defmodule PinchflatWeb.Router do 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 username = Application.get_env(:pinchflat, :basic_auth_username) password = Application.get_env(:pinchflat, :basic_auth_password) diff --git a/priv/repo/migrations/20240323165649_add_uuid_to_source_and_media.exs b/priv/repo/migrations/20240325222242_add_uuid_to_source_and_media.exs similarity index 100% rename from priv/repo/migrations/20240323165649_add_uuid_to_source_and_media.exs rename to priv/repo/migrations/20240325222242_add_uuid_to_source_and_media.exs diff --git a/priv/repo/migrations/20240325222243_backfill_content_uuids.exs b/priv/repo/migrations/20240325222243_backfill_content_uuids.exs new file mode 100644 index 0000000..dc20e51 --- /dev/null +++ b/priv/repo/migrations/20240325222243_backfill_content_uuids.exs @@ -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 diff --git a/test/pinchflat/metadata/source_metadata_storage_worker_test.exs b/test/pinchflat/metadata/source_metadata_storage_worker_test.exs index 878f8fd..06e6959 100644 --- a/test/pinchflat/metadata/source_metadata_storage_worker_test.exs +++ b/test/pinchflat/metadata/source_metadata_storage_worker_test.exs @@ -57,7 +57,7 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do _url, _opts, ot when ot == @metadata_ot -> {:ok, render_metadata(:channel_source_metadata)} end) - source = source_fixture() + source = source_fixture(%{description: nil}) refute source.description perform_job(SourceMetadataStorageWorker, %{id: source.id}) diff --git a/test/pinchflat/podcasts/podcast_helpers_test.exs b/test/pinchflat/podcasts/podcast_helpers_test.exs new file mode 100644 index 0000000..1d21d05 --- /dev/null +++ b/test/pinchflat/podcasts/podcast_helpers_test.exs @@ -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 diff --git a/test/pinchflat/podcasts/rss_feed_builder_test.exs b/test/pinchflat/podcasts/rss_feed_builder_test.exs new file mode 100644 index 0000000..99bf32c --- /dev/null +++ b/test/pinchflat/podcasts/rss_feed_builder_test.exs @@ -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()) + end + + test "escapes illegal characters" do + source = source_fixture(%{custom_name: "A & B"}) + res = RssFeedBuilder.build(source) + + assert String.contains?(res, ~s(A & B)) + 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(#{good_media.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(#{source.custom_name})) + assert String.contains?(res, ~s(#{source.original_url})) + assert String.contains?(res, ~s(#{source.description})) + assert String.contains?(res, ~s(#{source.custom_name})) + assert String.contains?(res, ~s(#{source.custom_name})) + assert String.contains?(res, ~s(#{source.description})) + assert String.contains?(res, ~s(#{source.uuid})) + 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(#{format_date(source.updated_at)})) + assert String.contains?(res, ~s(#{format_date(source.inserted_at)})) + end + + test "returns a self-link", %{source: source} do + res = RssFeedBuilder.build(source) + + assert String.contains?( + res, + ~s() + ) + 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()) + + assert String.contains?(image_block, ~s(http://localhost:8945/sources/#{source.uuid}/feed_image.jpg)) + assert String.contains?(image_block, ~s(#{source.custom_name})) + assert String.contains?(image_block, ~s(#{source.original_url})) + + assert String.contains?( + res, + ~s() + ) + 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(#{good_media.title})) + refute String.contains?(res, ~s(#{bad_media.title})) + refute String.contains?(res, ~s(#{pending_media.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()) + + assert String.contains?(item_xml, ~s(#{media_item.uuid})) + assert String.contains?(item_xml, ~s(#{media_item.title})) + assert String.contains?(item_xml, ~s(#{media_item.original_url})) + assert String.contains?(item_xml, ~s(#{media_item.description})) + assert String.contains?(item_xml, ~s(#{source.custom_name})) + assert String.contains?(item_xml, ~s(#{media_item.title})) + assert String.contains?(item_xml, ~s()) + 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()) + + assert String.contains?(item_xml, ~s(Wed, 01 Jan 2020 00:00:00 +0000)) + 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()) + + assert String.contains?(item_xml, ~s( ".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 diff --git a/test/pinchflat_web/routing_test.exs b/test/pinchflat_web/routing_test.exs index 37c047d..cbe4dd8 100644 --- a/test/pinchflat_web/routing_test.exs +++ b/test/pinchflat_web/routing_test.exs @@ -1,6 +1,8 @@ defmodule PinchflatWeb.RoutingTest do use PinchflatWeb.ConnCase + import Pinchflat.SourcesFixtures + describe "basic_auth plug" do setup do old_username = Application.get_env(:pinchflat, :basic_auth_username) @@ -54,4 +56,53 @@ defmodule PinchflatWeb.RoutingTest do assert conn.status == 200 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 diff --git a/test/support/fixtures/media_fixtures.ex b/test/support/fixtures/media_fixtures.ex index d9c208c..f724039 100644 --- a/test/support/fixtures/media_fixtures.ex +++ b/test/support/fixtures/media_fixtures.ex @@ -5,6 +5,7 @@ defmodule Pinchflat.MediaFixtures do """ alias Pinchflat.SourcesFixtures + alias Pinchflat.Filesystem.FilesystemHelpers @doc """ Generate a media_item. @@ -44,6 +45,27 @@ defmodule Pinchflat.MediaFixtures do media_item_fixture(merged_attrs) 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 stored_media_filepath = Path.join([ @@ -52,10 +74,7 @@ defmodule Pinchflat.MediaFixtures do "#{:rand.uniform(1_000_000)}_media.mp4" ]) - fixture_media_filepath = media_filepath_fixture() - - :ok = File.mkdir_p(Path.dirname(stored_media_filepath)) - :ok = File.cp(fixture_media_filepath, stored_media_filepath) + FilesystemHelpers.cp_p!(media_filepath_fixture(), stored_media_filepath) merged_attrs = Map.merge(attrs, %{media_filepath: stored_media_filepath}) media_item_fixture(merged_attrs) @@ -105,4 +124,14 @@ defmodule Pinchflat.MediaFixtures do "example.info.json" ]) end + + def media_metadata_filepath_fixture do + Path.join([ + File.cwd!(), + "test", + "support", + "files", + "media_metadata.json" + ]) + end end diff --git a/test/support/fixtures/sources_fixtures.ex b/test/support/fixtures/sources_fixtures.ex index 0db2e78..55375e5 100644 --- a/test/support/fixtures/sources_fixtures.ex +++ b/test/support/fixtures/sources_fixtures.ex @@ -5,8 +5,10 @@ defmodule Pinchflat.SourcesFixtures do """ alias Pinchflat.Repo - alias Pinchflat.ProfilesFixtures + alias Pinchflat.MediaFixtures alias Pinchflat.Sources.Source + alias Pinchflat.ProfilesFixtures + alias Pinchflat.Filesystem.FilesystemHelpers @doc """ Generate a source. @@ -22,6 +24,7 @@ defmodule Pinchflat.SourcesFixtures do collection_id: Base.encode16(:crypto.hash(:md5, "#{:rand.uniform(1_000_000)}")), collection_type: "channel", custom_name: "Cool and good internal name!", + description: "This is a description", original_url: "https://www.youtube.com/channel/#{Faker.String.base64(12)}", media_profile_id: ProfilesFixtures.media_profile_fixture().id, index_frequency_minutes: 60 @@ -48,6 +51,30 @@ defmodule Pinchflat.SourcesFixtures do source_fixture(merged_attrs) 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 source_attributes = [ %{