diff --git a/lib/pinchflat/podcasts/opml_feed_builder.ex b/lib/pinchflat/podcasts/opml_feed_builder.ex
new file mode 100644
index 0000000..de5c700
--- /dev/null
+++ b/lib/pinchflat/podcasts/opml_feed_builder.ex
@@ -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,
+ &"""
+
+ """
+ )
+
+ """
+
+
+
+ All Sources
+
+
+ #{Enum.join(sources_xml, "\n")}
+
+
+ """
+ end
+
+ defp source_route(url_base, source) do
+ Path.join(url_base, "#{Routes.podcast_path(PinchflatWeb.Endpoint, :rss_feed, source.uuid)}.xml")
+ end
+end
diff --git a/lib/pinchflat/podcasts/podcast_helpers.ex b/lib/pinchflat/podcasts/podcast_helpers.ex
index 041107c..30ba17a 100644
--- a/lib/pinchflat/podcasts/podcast_helpers.ex
+++ b/lib/pinchflat/podcasts/podcast_helpers.ex
@@ -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
+
@doc """
Returns a list of media items that have been downloaded to disk
and have been proven to still exist there.
diff --git a/lib/pinchflat_web/controllers/podcasts/podcast_controller.ex b/lib/pinchflat_web/controllers/podcasts/podcast_controller.ex
index d69e4f6..e667503 100644
--- a/lib/pinchflat_web/controllers/podcasts/podcast_controller.ex
+++ b/lib/pinchflat_web/controllers/podcasts/podcast_controller.ex
@@ -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, %{}) 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"/")
diff --git a/lib/pinchflat_web/router.ex b/lib/pinchflat_web/router.ex
index 9ba7068..e9fb6f3 100644
--- a/lib/pinchflat_web/router.ex
+++ b/lib/pinchflat_web/router.ex
@@ -53,6 +53,8 @@ defmodule PinchflatWeb.Router do
scope "/", PinchflatWeb do
pipe_through :feeds
+ get "/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
diff --git a/test/pinchflat/podcasts/opml_feed_builder_test.exs b/test/pinchflat/podcasts/opml_feed_builder_test.exs
new file mode 100644
index 0000000..460de76
--- /dev/null
+++ b/test/pinchflat/podcasts/opml_feed_builder_test.exs
@@ -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())
+ 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