From 0d9003cffd96a6b546feb4779de3d71bc1399fb6 Mon Sep 17 00:00:00 2001 From: Kieran Eglin Date: Sat, 23 Mar 2024 13:20:28 -0700 Subject: [PATCH 01/18] Added plug to strip file extensions --- lib/pinchflat_web/endpoint.ex | 28 ++++++++++++++++++++++++++++ lib/pinchflat_web/router.ex | 2 ++ 2 files changed, 30 insertions(+) diff --git a/lib/pinchflat_web/endpoint.ex b/lib/pinchflat_web/endpoint.ex index a3a2acb..0bc99a6 100644 --- a/lib/pinchflat_web/endpoint.ex +++ b/lib/pinchflat_web/endpoint.ex @@ -47,5 +47,33 @@ defmodule PinchflatWeb.Endpoint do plug Plug.MethodOverride plug Plug.Head plug Plug.Session, @session_options + + plug :strip_trailing_extension + plug PinchflatWeb.Router + + 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..0aff573 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"] From 67f8000f5aec262221ff0eecb5b401a11ecf1601 Mon Sep 17 00:00:00 2001 From: Kieran Eglin Date: Sat, 23 Mar 2024 15:53:20 -0700 Subject: [PATCH 02/18] [VERY WIP] basic podcast RSS setup --- config/config.exs | 1 + config/runtime.exs | 53 +----------- lib/pinchflat/podcasts/rss_feed_builder.ex | 95 ++++++++++++++++++++++ 3 files changed, 98 insertions(+), 51 deletions(-) create mode 100644 lib/pinchflat/podcasts/rss_feed_builder.ex diff --git a/config/config.exs b/config/config.exs index e1f5e1d..33fbef8 100644 --- a/config/config.exs +++ b/config/config.exs @@ -21,6 +21,7 @@ config :pinchflat, # 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"), + url_base: System.get_env("BASIC_AUTH_PASSWORD") || "http://localhost:4008", file_watcher_poll_interval: 1000 # Configures the endpoint diff --git a/config/runtime.exs b/config/runtime.exs index 89d1d1a..b260571 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -66,7 +66,8 @@ if config_env() == :prod do config :pinchflat, yt_dlp_executable: System.find_executable("yt-dlp"), metadata_directory: metadata_path, - dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY") + dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"), + url_base: System.get_env("URL_BASE") || "http://localhost:8945" config :pinchflat, Pinchflat.Repo, database: db_path, @@ -123,54 +124,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/podcasts/rss_feed_builder.ex b/lib/pinchflat/podcasts/rss_feed_builder.ex new file mode 100644 index 0000000..45f07ed --- /dev/null +++ b/lib/pinchflat/podcasts/rss_feed_builder.ex @@ -0,0 +1,95 @@ +defmodule Pinchflat.Podcasts.RssFeedBuilder do + @datetime_format "%a, %d %b %Y %H:%M:%S %z" + + # TODO: only MIs that are confirmed to exist on-disk should be provided + def build(source, media_items) do + media_item_xml = Enum.map(media_items, &build_media_item_xml(source, &1)) + + build_source_xml(source, media_item_xml) + end + + defp build_media_item_xml(source, media_item) do + # NOTE: improvements for future: + # - Add + # - Serve proper images instead of the placeholders + """ + + #{media_item.uuid} + #{media_item.title} + #{media_item.original_url} + #{media_item.description} + #{generate_upload_date(media_item)} + + + #{source.custom_name} + #{media_item.title} + + + false + + """ + end + + defp build_source_xml(source, media_item_xml) do + # Useful: https://validator.w3.org/feed/#validate_by_input + # NOTE: improvements for future: + # - Add real + # - Add + # - Serve proper images instead of the placeholders + # - Ass + """ + + + + #{source.custom_name} + #{source.original_url} + #{source.custom_name} + TV & Film + Generated by Pinchflat + en-us + #{Calendar.strftime(DateTime.utc_now(), @datetime_format)} + #{Calendar.strftime(source.inserted_at, @datetime_format)} + + https://raw.githubusercontent.com/kieraneglin/pinchflat/master/priv/static/images/originals/logo.png + #{source.custom_name} + #{source.original_url} + + #{source.custom_name} + #{source.custom_name} + yes + + false + + + #{Enum.join(media_item_xml, "\n")} + + + + """ + end + + defp generate_media_stream_path(media_item) do + extension = Path.extname(media_item.media_filepath) + + "#{url_base()}/media/#{media_item.uuid}/stream#{extension}" + end + + defp generate_upload_date(media_item) do + media_item.upload_date + |> Date.to_gregorian_days() + |> Kernel.*(86400) + |> DateTime.from_gregorian_seconds() + |> Calendar.strftime(@datetime_format) + end + + defp determine_content_type(media_item) do + MIME.from_path(media_item.media_filepath) + end + + defp url_base do + Application.get_env(:pinchflat, :url_base) + end +end From 1413aa153a256c24fe72d881005df500d5549f44 Mon Sep 17 00:00:00 2001 From: Kieran Eglin Date: Sat, 23 Mar 2024 19:02:46 -0700 Subject: [PATCH 03/18] [WIP] got basic podcast RSS working --- lib/pinchflat/podcasts/rss_feed_builder.ex | 68 +++++++++++-------- .../controllers/sources/source_controller.ex | 14 ++++ lib/pinchflat_web/router.ex | 1 + 3 files changed, 53 insertions(+), 30 deletions(-) diff --git a/lib/pinchflat/podcasts/rss_feed_builder.ex b/lib/pinchflat/podcasts/rss_feed_builder.ex index 45f07ed..d7b041b 100644 --- a/lib/pinchflat/podcasts/rss_feed_builder.ex +++ b/lib/pinchflat/podcasts/rss_feed_builder.ex @@ -1,6 +1,7 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do @datetime_format "%a, %d %b %Y %H:%M:%S %z" + # TODO: test # TODO: only MIs that are confirmed to exist on-disk should be provided def build(source, media_items) do media_item_xml = Enum.map(media_items, &build_media_item_xml(source, &1)) @@ -8,41 +9,20 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do build_source_xml(source, media_item_xml) end - defp build_media_item_xml(source, media_item) do - # NOTE: improvements for future: - # - Add - # - Serve proper images instead of the placeholders - """ - - #{media_item.uuid} - #{media_item.title} - #{media_item.original_url} - #{media_item.description} - #{generate_upload_date(media_item)} - - - #{source.custom_name} - #{media_item.title} - - - false - - """ - end - defp build_source_xml(source, media_item_xml) do - # Useful: https://validator.w3.org/feed/#validate_by_input - # NOTE: improvements for future: + # 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 + # # - Add real - # - Add # - Serve proper images instead of the placeholders - # - Ass """ - + #{source.custom_name} #{source.original_url} @@ -52,6 +32,9 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do en-us #{Calendar.strftime(DateTime.utc_now(), @datetime_format)} #{Calendar.strftime(source.inserted_at, @datetime_format)} + + false + #{source.uuid} https://raw.githubusercontent.com/kieraneglin/pinchflat/master/priv/static/images/originals/logo.png #{source.custom_name} @@ -71,6 +54,31 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do """ end + defp build_media_item_xml(source, media_item) do + """ + + #{media_item.uuid} + #{media_item.title} + #{media_item.original_url} + #{media_item.description} + #{generate_upload_date(media_item)} + + + #{source.custom_name} + #{media_item.title} + + false + + """ + end + + defp generate_self_link(source) do + "#{url_base()}/sources/#{source.uuid}/feed.xml" + end + defp generate_media_stream_path(media_item) do extension = Path.extname(media_item.media_filepath) diff --git a/lib/pinchflat_web/controllers/sources/source_controller.ex b/lib/pinchflat_web/controllers/sources/source_controller.ex index 3211ccc..ae5b3f1 100644 --- a/lib/pinchflat_web/controllers/sources/source_controller.ex +++ b/lib/pinchflat_web/controllers/sources/source_controller.ex @@ -7,6 +7,7 @@ defmodule PinchflatWeb.Sources.SourceController do alias Pinchflat.Sources alias Pinchflat.Profiles alias Pinchflat.Sources.Source + alias Pinchflat.Podcasts.RssFeedBuilder def index(conn, _params) do sources = Repo.preload(Sources.list_sources(), :media_profile) @@ -58,6 +59,19 @@ defmodule PinchflatWeb.Sources.SourceController do ) end + def feed(conn, %{"id" => uuid}) do + # TODO: change this to UUID + source = Repo.get_by!(Source, id: uuid) + media_items = Media.list_downloaded_media_items_for(source, limit: 100) + + xml = RssFeedBuilder.build(source, media_items) + + conn + |> put_resp_content_type("application/rss+xml") + |> put_resp_header("content-disposition", "inline") + |> send_resp(200, xml) + end + def edit(conn, %{"id" => id}) do source = Sources.get_source!(id) changeset = Sources.change_source(source) diff --git a/lib/pinchflat_web/router.ex b/lib/pinchflat_web/router.ex index 0aff573..c452b58 100644 --- a/lib/pinchflat_web/router.ex +++ b/lib/pinchflat_web/router.ex @@ -36,6 +36,7 @@ defmodule PinchflatWeb.Router do # 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 "/sources/:id/feed", Sources.SourceController, :feed get "/media/:id/stream", MediaItems.MediaItemController, :stream end From e365f264ef32a31ab684d69b91e606ab2ce35295 Mon Sep 17 00:00:00 2001 From: Kieran Eglin Date: Sun, 24 Mar 2024 08:59:43 -0700 Subject: [PATCH 04/18] [WIP] more expanding on RSS --- lib/pinchflat/podcasts/rss_feed_builder.ex | 11 ++++++-- .../controllers/sources/source_controller.ex | 28 +++++++++++++++++++ lib/pinchflat_web/router.ex | 2 ++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/lib/pinchflat/podcasts/rss_feed_builder.ex b/lib/pinchflat/podcasts/rss_feed_builder.ex index d7b041b..1c921ed 100644 --- a/lib/pinchflat/podcasts/rss_feed_builder.ex +++ b/lib/pinchflat/podcasts/rss_feed_builder.ex @@ -33,17 +33,17 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do #{Calendar.strftime(DateTime.utc_now(), @datetime_format)} #{Calendar.strftime(source.inserted_at, @datetime_format)} - false + yes #{source.uuid} - https://raw.githubusercontent.com/kieraneglin/pinchflat/master/priv/static/images/originals/logo.png + #{generate_source_image_path(source)} #{source.custom_name} #{source.original_url} #{source.custom_name} #{source.custom_name} yes - + false @@ -85,6 +85,11 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do "#{url_base()}/media/#{media_item.uuid}/stream#{extension}" end + # TODO: add extension maybe. maybe refactor controller to handle this + defp generate_source_image_path(source) do + "#{url_base()}/sources/#{source.uuid}/feed_image" + end + defp generate_upload_date(media_item) do media_item.upload_date |> Date.to_gregorian_days() diff --git a/lib/pinchflat_web/controllers/sources/source_controller.ex b/lib/pinchflat_web/controllers/sources/source_controller.ex index ae5b3f1..b67846f 100644 --- a/lib/pinchflat_web/controllers/sources/source_controller.ex +++ b/lib/pinchflat_web/controllers/sources/source_controller.ex @@ -59,6 +59,8 @@ defmodule PinchflatWeb.Sources.SourceController do ) end + # TODO: test + # TODO: maybe move both of these to a separate controller def feed(conn, %{"id" => uuid}) do # TODO: change this to UUID source = Repo.get_by!(Source, id: uuid) @@ -72,6 +74,32 @@ defmodule PinchflatWeb.Sources.SourceController do |> send_resp(200, xml) end + # TODO: test + # TODO: look into media_items having a thumbnail path when the image is embedded but not on disk + # TODO: pull these images from the internal metadata instead. this implies I'll have to hook up + # metadata images for sources + def feed_image(conn, %{"id" => uuid}) do + source = Repo.get_by!(Source, uuid: uuid) + media_item = Media.list_downloaded_media_items_for(source, limit: 1) + + filepath = + case {source.poster_filepath, media_item} do + {poster, _} when poster != nil -> poster + {nil, [media_item]} -> media_item.thumbnail_filepath + _ -> nil + end + + IO.inspect(filepath) + + if filepath && File.exists?(filepath) do + conn + |> put_resp_content_type(MIME.from_path(filepath)) + |> send_file(200, filepath) + else + send_resp(conn, 404, "File not found") + end + end + def edit(conn, %{"id" => id}) do source = Sources.get_source!(id) changeset = Sources.change_source(source) diff --git a/lib/pinchflat_web/router.ex b/lib/pinchflat_web/router.ex index c452b58..0a1071c 100644 --- a/lib/pinchflat_web/router.ex +++ b/lib/pinchflat_web/router.ex @@ -37,6 +37,8 @@ defmodule PinchflatWeb.Router do # so people that want RSS feeds to work can enable it. scope "/", PinchflatWeb do get "/sources/:id/feed", Sources.SourceController, :feed + get "/sources/:id/feed_image", Sources.SourceController, :feed_image + get "/media/:id/stream", MediaItems.MediaItemController, :stream end From ab47bdbdf518a25b65fdfaa1fbde9dbf2ebec047 Mon Sep 17 00:00:00 2001 From: Kieran Eglin Date: Mon, 25 Mar 2024 15:22:20 -0700 Subject: [PATCH 05/18] [WIP] Working on refactoring feed --- lib/pinchflat/podcasts/podcast_helpers.ex | 44 +++++++++++++++++++ lib/pinchflat/podcasts/rss_feed_builder.ex | 37 ++++++++++++---- lib/pinchflat_web.ex | 2 +- .../media_items/media_item_controller.ex | 2 +- .../podcasts/podcast_controller.ex | 34 ++++++++++++++ .../controllers/sources/source_controller.ex | 42 ------------------ lib/pinchflat_web/router.ex | 6 +-- 7 files changed, 111 insertions(+), 56 deletions(-) create mode 100644 lib/pinchflat/podcasts/podcast_helpers.ex create mode 100644 lib/pinchflat_web/controllers/podcasts/podcast_controller.ex diff --git a/lib/pinchflat/podcasts/podcast_helpers.ex b/lib/pinchflat/podcasts/podcast_helpers.ex new file mode 100644 index 0000000..d67482a --- /dev/null +++ b/lib/pinchflat/podcasts/podcast_helpers.ex @@ -0,0 +1,44 @@ +defmodule Pinchflat.Podcasts.PostcastHelpers do + alias Pinchflat.Repo + alias Pinchflat.Media + + # TODO: test + 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 + + # TODO: test + # Returns string or nil + def select_cover_image(source) do + source_with_preloads = Repo.preload(source, :metadata) + # Since we're looking for the metadata image, _any_ downloaded media + # items should be fine + media_items = Media.list_downloaded_media_items_for(source, limit: 1) + + source_with_preloads + |> get_images_by_preference(media_items) + |> Enum.reject(&is_nil(&1)) + |> Enum.find(&File.exists?/1) + end + + def get_images_by_preference(source_with_preloads, []) do + source_metadata = source_with_preloads.metadata + + [ + source_metadata.poster_filepath, + source_metadata.banner_filepath + ] + end + + def 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 + 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 index 1c921ed..0e6b260 100644 --- a/lib/pinchflat/podcasts/rss_feed_builder.ex +++ b/lib/pinchflat/podcasts/rss_feed_builder.ex @@ -1,6 +1,9 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do @datetime_format "%a, %d %b %Y %H:%M:%S %z" + alias Pinchflat.Podcasts.PostcastHelpers + alias PinchflatWeb.Router.Helpers, as: Routes + # TODO: test # TODO: only MIs that are confirmed to exist on-disk should be provided def build(source, media_items) do @@ -36,14 +39,14 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do yes #{source.uuid} - #{generate_source_image_path(source)} + #{feed_image_path(source)} #{source.custom_name} #{source.original_url} #{source.custom_name} #{source.custom_name} yes - + false @@ -63,7 +66,7 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do #{media_item.description} #{generate_upload_date(media_item)} @@ -76,18 +79,26 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do end defp generate_self_link(source) do - "#{url_base()}/sources/#{source.uuid}/feed.xml" + Path.join(url_base(), "#{podcast_route(:rss_feed, source.uuid)}.xml") end - defp generate_media_stream_path(media_item) do + defp media_stream_path(media_item) do extension = Path.extname(media_item.media_filepath) - "#{url_base()}/media/#{media_item.uuid}/stream#{extension}" + Path.join(url_base(), "#{media_route(:stream, media_item.uuid)}#{extension}") end - # TODO: add extension maybe. maybe refactor controller to handle this - defp generate_source_image_path(source) do - "#{url_base()}/sources/#{source.uuid}/feed_image" + defp feed_image_path(source) do + image_path_on_disk = PostcastHelpers.select_cover_image(source) + + case image_path_on_disk do + nil -> + "" + + _ -> + extension = Path.extname(image_path_on_disk) + Path.join(url_base(), "#{podcast_route(:feed_image, source.uuid)}#{extension}") + end end defp generate_upload_date(media_item) do @@ -102,6 +113,14 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do MIME.from_path(media_item.media_filepath) 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 + defp url_base do Application.get_env(:pinchflat, :url_base) 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/controllers/media_items/media_item_controller.ex b/lib/pinchflat_web/controllers/media_items/media_item_controller.ex index bf44c77..5efc308 100644 --- a/lib/pinchflat_web/controllers/media_items/media_item_controller.ex +++ b/lib/pinchflat_web/controllers/media_items/media_item_controller.ex @@ -36,7 +36,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 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..b5e2c36 --- /dev/null +++ b/lib/pinchflat_web/controllers/podcasts/podcast_controller.ex @@ -0,0 +1,34 @@ +defmodule PinchflatWeb.Podcasts.PodcastController do + use PinchflatWeb, :controller + + alias Pinchflat.Repo + alias Pinchflat.Podcasts.RssFeedBuilder + alias Pinchflat.Podcasts.PostcastHelpers + + # TODO: test + def rss_feed(conn, %{"uuid" => uuid}) do + # TODO: change this to UUID + source = Repo.get_by!(Source, id: uuid) + media_items = PostcastHelpers.persisted_media_items_for(source) + xml = RssFeedBuilder.build(source, media_items) + + conn + |> put_resp_content_type("application/rss+xml") + |> put_resp_header("content-disposition", "inline") + |> send_resp(200, xml) + end + + # TODO: test + def feed_image(conn, %{"uuid" => uuid}) do + source = Repo.get_by!(Source, uuid: uuid) + filepath = PostcastHelpers.select_cover_image(source) + + if filepath && File.exists?(filepath) do + conn + |> put_resp_content_type(MIME.from_path(filepath)) + |> send_file(200, filepath) + else + send_resp(conn, 404, "File not found") + end + end +end diff --git a/lib/pinchflat_web/controllers/sources/source_controller.ex b/lib/pinchflat_web/controllers/sources/source_controller.ex index b67846f..3211ccc 100644 --- a/lib/pinchflat_web/controllers/sources/source_controller.ex +++ b/lib/pinchflat_web/controllers/sources/source_controller.ex @@ -7,7 +7,6 @@ defmodule PinchflatWeb.Sources.SourceController do alias Pinchflat.Sources alias Pinchflat.Profiles alias Pinchflat.Sources.Source - alias Pinchflat.Podcasts.RssFeedBuilder def index(conn, _params) do sources = Repo.preload(Sources.list_sources(), :media_profile) @@ -59,47 +58,6 @@ defmodule PinchflatWeb.Sources.SourceController do ) end - # TODO: test - # TODO: maybe move both of these to a separate controller - def feed(conn, %{"id" => uuid}) do - # TODO: change this to UUID - source = Repo.get_by!(Source, id: uuid) - media_items = Media.list_downloaded_media_items_for(source, limit: 100) - - xml = RssFeedBuilder.build(source, media_items) - - conn - |> put_resp_content_type("application/rss+xml") - |> put_resp_header("content-disposition", "inline") - |> send_resp(200, xml) - end - - # TODO: test - # TODO: look into media_items having a thumbnail path when the image is embedded but not on disk - # TODO: pull these images from the internal metadata instead. this implies I'll have to hook up - # metadata images for sources - def feed_image(conn, %{"id" => uuid}) do - source = Repo.get_by!(Source, uuid: uuid) - media_item = Media.list_downloaded_media_items_for(source, limit: 1) - - filepath = - case {source.poster_filepath, media_item} do - {poster, _} when poster != nil -> poster - {nil, [media_item]} -> media_item.thumbnail_filepath - _ -> nil - end - - IO.inspect(filepath) - - if filepath && File.exists?(filepath) do - conn - |> put_resp_content_type(MIME.from_path(filepath)) - |> send_file(200, filepath) - else - send_resp(conn, 404, "File not found") - end - end - def edit(conn, %{"id" => id}) do source = Sources.get_source!(id) changeset = Sources.change_source(source) diff --git a/lib/pinchflat_web/router.ex b/lib/pinchflat_web/router.ex index 0a1071c..3c8cc67 100644 --- a/lib/pinchflat_web/router.ex +++ b/lib/pinchflat_web/router.ex @@ -36,10 +36,10 @@ defmodule PinchflatWeb.Router do # 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 "/sources/:id/feed", Sources.SourceController, :feed - get "/sources/:id/feed_image", Sources.SourceController, :feed_image + get "/sources/:uuid/rss_feed", Podcasts.PodcastController, :rss_feed + get "/sources/:uuid/feed_image", Podcasts.PodcastController, :feed_image - get "/media/:id/stream", MediaItems.MediaItemController, :stream + get "/media/:uuid/stream", MediaItems.MediaItemController, :stream end # Enable LiveDashboard and Swoosh mailbox preview in development From 05e7fa3d2ee819cb0bde3c912c0182ff238c6adc Mon Sep 17 00:00:00 2001 From: Kieran Eglin Date: Mon, 25 Mar 2024 15:25:44 -0700 Subject: [PATCH 06/18] Added UUID backfill to a migration --- lib/pinchflat/boot/pre_job_startup_tasks.ex | 15 --------------- ...240325222242_add_uuid_to_source_and_media.exs} | 0 .../20240325222243_backfill_content_uuids.exs | 13 +++++++++++++ 3 files changed, 13 insertions(+), 15 deletions(-) rename priv/repo/migrations/{20240323165649_add_uuid_to_source_and_media.exs => 20240325222242_add_uuid_to_source_and_media.exs} (100%) create mode 100644 priv/repo/migrations/20240325222243_backfill_content_uuids.exs diff --git a/lib/pinchflat/boot/pre_job_startup_tasks.ex b/lib/pinchflat/boot/pre_job_startup_tasks.ex index e1bb4c0..83573c5 100644 --- a/lib/pinchflat/boot/pre_job_startup_tasks.ex +++ b/lib/pinchflat/boot/pre_job_startup_tasks.ex @@ -36,7 +36,6 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do reset_executing_jobs() create_blank_cookie_file() apply_default_settings() - backfill_uuids() {:ok, state} end @@ -68,18 +67,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/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 From 24e6b2e868a2f6686ab4cd51d04a5d70e97afb3d Mon Sep 17 00:00:00 2001 From: Kieran Eglin Date: Tue, 26 Mar 2024 08:24:30 -0700 Subject: [PATCH 07/18] [WIP] Moar refactoring --- lib/pinchflat/podcasts/podcast_helpers.ex | 7 ++---- lib/pinchflat/podcasts/rss_feed_builder.ex | 23 ++++++++----------- .../podcasts/podcast_controller.ex | 11 ++++++--- 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/lib/pinchflat/podcasts/podcast_helpers.ex b/lib/pinchflat/podcasts/podcast_helpers.ex index d67482a..a999dd1 100644 --- a/lib/pinchflat/podcasts/podcast_helpers.ex +++ b/lib/pinchflat/podcasts/podcast_helpers.ex @@ -1,4 +1,4 @@ -defmodule Pinchflat.Podcasts.PostcastHelpers do +defmodule Pinchflat.Podcasts.PodcastHelpers do alias Pinchflat.Repo alias Pinchflat.Media @@ -13,11 +13,8 @@ defmodule Pinchflat.Podcasts.PostcastHelpers do # TODO: test # Returns string or nil - def select_cover_image(source) do + def select_cover_image(source, media_items) do source_with_preloads = Repo.preload(source, :metadata) - # Since we're looking for the metadata image, _any_ downloaded media - # items should be fine - media_items = Media.list_downloaded_media_items_for(source, limit: 1) source_with_preloads |> get_images_by_preference(media_items) diff --git a/lib/pinchflat/podcasts/rss_feed_builder.ex b/lib/pinchflat/podcasts/rss_feed_builder.ex index 0e6b260..0733ca1 100644 --- a/lib/pinchflat/podcasts/rss_feed_builder.ex +++ b/lib/pinchflat/podcasts/rss_feed_builder.ex @@ -1,25 +1,22 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do @datetime_format "%a, %d %b %Y %H:%M:%S %z" - alias Pinchflat.Podcasts.PostcastHelpers + alias Pinchflat.Podcasts.PodcastHelpers alias PinchflatWeb.Router.Helpers, as: Routes # TODO: test # TODO: only MIs that are confirmed to exist on-disk should be provided def build(source, media_items) do - media_item_xml = Enum.map(media_items, &build_media_item_xml(source, &1)) - - build_source_xml(source, media_item_xml) + build_source_xml(source, media_items) end - defp build_source_xml(source, media_item_xml) do + defp build_source_xml(source, media_items) do + media_item_xml = Enum.map(media_items, &build_media_item_xml(source, &1)) + # 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 - # - # - Add real - # - Serve proper images instead of the placeholders """ #{source.custom_name} #{source.original_url} - #{source.custom_name} + #{source.description} TV & Film Generated by Pinchflat en-us @@ -39,14 +36,14 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do yes #{source.uuid} - #{feed_image_path(source)} + #{feed_image_path(source, media_items)} #{source.custom_name} #{source.original_url} #{source.custom_name} #{source.custom_name} yes - + false @@ -88,8 +85,8 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do Path.join(url_base(), "#{media_route(:stream, media_item.uuid)}#{extension}") end - defp feed_image_path(source) do - image_path_on_disk = PostcastHelpers.select_cover_image(source) + defp feed_image_path(source, media_items) do + image_path_on_disk = PodcastHelpers.select_cover_image(source, media_items) case image_path_on_disk do nil -> diff --git a/lib/pinchflat_web/controllers/podcasts/podcast_controller.ex b/lib/pinchflat_web/controllers/podcasts/podcast_controller.ex index b5e2c36..ac4760b 100644 --- a/lib/pinchflat_web/controllers/podcasts/podcast_controller.ex +++ b/lib/pinchflat_web/controllers/podcasts/podcast_controller.ex @@ -2,14 +2,15 @@ defmodule PinchflatWeb.Podcasts.PodcastController do use PinchflatWeb, :controller alias Pinchflat.Repo + alias Pinchflat.Media alias Pinchflat.Podcasts.RssFeedBuilder - alias Pinchflat.Podcasts.PostcastHelpers + alias Pinchflat.Podcasts.PodcastHelpers # TODO: test def rss_feed(conn, %{"uuid" => uuid}) do # TODO: change this to UUID source = Repo.get_by!(Source, id: uuid) - media_items = PostcastHelpers.persisted_media_items_for(source) + media_items = PodcastHelpers.persisted_media_items_for(source) xml = RssFeedBuilder.build(source, media_items) conn @@ -21,7 +22,11 @@ defmodule PinchflatWeb.Podcasts.PodcastController do # TODO: test def feed_image(conn, %{"uuid" => uuid}) do source = Repo.get_by!(Source, uuid: uuid) - filepath = PostcastHelpers.select_cover_image(source) + # 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) + filepath = PodcastHelpers.select_cover_image(source, media_items) if filepath && File.exists?(filepath) do conn From 7879e7202f9fbf761a8bf3cde57f6e2fce2a7129 Mon Sep 17 00:00:00 2001 From: Kieran Eglin Date: Tue, 26 Mar 2024 10:23:15 -0700 Subject: [PATCH 08/18] [WIP] Adding UI for getting RSS feed --- .../components/core_components.ex | 5 +- .../custom_components/button_components.ex | 52 +++++++++++++++- .../custom_components/tab_components.ex | 31 ++++++---- .../controllers/sources/source_html.ex | 7 +++ .../sources/source_html/show.html.heex | 61 ++++++++++++------- lib/pinchflat_web/router.ex | 2 +- 6 files changed, 120 insertions(+), 38 deletions(-) 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..5e9ebe7 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/sources/source_html.ex b/lib/pinchflat_web/controllers/sources/source_html.ex index dcb6b05..b7afe45 100644 --- a/lib/pinchflat_web/controllers/sources/source_html.ex +++ b/lib/pinchflat_web/controllers/sources/source_html.ex @@ -24,4 +24,11 @@ defmodule PinchflatWeb.Sources.SourceHTML do {"Monthly", 30 * 24 * 60} ] end + + def rss_feed_url(source) do + url_base = Application.get_env(:pinchflat, :url_base) + path = ~p"/sources/#{source.uuid}/feed" + + Path.join(url_base, "#{path}.rss") + 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..1ad1642 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/router.ex b/lib/pinchflat_web/router.ex index 3c8cc67..ac166ff 100644 --- a/lib/pinchflat_web/router.ex +++ b/lib/pinchflat_web/router.ex @@ -36,7 +36,7 @@ defmodule PinchflatWeb.Router do # 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 "/sources/:uuid/rss_feed", Podcasts.PodcastController, :rss_feed + get "/sources/:uuid/feed", Podcasts.PodcastController, :rss_feed get "/sources/:uuid/feed_image", Podcasts.PodcastController, :feed_image get "/media/:uuid/stream", MediaItems.MediaItemController, :stream From b7e7992f4d280529de8cc4b336fdd9bef142c571 Mon Sep 17 00:00:00 2001 From: Kieran Eglin Date: Tue, 26 Mar 2024 12:43:35 -0700 Subject: [PATCH 09/18] Many tests --- lib/pinchflat/boot/pre_job_startup_tasks.ex | 2 - lib/pinchflat/podcasts/podcast_helpers.ex | 50 +++++-- lib/pinchflat/podcasts/rss_feed_builder.ex | 51 ++++--- lib/pinchflat/utils/datetime_utils.ex | 17 +++ .../podcasts/podcast_controller.ex | 24 ++-- .../source_metadata_storage_worker_test.exs | 2 +- .../podcasts/podcast_helpers_test.exs | 64 +++++++++ .../podcasts/rss_feed_builder_test.exs | 132 ++++++++++++++++++ test/pinchflat/utils/datetime_utils_test.exs | 14 ++ .../controllers/podcast_controller_test.exs | 38 +++++ test/support/fixtures/media_fixtures.ex | 37 ++++- test/support/fixtures/sources_fixtures.ex | 29 +++- 12 files changed, 410 insertions(+), 50 deletions(-) create mode 100644 lib/pinchflat/utils/datetime_utils.ex create mode 100644 test/pinchflat/podcasts/podcast_helpers_test.exs create mode 100644 test/pinchflat/podcasts/rss_feed_builder_test.exs create mode 100644 test/pinchflat/utils/datetime_utils_test.exs create mode 100644 test/pinchflat_web/controllers/podcast_controller_test.exs diff --git a/lib/pinchflat/boot/pre_job_startup_tasks.ex b/lib/pinchflat/boot/pre_job_startup_tasks.ex index 83573c5..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 diff --git a/lib/pinchflat/podcasts/podcast_helpers.ex b/lib/pinchflat/podcasts/podcast_helpers.ex index a999dd1..33e328e 100644 --- a/lib/pinchflat/podcasts/podcast_helpers.ex +++ b/lib/pinchflat/podcasts/podcast_helpers.ex @@ -1,8 +1,27 @@ 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 - # TODO: test + @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) @@ -11,8 +30,19 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do |> Enum.filter(fn media_item -> File.exists?(media_item.media_filepath) end) end - # TODO: test - # Returns string or nil + @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) @@ -20,20 +50,24 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do |> 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 - def get_images_by_preference(source_with_preloads, []) do - source_metadata = source_with_preloads.metadata + defp get_images_by_preference(source_with_preloads, []) do + source_metadata = source_with_preloads.metadata || %SourceMetadata{} [ source_metadata.poster_filepath, - source_metadata.banner_filepath + source_metadata.fanart_filepath ] end - def get_images_by_preference(source_with_preloads, [media_item | _]) do + 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 + 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] diff --git a/lib/pinchflat/podcasts/rss_feed_builder.ex b/lib/pinchflat/podcasts/rss_feed_builder.ex index 0733ca1..72973f5 100644 --- a/lib/pinchflat/podcasts/rss_feed_builder.ex +++ b/lib/pinchflat/podcasts/rss_feed_builder.ex @@ -1,17 +1,34 @@ 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 - # TODO: test - # TODO: only MIs that are confirmed to exist on-disk should be provided - def build(source, media_items) do + @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) + + media_items = PodcastHelpers.persisted_media_items_for(source, limit: limit) build_source_xml(source, media_items) end defp build_source_xml(source, media_items) do media_item_xml = Enum.map(media_items, &build_media_item_xml(source, &1)) + # "caching" the image path since it requires some DB calls and is used twice + feed_image_path = feed_image_path(source, media_items) # Useful: resources: # - https://validator.w3.org/feed/#validate_by_input @@ -30,20 +47,20 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do TV & Film Generated by Pinchflat en-us - #{Calendar.strftime(DateTime.utc_now(), @datetime_format)} + #{Calendar.strftime(source.updated_at, @datetime_format)} #{Calendar.strftime(source.inserted_at, @datetime_format)} yes #{source.uuid} - #{feed_image_path(source, media_items)} + #{feed_image_path} #{source.custom_name} #{source.original_url} #{source.custom_name} #{source.custom_name} yes - + false @@ -65,8 +82,8 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do - + type="#{MIME.from_path(media_item.media_filepath)}" + /> #{source.custom_name} #{media_item.title} @@ -86,30 +103,22 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do end defp feed_image_path(source, media_items) do - image_path_on_disk = PodcastHelpers.select_cover_image(source, media_items) - - case image_path_on_disk do - nil -> + case PodcastHelpers.select_cover_image(source, media_items) do + {:error, _} -> "" - _ -> - extension = Path.extname(image_path_on_disk) + {: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 - |> Date.to_gregorian_days() - |> Kernel.*(86400) - |> DateTime.from_gregorian_seconds() + |> DatetimeUtils.date_to_datetime() |> Calendar.strftime(@datetime_format) end - defp determine_content_type(media_item) do - MIME.from_path(media_item.media_filepath) - end - defp podcast_route(action, params) do Routes.podcast_path(PinchflatWeb.Endpoint, action, params) 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/controllers/podcasts/podcast_controller.ex b/lib/pinchflat_web/controllers/podcasts/podcast_controller.ex index ac4760b..284851e 100644 --- a/lib/pinchflat_web/controllers/podcasts/podcast_controller.ex +++ b/lib/pinchflat_web/controllers/podcasts/podcast_controller.ex @@ -3,15 +3,13 @@ defmodule PinchflatWeb.Podcasts.PodcastController do alias Pinchflat.Repo alias Pinchflat.Media + alias Pinchflat.Sources.Source alias Pinchflat.Podcasts.RssFeedBuilder alias Pinchflat.Podcasts.PodcastHelpers - # TODO: test def rss_feed(conn, %{"uuid" => uuid}) do - # TODO: change this to UUID - source = Repo.get_by!(Source, id: uuid) - media_items = PodcastHelpers.persisted_media_items_for(source) - xml = RssFeedBuilder.build(source, media_items) + source = Repo.get_by!(Source, uuid: uuid) + xml = RssFeedBuilder.build(source, limit: 300) conn |> put_resp_content_type("application/rss+xml") @@ -19,21 +17,21 @@ defmodule PinchflatWeb.Podcasts.PodcastController do |> send_resp(200, xml) end - # TODO: test 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) - filepath = PodcastHelpers.select_cover_image(source, media_items) - if filepath && File.exists?(filepath) do - conn - |> put_resp_content_type(MIME.from_path(filepath)) - |> send_file(200, filepath) - else - send_resp(conn, 404, "File not found") + 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/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..6e24c42 --- /dev/null +++ b/test/pinchflat/podcasts/rss_feed_builder_test.exs @@ -0,0 +1,132 @@ +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 "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 + 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:4008/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/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 = [ %{ From 73b1e60e9f42baf69a00b32b9b9feba89ca7d3bd Mon Sep 17 00:00:00 2001 From: Kieran Eglin Date: Tue, 26 Mar 2024 13:11:10 -0700 Subject: [PATCH 10/18] Added conditional routing for feed URLs --- config/config.exs | 5 +-- config/runtime.exs | 7 +++- lib/pinchflat_web/router.ex | 19 ++++++++--- test/pinchflat_web/routing_test.exs | 51 +++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/config/config.exs b/config/config.exs index 5f13716..874c363 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_xml_feed: false, url_base: System.get_env("BASIC_AUTH_PASSWORD") || "http://localhost:4008", file_watcher_poll_interval: 1000 diff --git a/config/runtime.exs b/config/runtime.exs index f84239a..e3d0166 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -47,6 +47,10 @@ 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_xml_feed = String.length(System.get_env("EXPOSE_XML_FEED", "")) > 0 + url_base = System.get_env("URL_BASE", "") + # 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")) @@ -65,7 +69,8 @@ if config_env() == :prod do extras_directory: extras_path, tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]), dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"), - url_base: System.get_env("URL_BASE") || "http://localhost:8945" + url_base: url_base, + expose_xml_feed: expose_xml_feed config :pinchflat, Pinchflat.Repo, database: db_path, diff --git a/lib/pinchflat_web/router.ex b/lib/pinchflat_web/router.ex index ac166ff..68d3091 100644 --- a/lib/pinchflat_web/router.ex +++ b/lib/pinchflat_web/router.ex @@ -17,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 @@ -30,12 +34,11 @@ 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 + pipe_through :feeds + get "/sources/:uuid/feed", Podcasts.PodcastController, :rss_feed get "/sources/:uuid/feed_image", Podcasts.PodcastController, :feed_image @@ -59,6 +62,14 @@ defmodule PinchflatWeb.Router do end end + defp maybe_basic_auth(conn, opts) do + if Application.get_env(:pinchflat, :expose_xml_feed) 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/test/pinchflat_web/routing_test.exs b/test/pinchflat_web/routing_test.exs index 37c047d..42d4e31 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_expore_xml_feed = Application.get_env(:pinchflat, :expose_xml_feed) + + 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_xml_feed, old_expore_xml_feed) + end) + + {:ok, source: source} + end + + test "uses basic auth when expose_xml_feed 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_xml_feed, 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_xml_feed 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_xml_feed, 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_xml_feed, false) + + conn = get(build_conn(), "/sources/#{source.uuid}/feed") + + assert conn.status == 200 + end + end end From c315e91cb5f9f58623476302bdd2ff494d9c3578 Mon Sep 17 00:00:00 2001 From: Kieran Eglin Date: Tue, 26 Mar 2024 15:14:31 -0700 Subject: [PATCH 11/18] Removed the need for url_base to be set --- config/config.exs | 1 - config/runtime.exs | 2 -- lib/pinchflat/podcasts/rss_feed_builder.ex | 33 ++++++++++--------- .../podcasts/podcast_controller.ex | 3 +- .../controllers/sources/source_html.ex | 7 ++-- .../sources/source_html/show.html.heex | 2 +- lib/pinchflat_web/endpoint.ex | 15 +++++++++ .../podcasts/rss_feed_builder_test.exs | 14 +++++--- 8 files changed, 47 insertions(+), 30 deletions(-) diff --git a/config/config.exs b/config/config.exs index 874c363..5038e77 100644 --- a/config/config.exs +++ b/config/config.exs @@ -23,7 +23,6 @@ config :pinchflat, basic_auth_username: "", basic_auth_password: "", expose_xml_feed: false, - url_base: System.get_env("BASIC_AUTH_PASSWORD") || "http://localhost:4008", file_watcher_poll_interval: 1000 # Configures the endpoint diff --git a/config/runtime.exs b/config/runtime.exs index e3d0166..f494884 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -49,7 +49,6 @@ if config_env() == :prod do # For running PF as a podcast host on self-hosted environments expose_xml_feed = String.length(System.get_env("EXPOSE_XML_FEED", "")) > 0 - url_base = System.get_env("URL_BASE", "") # We want to force _some_ level of useful logging in production acceptable_log_levels = ~w(debug info)a @@ -69,7 +68,6 @@ if config_env() == :prod do extras_directory: extras_path, tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]), dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"), - url_base: url_base, expose_xml_feed: expose_xml_feed config :pinchflat, Pinchflat.Repo, diff --git a/lib/pinchflat/podcasts/rss_feed_builder.ex b/lib/pinchflat/podcasts/rss_feed_builder.ex index 72973f5..7e74010 100644 --- a/lib/pinchflat/podcasts/rss_feed_builder.ex +++ b/lib/pinchflat/podcasts/rss_feed_builder.ex @@ -20,15 +20,16 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do """ 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) + build_source_xml(source, media_items, url_base) end - defp build_source_xml(source, media_items) do - media_item_xml = Enum.map(media_items, &build_media_item_xml(source, &1)) + 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(source, media_items) + feed_image_path = feed_image_path(url_base, source, media_items) # Useful: resources: # - https://validator.w3.org/feed/#validate_by_input @@ -49,7 +50,7 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do en-us #{Calendar.strftime(source.updated_at, @datetime_format)} #{Calendar.strftime(source.inserted_at, @datetime_format)} - + yes #{source.uuid} @@ -71,7 +72,7 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do """ end - defp build_media_item_xml(source, media_item) do + defp build_media_item_xml(source, media_item, url_base) do """ #{media_item.uuid} @@ -80,7 +81,7 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do #{media_item.description} #{generate_upload_date(media_item)} @@ -92,24 +93,24 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do """ end - defp generate_self_link(source) do - Path.join(url_base(), "#{podcast_route(:rss_feed, source.uuid)}.xml") + defp generate_self_link(url_base, source) do + Path.join(url_base, "#{podcast_route(:rss_feed, source.uuid)}.xml") end - defp media_stream_path(media_item) do + 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}") + Path.join(url_base, "#{media_route(:stream, media_item.uuid)}#{extension}") end - defp feed_image_path(source, media_items) do + 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}") + Path.join(url_base, "#{podcast_route(:feed_image, source.uuid)}#{extension}") end end @@ -127,7 +128,7 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do Routes.media_item_path(PinchflatWeb.Endpoint, action, params) end - defp url_base do - Application.get_env(:pinchflat, :url_base) - end + # defp url_base do + # Application.get_env(:pinchflat, :url_base) + # end end diff --git a/lib/pinchflat_web/controllers/podcasts/podcast_controller.ex b/lib/pinchflat_web/controllers/podcasts/podcast_controller.ex index 284851e..aa65c57 100644 --- a/lib/pinchflat_web/controllers/podcasts/podcast_controller.ex +++ b/lib/pinchflat_web/controllers/podcasts/podcast_controller.ex @@ -9,7 +9,8 @@ defmodule PinchflatWeb.Podcasts.PodcastController do def rss_feed(conn, %{"uuid" => uuid}) do source = Repo.get_by!(Source, uuid: uuid) - xml = RssFeedBuilder.build(source, limit: 300) + url_base = url(conn, ~p"/") + xml = RssFeedBuilder.build(source, limit: 300, url_base: url_base) conn |> put_resp_content_type("application/rss+xml") diff --git a/lib/pinchflat_web/controllers/sources/source_html.ex b/lib/pinchflat_web/controllers/sources/source_html.ex index b7afe45..b93b754 100644 --- a/lib/pinchflat_web/controllers/sources/source_html.ex +++ b/lib/pinchflat_web/controllers/sources/source_html.ex @@ -25,10 +25,7 @@ defmodule PinchflatWeb.Sources.SourceHTML do ] end - def rss_feed_url(source) do - url_base = Application.get_env(:pinchflat, :url_base) - path = ~p"/sources/#{source.uuid}/feed" - - Path.join(url_base, "#{path}.rss") + 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 1ad1642..3c2934b 100644 --- a/lib/pinchflat_web/controllers/sources/source_html/show.html.heex +++ b/lib/pinchflat_web/controllers/sources/source_html/show.html.heex @@ -25,7 +25,7 @@ copied = false, 4000); "} diff --git a/lib/pinchflat_web/endpoint.ex b/lib/pinchflat_web/endpoint.ex index 0bc99a6..3fdca80 100644 --- a/lib/pinchflat_web/endpoint.ex +++ b/lib/pinchflat_web/endpoint.ex @@ -48,10 +48,25 @@ defmodule PinchflatWeb.Endpoint do 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_port = if conn.port in [80, 443], do: "", else: ":#{conn.port}" + new_base_url = "#{conn.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 diff --git a/test/pinchflat/podcasts/rss_feed_builder_test.exs b/test/pinchflat/podcasts/rss_feed_builder_test.exs index 6e24c42..8146b55 100644 --- a/test/pinchflat/podcasts/rss_feed_builder_test.exs +++ b/test/pinchflat/podcasts/rss_feed_builder_test.exs @@ -28,6 +28,12 @@ defmodule Pinchflat.Podcasts.RssFeedBuilderTest do 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 @@ -55,7 +61,7 @@ defmodule Pinchflat.Podcasts.RssFeedBuilderTest do assert String.contains?( res, - ~s() + ~s() ) end @@ -65,13 +71,13 @@ defmodule Pinchflat.Podcasts.RssFeedBuilderTest do res = RssFeedBuilder.build(source) [_before, image_block, _after] = String.split(res, ~r()) - assert String.contains?(image_block, ~s(http://localhost:4008/sources/#{source.uuid}/feed_image.jpg)) + 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() + ~s() ) end end @@ -120,7 +126,7 @@ defmodule Pinchflat.Podcasts.RssFeedBuilderTest do [_before, item_xml, _after] = String.split(res, ~r()) assert String.contains?(item_xml, ~s( Date: Tue, 26 Mar 2024 15:35:30 -0700 Subject: [PATCH 12/18] Updated preset name --- .../media_profile_html/media_profile_form.html.heex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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' } }"> From 3205597c27554f04a0f84e53dad6838e47c315c8 Mon Sep 17 00:00:00 2001 From: Kieran Eglin Date: Tue, 26 Mar 2024 15:56:42 -0700 Subject: [PATCH 13/18] Rendered certain fields HTML-safe; Added logging to confirm range request support --- lib/pinchflat/podcasts/rss_feed_builder.ex | 26 ++++++++++++------- .../media_items/media_item_controller.ex | 6 +++++ .../podcasts/rss_feed_builder_test.exs | 7 +++++ 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/lib/pinchflat/podcasts/rss_feed_builder.ex b/lib/pinchflat/podcasts/rss_feed_builder.ex index 7e74010..6acef60 100644 --- a/lib/pinchflat/podcasts/rss_feed_builder.ex +++ b/lib/pinchflat/podcasts/rss_feed_builder.ex @@ -42,9 +42,9 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:atom="http://www.w3.org/2005/Atom"> - #{source.custom_name} + #{safe(source.custom_name)} #{source.original_url} - #{source.description} + #{safe(source.description)} TV & Film Generated by Pinchflat en-us @@ -55,11 +55,11 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do #{source.uuid} #{feed_image_path} - #{source.custom_name} + #{safe(source.custom_name)} #{source.original_url} - #{source.custom_name} - #{source.custom_name} + #{safe(source.custom_name)} + #{safe(source.custom_name)} yes false @@ -76,23 +76,31 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do """ #{media_item.uuid} - #{media_item.title} + #{safe(media_item.title)} #{media_item.original_url} - #{media_item.description} + #{safe(media_item.description)} #{generate_upload_date(media_item)} - #{source.custom_name} - #{media_item.title} + #{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 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 5efc308..a741e26 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 @@ -47,6 +49,8 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do {:ok, {start_pos, end_pos}} -> length = end_pos - start_pos + 1 + Logger.debug("Streaming media item: #{media_item.uuid} from #{start_pos} to #{end_pos} (#{length} bytes)") + conn |> put_resp_content_type(mime_type) |> put_resp_header("accept-ranges", "bytes") @@ -55,6 +59,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/test/pinchflat/podcasts/rss_feed_builder_test.exs b/test/pinchflat/podcasts/rss_feed_builder_test.exs index 8146b55..99bf32c 100644 --- a/test/pinchflat/podcasts/rss_feed_builder_test.exs +++ b/test/pinchflat/podcasts/rss_feed_builder_test.exs @@ -21,6 +21,13 @@ defmodule Pinchflat.Podcasts.RssFeedBuilderTest do 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}) From 167dcb26945ff948189bfd1348f39b436fbd8e7f Mon Sep 17 00:00:00 2001 From: Kieran Eglin Date: Tue, 26 Mar 2024 16:02:45 -0700 Subject: [PATCH 14/18] Fixed incorrect scheme issue --- .../controllers/media_items/media_item_controller.ex | 3 +-- lib/pinchflat_web/endpoint.ex | 8 +++++++- 2 files changed, 8 insertions(+), 3 deletions(-) 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 a741e26..31a9ef4 100644 --- a/lib/pinchflat_web/controllers/media_items/media_item_controller.ex +++ b/lib/pinchflat_web/controllers/media_items/media_item_controller.ex @@ -47,10 +47,9 @@ 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 - Logger.debug("Streaming media item: #{media_item.uuid} from #{start_pos} to #{end_pos} (#{length} bytes)") - conn |> put_resp_content_type(mime_type) |> put_resp_header("accept-ranges", "bytes") diff --git a/lib/pinchflat_web/endpoint.ex b/lib/pinchflat_web/endpoint.ex index 3fdca80..b6ab016 100644 --- a/lib/pinchflat_web/endpoint.ex +++ b/lib/pinchflat_web/endpoint.ex @@ -61,8 +61,14 @@ defmodule PinchflatWeb.Endpoint do # 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 = "#{conn.scheme}://#{conn.host}#{new_port}" + new_base_url = "#{new_scheme}://#{conn.host}#{new_port}" Phoenix.Controller.put_router_url(conn, new_base_url) end From 283a749fbaaab03cf582e2afb6c9ff999c155935 Mon Sep 17 00:00:00 2001 From: Kieran Eglin Date: Wed, 27 Mar 2024 08:31:06 -0700 Subject: [PATCH 15/18] Updated env var --- config/config.exs | 2 +- config/runtime.exs | 4 ++-- lib/pinchflat_web/router.ex | 2 +- test/pinchflat_web/routing_test.exs | 14 +++++++------- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/config/config.exs b/config/config.exs index 5038e77..4c26b3b 100644 --- a/config/config.exs +++ b/config/config.exs @@ -22,7 +22,7 @@ config :pinchflat, # If either is unset, basic auth will not be used. basic_auth_username: "", basic_auth_password: "", - expose_xml_feed: false, + expose_feed_endpoints: false, file_watcher_poll_interval: 1000 # Configures the endpoint diff --git a/config/runtime.exs b/config/runtime.exs index f494884..1f4150e 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -48,7 +48,7 @@ if config_env() == :prod do extras_path = System.get_env("EXTRAS_PATH", Path.join([config_path, "extras"])) # For running PF as a podcast host on self-hosted environments - expose_xml_feed = String.length(System.get_env("EXPOSE_XML_FEED", "")) > 0 + 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 @@ -68,7 +68,7 @@ if config_env() == :prod do extras_directory: extras_path, tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]), dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"), - expose_xml_feed: expose_xml_feed + expose_feed_endpoints: expose_feed_endpoints config :pinchflat, Pinchflat.Repo, database: db_path, diff --git a/lib/pinchflat_web/router.ex b/lib/pinchflat_web/router.ex index 68d3091..6b70d03 100644 --- a/lib/pinchflat_web/router.ex +++ b/lib/pinchflat_web/router.ex @@ -63,7 +63,7 @@ defmodule PinchflatWeb.Router do end defp maybe_basic_auth(conn, opts) do - if Application.get_env(:pinchflat, :expose_xml_feed) do + if Application.get_env(:pinchflat, :expose_feed_endpoints) do conn else basic_auth(conn, opts) diff --git a/test/pinchflat_web/routing_test.exs b/test/pinchflat_web/routing_test.exs index 42d4e31..cbe4dd8 100644 --- a/test/pinchflat_web/routing_test.exs +++ b/test/pinchflat_web/routing_test.exs @@ -61,23 +61,23 @@ defmodule PinchflatWeb.RoutingTest do setup do old_username = Application.get_env(:pinchflat, :basic_auth_username) old_password = Application.get_env(:pinchflat, :basic_auth_password) - old_expore_xml_feed = Application.get_env(:pinchflat, :expose_xml_feed) + 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_xml_feed, old_expore_xml_feed) + Application.put_env(:pinchflat, :expose_feed_endpoints, old_expose_feed_endpoints) end) {:ok, source: source} end - test "uses basic auth when expose_xml_feed is false", %{source: source} do + 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_xml_feed, false) + Application.put_env(:pinchflat, :expose_feed_endpoints, false) conn = get(build_conn(), "/sources/#{source.uuid}/feed") @@ -85,10 +85,10 @@ defmodule PinchflatWeb.RoutingTest do assert {"www-authenticate", "Basic realm=\"Pinchflat\""} in conn.resp_headers end - test "does not use basic auth when expose_xml_feed is true", %{source: source} do + 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_xml_feed, true) + Application.put_env(:pinchflat, :expose_feed_endpoints, true) conn = get(build_conn(), "/sources/#{source.uuid}/feed") @@ -98,7 +98,7 @@ defmodule PinchflatWeb.RoutingTest do 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_xml_feed, false) + Application.put_env(:pinchflat, :expose_feed_endpoints, false) conn = get(build_conn(), "/sources/#{source.uuid}/feed") From c9b2a8503d9f5ce398b0db165868baedc06eb7fa Mon Sep 17 00:00:00 2001 From: Kieran Eglin Date: Wed, 27 Mar 2024 08:36:44 -0700 Subject: [PATCH 16/18] Updated other UI to use dropdown --- .../custom_components/button_components.ex | 2 +- .../media_item_html/show.html.heex | 26 ++++++----- .../media_profile_html/show.html.heex | 46 ++++++++++--------- 3 files changed, 39 insertions(+), 35 deletions(-) diff --git a/lib/pinchflat_web/components/custom_components/button_components.ex b/lib/pinchflat_web/components/custom_components/button_components.ex index 5e9ebe7..a80bb75 100644 --- a/lib/pinchflat_web/components/custom_components/button_components.ex +++ b/lib/pinchflat_web/components/custom_components/button_components.ex @@ -69,7 +69,7 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do <%= @text %> 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/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"> From 2ccd93833317c379c9a93c56b0c25cb14f9d29a6 Mon Sep 17 00:00:00 2001 From: Kieran Eglin Date: Wed, 27 Mar 2024 08:39:03 -0700 Subject: [PATCH 17/18] removed commented code --- lib/pinchflat/podcasts/rss_feed_builder.ex | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/pinchflat/podcasts/rss_feed_builder.ex b/lib/pinchflat/podcasts/rss_feed_builder.ex index 6acef60..54eb104 100644 --- a/lib/pinchflat/podcasts/rss_feed_builder.ex +++ b/lib/pinchflat/podcasts/rss_feed_builder.ex @@ -135,8 +135,4 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do defp media_route(action, params) do Routes.media_item_path(PinchflatWeb.Endpoint, action, params) end - - # defp url_base do - # Application.get_env(:pinchflat, :url_base) - # end end From e11586d13850ed13ae2c3d02bb44cbf127b61d14 Mon Sep 17 00:00:00 2001 From: Kieran Eglin Date: Wed, 27 Mar 2024 09:49:04 -0700 Subject: [PATCH 18/18] docs --- README.md | 37 +++---------------------------------- 1 file changed, 3 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 1e47776..523ed64 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,12 @@ - [Installation](#installation) - [Unraid](#unraid) - [Docker](#docker) -- [Authentication](#authentication) +- [Username and Password (authentication)](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password) - [Frequently asked questions](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions) - [Documentation](https://github.com/kieraneglin/pinchflat/wiki) - [EFF donations](#eff-donations) - [Pre-release disclaimer](#pre-release-disclaimer) -- [Development](#development) +- [Development and Contributing](https://github.com/kieraneglin/pinchflat/wiki/Development-and-Contributing) ## What it does @@ -43,6 +43,7 @@ If it doesn't work for your use case, please make a feature request! You can als - Powerful naming system so content is stored where and how you want it - Easy-to-use web interface with presets to get you started right away - First-class support for media center apps like Plex, Jellyfin, and Kodi +- Supports serving RSS feeds to your favourite podcast app (beta - [docs]()) - Automatically downloads new content from channels and playlists - Uses a novel approach to download new content more quickly than other apps - Supports downloading audio content @@ -103,14 +104,6 @@ You _must_ ensure the host directories you've mounted are writable by the user r It's recommended to not run the container as root. Doing so can create permission issues if other apps need to work with the downloaded media. If you need to run any command as root, you can run `su` from the container's shell as there is no password set for the root user. -## Username and Password - -HTTP basic authentication is optionally supported. To use it, set the `BASIC_AUTH_USERNAME` and `BASIC_AUTH_PASSWORD` environment variables when starting the container. No authentication will be required unless you set _both_ of these. - -### Important note: - -The media streaming endpoint is not protected by basic auth. To help protect your media, these endpoints work with UUIDs instead of sequential IDs but this is still essentially security through obscurity. If you're concerned about the security of your media, consider using a reverse proxy with authentication or a VPN. - ## EFF donations A portion of all donations to Pinchflat will be donated to the [Electronic Frontier Foundation](https://www.eff.org/). The EFF defends your online liberties and [backed](https://github.com/github/dmca/blob/9a85e0f021f7967af80e186b890776a50443f06c/2020/11/2020-11-16-RIAA-reversal-effletter.pdf) `youtube-dl` when Google took them down. [See here](https://github.com/kieraneglin/pinchflat/wiki/EFF-Donation-Receipts) for a list of donation receipts. @@ -119,30 +112,6 @@ A portion of all donations to Pinchflat will be donated to the [Electronic Front This is pre-release software and anything can break at any time. I make not guarantees about the stability of this software, forward-compatibility of updates, or integrity (both related to and independent of Pinchflat). Essentially, use at your own risk and expect there will be rough edges for now. -## Development - -Pinchflat is written in Elixir - a functional programming language that runs on the Erlang VM. It uses the Phoenix web framework and SQLite for the database. The frontend is mostly normal server-rendered HTML with a little Alpine.js as-needed. - -Elixir is a personal favourite of mine and is ideal for building fault-tolerant systems. It's also a joy to work with and has a great community. If you're interested in contributing, I'd be happy to help you get started with Elixir - just open an issue with some questions and we can chat! - -### Local setup - -- `docker compose build --no-cache` -- `docker compose up -d && docker attach pinchflat-phx-1` -- After a few minutes the app should be accessible at `localhost:4008` - - Please let me know if you run into any hiccups here - I haven't had to bootstrap the app from scratch in a long time and I might have forgotten something -- Media downloads and config will be stored in the `tmp` directory. Not the OS's `/tmp` directory, but the one in the root of the project - -### Running tests and linting - -- Open a shell with `docker compose exec phx bash` -- Run `mix test` to run the tests -- Run `mix check` to do a full testing, linting, and static analysis pass - -### Top tips - -- Look for any module that ends in `*_worker.ex` - these are where the interesting stuff happens and you can trace back from there to see how the app works. `lib/pinchflat/slow_indexing/media_collection_indexing_worker.ex` is a good place to start - ## License See `LICENSE` file