Added OPML Endpoint for podcast rss feeds (#512)
* Added OPML Endpoint for podcast rss feeds * changed opml route and added controller test for opml endpoint * add copy opml feed button * add copy opml feed button - correct url * fix html indenting * add indentation to opml Co-authored-by: Kieran <kieran.eglin@gmail.com> * use convention for unused controller params Co-authored-by: Kieran <kieran.eglin@gmail.com> * add test for opml_sources helper function * change opml endpoint to be more inline with the other routes --------- Co-authored-by: robs <git@robs.social> Co-authored-by: Kieran <kieran.eglin@gmail.com>
This commit is contained in:
parent
a2a70fcce2
commit
c9bd1ea7bd
9 changed files with 151 additions and 12 deletions
40
lib/pinchflat/podcasts/opml_feed_builder.ex
Normal file
40
lib/pinchflat/podcasts/opml_feed_builder.ex
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
defmodule Pinchflat.Podcasts.OpmlFeedBuilder do
|
||||||
|
@moduledoc """
|
||||||
|
Methods for building an OPML feed for a list of sources.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Pinchflat.Utils.XmlUtils, only: [safe: 1]
|
||||||
|
|
||||||
|
alias PinchflatWeb.Router.Helpers, as: Routes
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Builds an OPML feed for a given list of sources.
|
||||||
|
|
||||||
|
Returns an XML document as a string.
|
||||||
|
"""
|
||||||
|
def build(url_base, sources) do
|
||||||
|
sources_xml =
|
||||||
|
Enum.map(
|
||||||
|
sources,
|
||||||
|
&"""
|
||||||
|
<outline type="rss" text="#{safe(&1.custom_name)}" xmlUrl="#{safe(source_route(url_base, &1))}" />
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
"""
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<opml version="2.0">
|
||||||
|
<head>
|
||||||
|
<title>All Sources</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
#{Enum.join(sources_xml, "\n")}
|
||||||
|
</body>
|
||||||
|
</opml>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp source_route(url_base, source) do
|
||||||
|
Path.join(url_base, "#{Routes.podcast_path(PinchflatWeb.Endpoint, :rss_feed, source.uuid)}.xml")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -5,11 +5,25 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use Pinchflat.Media.MediaQuery
|
use Pinchflat.Media.MediaQuery
|
||||||
|
use Pinchflat.Sources.SourcesQuery
|
||||||
|
|
||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Metadata.MediaMetadata
|
alias Pinchflat.Metadata.MediaMetadata
|
||||||
alias Pinchflat.Metadata.SourceMetadata
|
alias Pinchflat.Metadata.SourceMetadata
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns a list of sources that are not marked for deletion.
|
||||||
|
|
||||||
|
Returns: [%Source{}]
|
||||||
|
"""
|
||||||
|
def opml_sources() do
|
||||||
|
SourcesQuery.new()
|
||||||
|
|> select([s], %{custom_name: s.custom_name, uuid: s.uuid})
|
||||||
|
|> where([s], is_nil(s.marked_for_deletion_at))
|
||||||
|
|> order_by(asc: :custom_name)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns a list of media items that have been downloaded to disk
|
Returns a list of media items that have been downloaded to disk
|
||||||
and have been proven to still exist there.
|
and have been proven to still exist there.
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,19 @@ defmodule PinchflatWeb.Podcasts.PodcastController do
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
alias Pinchflat.Media.MediaItem
|
alias Pinchflat.Media.MediaItem
|
||||||
alias Pinchflat.Podcasts.RssFeedBuilder
|
alias Pinchflat.Podcasts.RssFeedBuilder
|
||||||
|
alias Pinchflat.Podcasts.OpmlFeedBuilder
|
||||||
alias Pinchflat.Podcasts.PodcastHelpers
|
alias Pinchflat.Podcasts.PodcastHelpers
|
||||||
|
|
||||||
|
def opml_feed(conn, _params) do
|
||||||
|
url_base = url(conn, ~p"/")
|
||||||
|
xml = OpmlFeedBuilder.build(url_base, PodcastHelpers.opml_sources())
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_resp_content_type("application/opml+xml")
|
||||||
|
|> put_resp_header("content-disposition", "inline")
|
||||||
|
|> send_resp(200, xml)
|
||||||
|
end
|
||||||
|
|
||||||
def rss_feed(conn, %{"uuid" => uuid}) do
|
def rss_feed(conn, %{"uuid" => uuid}) do
|
||||||
source = Repo.get_by!(Source, uuid: uuid)
|
source = Repo.get_by!(Source, uuid: uuid)
|
||||||
url_base = url(conn, ~p"/")
|
url_base = url(conn, ~p"/")
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,10 @@ defmodule PinchflatWeb.Sources.SourceHTML do
|
||||||
url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml"
|
url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def opml_feed_url(conn) do
|
||||||
|
url(conn, ~p"/sources/opml") <> ".xml"
|
||||||
|
end
|
||||||
|
|
||||||
def output_path_template_override_placeholders(media_profiles) do
|
def output_path_template_override_placeholders(media_profiles) do
|
||||||
media_profiles
|
media_profiles
|
||||||
|> Enum.map(&{&1.id, &1.output_path_template})
|
|> Enum.map(&{&1.id, &1.output_path_template})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
|
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
|
||||||
<h2 class="text-title-md2 font-bold text-black dark:text-white">Sources</h2>
|
<h2 class="text-title-md2 font-bold text-black dark:text-white">Sources</h2>
|
||||||
<nav>
|
<nav>
|
||||||
|
<.button color="bg-transparent" x-data="{ copied: false }" x-on:click={~s"
|
||||||
|
copyWithCallbacks(
|
||||||
|
'#{opml_feed_url(@conn)}',
|
||||||
|
() => copied = true,
|
||||||
|
() => copied = false
|
||||||
|
)
|
||||||
|
"}>
|
||||||
|
Copy OPML Feed
|
||||||
|
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
||||||
|
</.button>
|
||||||
<.link href={~p"/sources/new"}>
|
<.link href={~p"/sources/new"}>
|
||||||
<.button color="bg-primary" rounding="rounded-lg">
|
<.button color="bg-primary" rounding="rounded-lg">
|
||||||
<span class="font-bold mx-2">+</span> New <span class="hidden sm:inline pl-1">Source</span>
|
<span class="font-bold mx-2">+</span> New <span class="hidden sm:inline pl-1">Source</span>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,20 @@ defmodule PinchflatWeb.Router do
|
||||||
plug :maybe_basic_auth
|
plug :maybe_basic_auth
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Routes in here _may not be_ protected by basic auth. This is necessary for
|
||||||
|
# media streaming to work for RSS podcast feeds.
|
||||||
|
scope "/", PinchflatWeb do
|
||||||
|
pipe_through :feeds
|
||||||
|
# has to match before /sources/:id
|
||||||
|
get "/sources/opml", Podcasts.PodcastController, :opml_feed
|
||||||
|
|
||||||
|
get "/sources/:uuid/feed", Podcasts.PodcastController, :rss_feed
|
||||||
|
get "/sources/:uuid/feed_image", Podcasts.PodcastController, :feed_image
|
||||||
|
get "/media/:uuid/episode_image", Podcasts.PodcastController, :episode_image
|
||||||
|
|
||||||
|
get "/media/:uuid/stream", MediaItems.MediaItemController, :stream
|
||||||
|
end
|
||||||
|
|
||||||
scope "/", PinchflatWeb do
|
scope "/", PinchflatWeb do
|
||||||
pipe_through :browser
|
pipe_through :browser
|
||||||
|
|
||||||
|
|
@ -48,18 +62,6 @@ defmodule PinchflatWeb.Router do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Routes in here _may not be_ protected by basic auth. This is necessary for
|
|
||||||
# media streaming to work for RSS podcast feeds.
|
|
||||||
scope "/", PinchflatWeb do
|
|
||||||
pipe_through :feeds
|
|
||||||
|
|
||||||
get "/sources/:uuid/feed", Podcasts.PodcastController, :rss_feed
|
|
||||||
get "/sources/:uuid/feed_image", Podcasts.PodcastController, :feed_image
|
|
||||||
get "/media/:uuid/episode_image", Podcasts.PodcastController, :episode_image
|
|
||||||
|
|
||||||
get "/media/:uuid/stream", MediaItems.MediaItemController, :stream
|
|
||||||
end
|
|
||||||
|
|
||||||
# No auth or CSRF protection for the health check endpoint
|
# No auth or CSRF protection for the health check endpoint
|
||||||
scope "/", PinchflatWeb do
|
scope "/", PinchflatWeb do
|
||||||
pipe_through :api
|
pipe_through :api
|
||||||
|
|
|
||||||
34
test/pinchflat/podcasts/opml_feed_builder_test.exs
Normal file
34
test/pinchflat/podcasts/opml_feed_builder_test.exs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
defmodule Pinchflat.Podcasts.OpmlFeedBuilderTest do
|
||||||
|
use Pinchflat.DataCase
|
||||||
|
|
||||||
|
import Pinchflat.SourcesFixtures
|
||||||
|
|
||||||
|
alias Pinchflat.Podcasts.OpmlFeedBuilder
|
||||||
|
|
||||||
|
setup do
|
||||||
|
source = source_fixture()
|
||||||
|
|
||||||
|
{:ok, source: source}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "build/2" do
|
||||||
|
test "returns an XML document", %{source: source} do
|
||||||
|
res = OpmlFeedBuilder.build("http://example.com", [source])
|
||||||
|
|
||||||
|
assert String.contains?(res, ~s(<?xml version="1.0" encoding="UTF-8"?>))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "escapes illegal characters" do
|
||||||
|
source = source_fixture(%{custom_name: "A & B"})
|
||||||
|
res = OpmlFeedBuilder.build("http://example.com", [source])
|
||||||
|
|
||||||
|
assert String.contains?(res, ~s(A & B))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "build podcast link with URL base", %{source: source} do
|
||||||
|
res = OpmlFeedBuilder.build("http://example.com", [source])
|
||||||
|
|
||||||
|
assert String.contains?(res, ~s(http://example.com/sources/#{source.uuid}/feed.xml))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -6,6 +6,16 @@ defmodule Pinchflat.Podcasts.PodcastHelpersTest do
|
||||||
|
|
||||||
alias Pinchflat.Podcasts.PodcastHelpers
|
alias Pinchflat.Podcasts.PodcastHelpers
|
||||||
|
|
||||||
|
describe "opml_sources" do
|
||||||
|
test "returns sources not marked for deletion" do
|
||||||
|
source = source_fixture()
|
||||||
|
source_fixture(%{marked_for_deletion_at: DateTime.utc_now()})
|
||||||
|
assert [found_source] = PodcastHelpers.opml_sources()
|
||||||
|
assert found_source.custom_name == source.custom_name
|
||||||
|
assert found_source.uuid == source.uuid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "persisted_media_items_for/2" do
|
describe "persisted_media_items_for/2" do
|
||||||
test "returns media items with files that exist on-disk" do
|
test "returns media items with files that exist on-disk" do
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,20 @@ defmodule PinchflatWeb.PodcastControllerTest do
|
||||||
import Pinchflat.MediaFixtures
|
import Pinchflat.MediaFixtures
|
||||||
import Pinchflat.SourcesFixtures
|
import Pinchflat.SourcesFixtures
|
||||||
|
|
||||||
|
describe "opml_feed" do
|
||||||
|
test "renders the XML document", %{conn: conn} do
|
||||||
|
source = source_fixture()
|
||||||
|
|
||||||
|
conn = get(conn, ~p"/sources/opml" <> ".xml")
|
||||||
|
|
||||||
|
assert conn.status == 200
|
||||||
|
assert {"content-type", "application/opml+xml; charset=utf-8"} in conn.resp_headers
|
||||||
|
assert {"content-disposition", "inline"} in conn.resp_headers
|
||||||
|
assert conn.resp_body =~ ~s"http://www.example.com/sources/#{source.uuid}/feed.xml"
|
||||||
|
assert conn.resp_body =~ "text=\"Cool and good internal name!\""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "rss_feed" do
|
describe "rss_feed" do
|
||||||
test "renders the XML document", %{conn: conn} do
|
test "renders the XML document", %{conn: conn} do
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue