diff --git a/docs/supportedsites.md b/docs/supportedsites.md
index 0f7d40b4..dc7552b7 100644
--- a/docs/supportedsites.md
+++ b/docs/supportedsites.md
@@ -700,7 +700,7 @@ Consider all listed sites to potentially be NSFW.
| Patreon |
https://www.patreon.com/ |
- Creators, Posts, User Profiles |
+ Collections, Creators, Posts, User Profiles |
Cookies |
diff --git a/gallery_dl/extractor/patreon.py b/gallery_dl/extractor/patreon.py
index fb2f32c1..cf1a6d61 100644
--- a/gallery_dl/extractor/patreon.py
+++ b/gallery_dl/extractor/patreon.py
@@ -230,6 +230,16 @@ class PatreonExtractor(Extractor):
attr["created"], "%Y-%m-%dT%H:%M:%S.%f%z")
return attr
+ def _collection(self, collection_id):
+ url = f"{self.root}/api/collection/{collection_id}"
+ data = self.request_json(url)
+ coll = data["data"]
+ attr = coll["attributes"]
+ attr["id"] = coll["id"]
+ attr["date"] = text.parse_datetime(
+ attr["created_at"], "%Y-%m-%dT%H:%M:%S.%f%z")
+ return attr
+
def _filename(self, url):
"""Fetch filename from an URL's Content-Disposition header"""
response = self.request(url, method="HEAD", fatal=False)
@@ -333,6 +343,33 @@ class PatreonExtractor(Extractor):
raise exception.AbortExtraction("Unable to extract bootstrap data")
+class PatreonCollectionExtractor(PatreonExtractor):
+ """Extractor for a patreon collection"""
+ subcategory = "collection"
+ directory_fmt = ("{category}", "{creator[full_name]}",
+ "Collections", "{collection[title]} ({collection[id]})")
+ pattern = r"(?:https?://)?(?:www\.)?patreon\.com/collection/(\d+)"
+ example = "https://www.patreon.com/collection/12345"
+
+ def posts(self):
+ collection_id = self.groups[0]
+ self.kwdict["collection"] = collection = \
+ self._collection(collection_id)
+ campaign_id = text.extr(
+ collection["thumbnail"]["url"], "/campaign/", "/")
+
+ url = self._build_url("posts", (
+ # patreon returns '400 Bad Request' without campaign_id filter
+ f"&filter[campaign_id]={campaign_id}"
+ "&filter[contains_exclusive_posts]=true"
+ "&filter[is_draft]=false"
+ f"&filter[collection_id]={collection_id}"
+ "&filter[include_drops]=true"
+ "&sort=collection_order"
+ ))
+ return self._pagination(url)
+
+
class PatreonCreatorExtractor(PatreonExtractor):
"""Extractor for a creator's works"""
subcategory = "creator"
diff --git a/test/results/patreon.py b/test/results/patreon.py
index 654109fb..f2aeee8b 100644
--- a/test/results/patreon.py
+++ b/test/results/patreon.py
@@ -191,4 +191,45 @@ __tests__ = (
"#exception": exception.NotFoundError,
},
+{
+ "#url" : "https://www.patreon.com/collection/15764",
+ "#class" : patreon.PatreonCollectionExtractor,
+ "#range" : "1-3",
+ "#pattern" : (
+ r"https://c10.patreonusercontent.com/4/patreon-media/p/post/32798362/957d49296e4f48ef80718d0de98c15a4/eyJhIjoxLCJwIjoxfQ%3D%3D/2.jpg\?token-hash=.+",
+ r"ytdl:https://stream.mux.com/h4DYqFU901qkkAwYmRWZPraVk5DvTJTlcSdhGV00006KBE.m3u8\?token=ey.+",
+ r"https://c10.patreonusercontent.com/4/patreon-media/p/post/32798374/357b0133a476427a99169b4400ee03d4/eyJhIjoxLCJwIjoxfQ%3D%3D/2.jpg\?token-hash=.+",
+ ),
+
+ "campaign" : {
+ "currency" : "USD",
+ "is_monthly" : True,
+ "is_nsfw" : False,
+ "name" : "YaBoyRoshi",
+ },
+ "collection" : {
+ "created_at" : "2023-08-31T14:10:41.000+00:00",
+ "date" : "dt:2023-08-31 14:10:41",
+ "default_layout" : "grid",
+ "description" : "",
+ "edited_at" : "2025-07-16T22:58:10.834+00:00",
+ "id" : "15764",
+ "num_draft_posts": 0,
+ "num_posts" : 207,
+ "num_posts_visible_for_creation": 207,
+ "num_scheduled_posts": 8,
+ "post_sort_type" : "custom",
+ "title" : "JoJo's Bizarre Adventure",
+ "post_ids" : list,
+ "thumbnail" : dict,
+ },
+ "creator" : {
+ "date" : "dt:2018-10-17 05:45:19",
+ "first_name": "YaBoyRoshi",
+ "full_name" : "YaBoyRoshi",
+ "id" : "14264111",
+ "vanity" : "yaboyroshi",
+ },
+},
+
)