diff --git a/backend/app/services/subscribestar_client.py b/backend/app/services/subscribestar_client.py
index abf93c5..43f07f5 100644
--- a/backend/app/services/subscribestar_client.py
+++ b/backend/app/services/subscribestar_client.py
@@ -97,6 +97,12 @@ _POST_DATE_RE = re.compile(r'
str:
return content.strip()
+def _attachment_item(
+ frag: str, base: str, post_id: str, *, kind: str, title_marker: str, url_attr: str
+) -> "MediaItem | None":
+ """One doc/audio attachment from its preview block — gallery-dl's
+ `_media_from_post` attachment/audio fields. `url_attr` is the URL-bearing
+ attribute (`href="` for docs, `src="` for audio); `title_marker` precedes the
+ display name. Returns None when the block carries no URL."""
+ rel = unescape(_extr(frag, url_attr, '"'))
+ if not rel:
+ return None
+ url = urljoin(base + "/", rel)
+ upload_id = _extr(frag, 'data-upload-id="', '"')
+ name = unescape(_extr(frag, title_marker, "<")).strip()
+ return MediaItem(
+ url=url,
+ filename=name or basename_from_url(url),
+ kind=kind,
+ filehash=filehash_from_url(url),
+ post_id=post_id,
+ media_id=str(upload_id or ""),
+ )
+
+
def _split_creator_url(campaign_id: str) -> tuple[str, str]:
"""`campaign_id` is the creator URL → (base, slug).
@@ -372,10 +401,11 @@ class SubscribeStarClient:
return unescape(m.group(1)) if m else None
def extract_media(self, post: dict, included_index: dict) -> list[MediaItem]:
- """Resolve downloadable media from the post's `data-gallery` JSON
- manifest. Each item carries a stable `id`, `original_filename`, `type`,
- and a full-res `url` (`/post_uploads?payload=...`, relative — joined to the
- post's base). `included_index` is unused (HTML carries media inline)."""
+ """Resolve downloadable media (gallery-dl's `_media_from_post`): the
+ per-post `data-gallery` JSON manifest (images/videos), PLUS document
+ attachments (`uploads-docs`) and audio (`uploads-audios`) — some posts
+ deliver content only through the latter two. `/previews` (locked teaser)
+ gallery items are skipped. `included_index` is unused (media is inline)."""
chunk = post.get("_html") or ""
base = post.get("_base") or "https://www.subscribestar.com"
post_id = str(post.get("id") or "")
@@ -413,6 +443,24 @@ class SubscribeStarClient:
media_id=media_id,
)
)
+ # Document attachments (uploads-docs → doc_preview blocks): href URL.
+ docs = _extr(chunk, 'class="uploads-docs"', 'class="post-edit_form"')
+ for frag in _DOC_SPLIT_RE.split(docs)[1:]:
+ item = _attachment_item(
+ frag, base, post_id, kind="attachment",
+ title_marker='doc_preview-title">', url_attr='href="',
+ )
+ if item is not None:
+ items.append(item)
+ # Audio attachments (uploads-audios → audio_preview-data blocks): src URL.
+ audios = _extr(chunk, 'class="uploads-audios"', 'class="post-edit_form"')
+ for frag in _AUDIO_SPLIT_RE.split(audios)[1:]:
+ item = _attachment_item(
+ frag, base, post_id, kind="audio",
+ title_marker='audio_preview-title">', url_attr='src="',
+ )
+ if item is not None:
+ items.append(item)
return items
@staticmethod
diff --git a/tests/test_subscribestar_native.py b/tests/test_subscribestar_native.py
index 7e21f87..4b17020 100644
--- a/tests/test_subscribestar_native.py
+++ b/tests/test_subscribestar_native.py
@@ -227,6 +227,33 @@ def test_extract_media_skips_locked_previews():
assert [m.media_id for m in items] == ["1"]
+def test_extract_media_includes_doc_and_audio_attachments():
+ # gallery-dl _media_from_post: document (uploads-docs/doc_preview, href) and
+ # audio (uploads-audios/audio_preview-data, src) attachments are separate from
+ # data-gallery — some posts deliver content ONLY through these.
+ client = SubscribeStarClient(None)
+ chunk = (
+ '
'
+ )
+ post = {"id": "888", "_html": chunk, "_base": "https://subscribestar.adult"}
+ by_kind = {m.kind: m for m in client.extract_media(post, {})}
+ assert by_kind["attachment"].url == "https://subscribestar.adult/post_uploads/d/chapter.pdf"
+ assert by_kind["attachment"].filename == "chapter.pdf"
+ assert by_kind["attachment"].media_id == "501"
+ assert by_kind["audio"].url == "https://subscribestar.adult/post_uploads/a/track.mp3"
+ assert by_kind["audio"].filename == "track.mp3"
+ assert by_kind["audio"].media_id == "502"
+
+
def test_text_post_has_no_media_but_keeps_body():
client = SubscribeStarClient(None)
[post] = client._parse_posts(_feed_page(_post_html("111", body="
text only
")))