RSS feed for sources (#110)

* Add media streaming (#108)

* [WIP] set up streaming route

* Added UUID to sources and media items

* Added media preview to MI show page

* Added plug to strip file extensions

* [VERY WIP] basic podcast RSS setup

* [WIP] got basic podcast RSS working

* [WIP] more expanding on RSS

* Comment

* [WIP] Working on refactoring feed

* Added UUID backfill to a migration

* [WIP] Moar refactoring

* [WIP] Adding UI for getting RSS feed

* Many tests

* Added conditional routing for feed URLs

* Removed the need for url_base to be set

* Updated preset name

* Rendered certain fields HTML-safe; Added logging to confirm range request support

* Fixed incorrect scheme issue

* Updated env var

* Updated other UI to use dropdown

* removed commented code

* Generate rss feeds (#123)

* Added plug to strip file extensions

* [VERY WIP] basic podcast RSS setup

* [WIP] got basic podcast RSS working

* [WIP] more expanding on RSS

* [WIP] Working on refactoring feed

* Added UUID backfill to a migration

* [WIP] Moar refactoring

* [WIP] Adding UI for getting RSS feed

* Many tests

* Added conditional routing for feed URLs

* Removed the need for url_base to be set

* Updated preset name

* Rendered certain fields HTML-safe; Added logging to confirm range request support

* Fixed incorrect scheme issue

* Updated env var

* Updated other UI to use dropdown

* removed commented code

* docs

* Added unique index to UUID fields
This commit is contained in:
Kieran
2024-03-27 18:11:25 -07:00
committed by GitHub
parent d043e5574b
commit 320b25f5b6
38 changed files with 1237 additions and 169 deletions
+8
View File
@@ -5,6 +5,7 @@ defmodule Pinchflat.Media.MediaItem do
use Ecto.Schema
import Ecto.Changeset
import Pinchflat.Utils.ChangesetUtils
alias Pinchflat.Tasks.Task
alias Pinchflat.Sources.Source
@@ -32,6 +33,7 @@ defmodule Pinchflat.Media.MediaItem do
]
# Pretty much all the fields captured at index are required.
@required_fields ~w(
uuid
title
original_url
livestream
@@ -42,6 +44,11 @@ defmodule Pinchflat.Media.MediaItem do
)a
schema "media_items" do
# This is _not_ used as the primary key or internally in the database
# relations. This is only used to prevent an enumeration attack on the streaming
# and RSS feed endpoints since those _must_ be public (ie: no basic auth)
field :uuid, Ecto.UUID
field :title, :string
field :media_id, :string
field :description, :string
@@ -78,6 +85,7 @@ defmodule Pinchflat.Media.MediaItem do
media_item
|> cast(attrs, @allowed_fields)
|> cast_assoc(:metadata, with: &MediaMetadata.changeset/2, required: false)
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|> validate_required(@required_fields)
|> unique_constraint([:media_id, :source_id])
end
+75
View File
@@ -0,0 +1,75 @@
defmodule Pinchflat.Podcasts.PodcastHelpers do
@moduledoc """
Methods for fetching postcast-related data from a source
or its media items
"""
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Metadata.MediaMetadata
alias Pinchflat.Metadata.SourceMetadata
@doc """
Returns a list of media items that have been downloaded to disk
and have been proven to still exist there.
Useful for podcasts since we don't want to serve media that
has been deleted or moved, but it's also fairly generally useful
so I could see this being moved in the future.
Options:
- limit: integer - the maximum number of media items to return
Returns: [%MediaItem{}]
"""
def persisted_media_items_for(source, opts \\ []) do
limit = Keyword.get(opts, :limit, 500)
source
|> Media.list_downloaded_media_items_for(limit: limit)
|> Enum.filter(fn media_item -> File.exists?(media_item.media_filepath) end)
end
@doc """
Selects a cover image for a source based on the source's metadata
and the metadata of the media items associated with the source. Also
ensures images exist on disk.
Only one media item should need to be returned since this is using the
internal metadata which, so long as the media_item was _downloaded_, should
be guaranteed to exist.
Prefers the source's poster, then fanart, then the media item's thumbnail.
Returns: {:ok, filepath} | {:error, :no_suitable_image}
"""
def select_cover_image(source, media_items) do
source_with_preloads = Repo.preload(source, :metadata)
source_with_preloads
|> get_images_by_preference(media_items)
|> Enum.reject(&is_nil(&1))
|> Enum.find(&File.exists?/1)
|> case do
nil -> {:error, :no_suitable_image}
filepath -> {:ok, filepath}
end
end
defp get_images_by_preference(source_with_preloads, []) do
source_metadata = source_with_preloads.metadata || %SourceMetadata{}
[
source_metadata.poster_filepath,
source_metadata.fanart_filepath
]
end
defp get_images_by_preference(source_with_preloads, [media_item | _]) do
media_item_with_preloads = Repo.preload(media_item, :metadata)
media_item_metadata = media_item_with_preloads.metadata || %MediaMetadata{}
source_images = get_images_by_preference(source_with_preloads, [])
source_images ++ [media_item_metadata.thumbnail_filepath]
end
end
+138
View File
@@ -0,0 +1,138 @@
defmodule Pinchflat.Podcasts.RssFeedBuilder do
@moduledoc """
Methods for building an RSS feed for a source and its media items.
"""
@datetime_format "%a, %d %b %Y %H:%M:%S %z"
alias Pinchflat.Utils.DatetimeUtils
alias Pinchflat.Podcasts.PodcastHelpers
alias PinchflatWeb.Router.Helpers, as: Routes
@doc """
Builds an RSS feed for a given source and its media items.
Only MediaItems that have been persisted will be included in the feed.
## Options:
- `:limit` - The maximum number of media items to include in the feed. Defaults to 300.
Returns an XML document as a string.
"""
def build(source, opts \\ []) do
limit = Keyword.get(opts, :limit, 300)
url_base = Keyword.get(opts, :url_base, PinchflatWeb.Endpoint.url())
media_items = PodcastHelpers.persisted_media_items_for(source, limit: limit)
build_source_xml(source, media_items, url_base)
end
defp build_source_xml(source, media_items, url_base) do
media_item_xml = Enum.map(media_items, &build_media_item_xml(source, &1, url_base))
# "caching" the image path since it requires some DB calls and is used twice
feed_image_path = feed_image_path(url_base, source, media_items)
# Useful: resources:
# - https://validator.w3.org/feed/#validate_by_input
# - https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md
# - https://podba.se/validate
"""
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
xmlns:podcast="https://podcastindex.org/namespace/1.0"
xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>#{safe(source.custom_name)}</title>
<link>#{source.original_url}</link>
<description>#{safe(source.description)}</description>
<category>TV &amp; Film</category>
<generator>Generated by Pinchflat</generator>
<language>en-us</language>
<lastBuildDate>#{Calendar.strftime(source.updated_at, @datetime_format)}</lastBuildDate>
<pubDate>#{Calendar.strftime(source.inserted_at, @datetime_format)}</pubDate>
<atom:link href="#{generate_self_link(url_base, source)}" rel="self" type="application/rss+xml" />
<podcast:locked>yes</podcast:locked>
<podcast:guid>#{source.uuid}</podcast:guid>
<image>
<url>#{feed_image_path}</url>
<title>#{safe(source.custom_name)}</title>
<link>#{source.original_url}</link>
</image>
<itunes:author>#{safe(source.custom_name)}</itunes:author>
<itunes:subtitle>#{safe(source.custom_name)}</itunes:subtitle>
<itunes:block>yes</itunes:block>
<itunes:image href="#{feed_image_path}"></itunes:image>
<itunes:explicit>false</itunes:explicit>
<itunes:category text="TV &amp; Film"></itunes:category>
#{Enum.join(media_item_xml, "\n")}
</channel>
</rss>
"""
end
defp build_media_item_xml(source, media_item, url_base) do
"""
<item>
<guid isPermaLink="false">#{media_item.uuid}</guid>
<title>#{safe(media_item.title)}</title>
<link>#{media_item.original_url}</link>
<description>#{safe(media_item.description)}</description>
<pubDate>#{generate_upload_date(media_item)}</pubDate>
<enclosure
url="#{media_stream_path(url_base, media_item)}"
length="#{media_item.media_size_bytes}"
type="#{MIME.from_path(media_item.media_filepath)}"
/>
<itunes:author>#{safe(source.custom_name)}</itunes:author>
<itunes:subtitle>#{safe(media_item.title)}</itunes:subtitle>
<itunes:summary><![CDATA[#{media_item.description}]]></itunes:summary>
<itunes:explicit>false</itunes:explicit>
</item>
"""
end
defp safe(nil), do: ""
defp safe(value) do
value
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
end
defp generate_self_link(url_base, source) do
Path.join(url_base, "#{podcast_route(:rss_feed, source.uuid)}.xml")
end
defp media_stream_path(url_base, media_item) do
extension = Path.extname(media_item.media_filepath)
Path.join(url_base, "#{media_route(:stream, media_item.uuid)}#{extension}")
end
defp feed_image_path(url_base, source, media_items) do
case PodcastHelpers.select_cover_image(source, media_items) do
{:error, _} ->
""
{:ok, filepath} ->
extension = Path.extname(filepath)
Path.join(url_base, "#{podcast_route(:feed_image, source.uuid)}#{extension}")
end
end
defp generate_upload_date(media_item) do
media_item.upload_date
|> DatetimeUtils.date_to_datetime()
|> Calendar.strftime(@datetime_format)
end
defp podcast_route(action, params) do
Routes.podcast_path(PinchflatWeb.Endpoint, action, params)
end
defp media_route(action, params) do
Routes.media_item_path(PinchflatWeb.Endpoint, action, params)
end
end
+7
View File
@@ -48,6 +48,7 @@ defmodule Pinchflat.Sources.Source do
@pre_insert_required_fields @initially_required_fields ++
~w(
uuid
custom_name
collection_name
collection_id
@@ -55,6 +56,11 @@ defmodule Pinchflat.Sources.Source do
)a
schema "sources" do
# This is _not_ used as the primary key or internally in the database
# relations. This is only used to prevent an enumeration attack on the streaming
# and RSS feed endpoints since those _must_ be public (ie: no basic auth)
field :uuid, Ecto.UUID
field :custom_name, :string
field :description, :string
field :collection_name, :string
@@ -98,6 +104,7 @@ defmodule Pinchflat.Sources.Source do
source
|> cast(attrs, @allowed_fields)
|> dynamic_default(:custom_name, fn cs -> get_field(cs, :collection_name) end)
|> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
|> validate_required(required_fields)
|> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false)
end
+17
View File
@@ -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