diff --git a/bookwyrm/templates/rss/edition.html b/bookwyrm/templates/rss/edition.html
new file mode 100644
index 0000000000..db65b13336
--- /dev/null
+++ b/bookwyrm/templates/rss/edition.html
@@ -0,0 +1,10 @@
+{% load i18n %}
+{% load shelf_tags %}
+‘{{ obj.title }}’ {% if obj.author_text %} by {{obj.author_text}} {% endif %}
+
{{ obj.description|default_if_none:obj.parent_work.description}}
+{% if obj.isbn_13 %}{% trans "ISBN 13:" %} {{ obj.isbn_13 }}
{% endif %}
+{% if obj.oclc_number %}{% trans "OCLC Number:" %} {{ obj.oclc_number }}
{% endif %}
+{% if obj.asin %}{% trans "ASIN:" %} {{ obj.asin }}
{% endif %}
+{% if obj.aasin %}{% trans "Audible ASIN:" %} {{ obj.aasin }}
{% endif %}
+{% if obj.isfdb %}{% trans "ISFDB ID:" %} {{ obj.isfdb }}
{% endif %}
+{% if obj.goodreads_key %}{% trans "Goodreads:" %} {{ obj.goodreads_key }}{% endif %}
diff --git a/bookwyrm/templates/shelf/shelf.html b/bookwyrm/templates/shelf/shelf.html
index 71f4bc088e..f80009e662 100644
--- a/bookwyrm/templates/shelf/shelf.html
+++ b/bookwyrm/templates/shelf/shelf.html
@@ -12,6 +12,11 @@
{% include 'snippets/opengraph.html' with image=user.preview_image %}
{% endblock %}
+
+{% block head_links %}
+
+{% endblock %}
+
{% block content %}
diff --git a/bookwyrm/tests/views/test_rss_feed.py b/bookwyrm/tests/views/test_rss_feed.py
index 790efe51b6..2952315a98 100644
--- a/bookwyrm/tests/views/test_rss_feed.py
+++ b/bookwyrm/tests/views/test_rss_feed.py
@@ -132,3 +132,27 @@ def test_rss_quotation_only(self, *_):
self.assertEqual(result.status_code, 200)
self.assertIn(b"a sickening sense", result.content)
+
+ def test_rss_shelf(self, *_):
+ """load the rss feed of a shelf"""
+ with patch(
+ "bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"
+ ), patch("bookwyrm.activitystreams.add_book_statuses_task.delay"):
+ # make the shelf
+ shelf = models.Shelf.objects.create(
+ name="Test Shelf", identifier="test-shelf", user=self.local_user
+ )
+ # put the shelf on the book
+ models.ShelfBook.objects.create(
+ book=self.book,
+ shelf=shelf,
+ user=self.local_user,
+ )
+ view = rss_feed.RssShelfFeed()
+ request = self.factory.get("/user/books/test-shelf/rss")
+ request.user = self.local_user
+ result = view(
+ request, username=self.local_user.username, shelf_identifier="test-shelf"
+ )
+ self.assertEqual(result.status_code, 200)
+ self.assertIn(b"Example Edition", result.content)
diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py
index cd75eb0c02..9b29e1b2dc 100644
--- a/bookwyrm/urls.py
+++ b/bookwyrm/urls.py
@@ -577,11 +577,21 @@
views.Shelf.as_view(),
name="shelf",
),
+ re_path(
+ rf"^{USER_PATH}/(shelf|books)/(?P[\w-]+)/rss/?$",
+ views.rss_feed.RssShelfFeed(),
+ name="shelf-rss",
+ ),
re_path(
rf"^{LOCAL_USER_PATH}/(books|shelf)/(?P[\w-]+)(.json)?/?$",
views.Shelf.as_view(),
name="shelf",
),
+ re_path(
+ rf"^{LOCAL_USER_PATH}/(books|shelf)/(?P[\w-]+)/rss/?$",
+ views.rss_feed.RssShelfFeed(),
+ name="shelf-rss",
+ ),
re_path(r"^create-shelf/?$", views.create_shelf, name="shelf-create"),
re_path(r"^delete-shelf/(?P\d+)/?$", views.delete_shelf),
re_path(r"^shelve/?$", views.shelve),
diff --git a/bookwyrm/views/rss_feed.py b/bookwyrm/views/rss_feed.py
index 9f5e97d597..acf831708a 100644
--- a/bookwyrm/views/rss_feed.py
+++ b/bookwyrm/views/rss_feed.py
@@ -3,6 +3,7 @@
from django.contrib.syndication.views import Feed
from django.template.loader import get_template
from django.utils.translation import gettext_lazy as _
+from django.shortcuts import get_object_or_404
from ..models import Review, Quotation, Comment
from .helpers import get_user_from_username
@@ -177,3 +178,61 @@ def item_link(self, item):
def item_pubdate(self, item):
"""publication date of the item"""
return item.published_date
+
+
+class RssShelfFeed(Feed):
+ """serialize a shelf activity in rss"""
+
+ description_template = "rss/edition.html"
+
+ def item_title(self, item):
+ """render the item title"""
+ authors = item.authors
+ if item.author_text:
+ authors.display_name = f"{item.author_text}:"
+ else:
+ authors.description = ""
+ template = get_template("rss/title.html")
+ return template.render({"user": authors, "item_title": item.title}).strip()
+
+ def get_object(
+ self, request, shelf_identifier, username
+ ): # pylint: disable=arguments-differ
+ """the shelf that gets serialized"""
+ user = get_user_from_username(request.user, username)
+ # always get privacy, don't support rss over anything private
+ # get the SHELF of the object
+ shelf = get_object_or_404(
+ user.shelf_set,
+ identifier=shelf_identifier,
+ privacy__in=["public", "unlisted"],
+ )
+ shelf.raise_visible_to_user(request.user)
+ return shelf
+
+ def link(self, obj):
+ """link to the shelf"""
+ return obj.local_path
+
+ def title(self, obj):
+ """title of the rss feed entry"""
+ return _(f"{obj.user.display_name}’s {obj.name} shelf")
+
+ def items(self, obj):
+ """the user's activity feed"""
+ return obj.books.order_by("-shelfbook__shelved_date")[:10]
+
+ def item_link(self, item):
+ """link to the status"""
+ return item.local_path
+
+ def item_pubdate(self, item):
+ """publication date of the item"""
+ return item.published_date
+
+ def description(self, obj):
+ """description of the shelf including the shelf name and user."""
+ # if there's a description, lets add it. Not everyone puts a description in.
+ if desc := obj.description:
+ return _(f"{obj.user.display_name}’s {obj.name} shelf: {desc}")
+ return _(f"Books added to {obj.user.name}’s {obj.name} shelf")