Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Enhancement] Added OPML Endpoint for podcast rss feeds #512

Merged
merged 9 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions lib/pinchflat/podcasts/opml_feed_builder.ex
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions lib/pinchflat/podcasts/podcast_helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,25 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
"""

use Pinchflat.Media.MediaQuery
use Pinchflat.Sources.SourcesQuery

alias Pinchflat.Repo
alias Pinchflat.Metadata.MediaMetadata
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
robertkleinschuster marked this conversation as resolved.
Show resolved Hide resolved

@doc """
Returns a list of media items that have been downloaded to disk
and have been proven to still exist there.
Expand Down
11 changes: 11 additions & 0 deletions lib/pinchflat_web/controllers/podcasts/podcast_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,19 @@ defmodule PinchflatWeb.Podcasts.PodcastController do
alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem
alias Pinchflat.Podcasts.RssFeedBuilder
alias Pinchflat.Podcasts.OpmlFeedBuilder
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
source = Repo.get_by!(Source, uuid: uuid)
url_base = url(conn, ~p"/")
Expand Down
4 changes: 4 additions & 0 deletions lib/pinchflat_web/controllers/sources/source_html.ex
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ defmodule PinchflatWeb.Sources.SourceHTML do
url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml"
end

def opml_feed_url(conn) do
url(conn, ~p"/sources/opml") <> ".xml"
end

def output_path_template_override_placeholders(media_profiles) do
media_profiles
|> Enum.map(&{&1.id, &1.output_path_template})
Expand Down
10 changes: 10 additions & 0 deletions lib/pinchflat_web/controllers/sources/source_html/index.html.heex
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
<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>
<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>
robertkleinschuster marked this conversation as resolved.
Show resolved Hide resolved
<.link href={~p"/sources/new"}>
<.button color="bg-primary" rounding="rounded-lg">
<span class="font-bold mx-2">+</span> New <span class="hidden sm:inline pl-1">Source</span>
Expand Down
26 changes: 14 additions & 12 deletions lib/pinchflat_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ defmodule PinchflatWeb.Router do
plug :maybe_basic_auth
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
pipe_through :browser

Expand All @@ -48,18 +62,6 @@ defmodule PinchflatWeb.Router do
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
scope "/", PinchflatWeb do
pipe_through :api
Expand Down
34 changes: 34 additions & 0 deletions test/pinchflat/podcasts/opml_feed_builder_test.exs
Original file line number Diff line number Diff line change
@@ -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 &amp; 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
10 changes: 10 additions & 0 deletions test/pinchflat/podcasts/podcast_helpers_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ defmodule Pinchflat.Podcasts.PodcastHelpersTest do

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
test "returns media items with files that exist on-disk" do
source = source_fixture()
Expand Down
14 changes: 14 additions & 0 deletions test/pinchflat_web/controllers/podcast_controller_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ defmodule PinchflatWeb.PodcastControllerTest do
import Pinchflat.MediaFixtures
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
test "renders the XML document", %{conn: conn} do
source = source_fixture()
Expand Down
Loading