From 83dc488457510398d1f4812f2197c2f425342663 Mon Sep 17 00:00:00 2001 From: Phil Skentelbery Date: Tue, 16 Dec 2025 07:47:56 -0700 Subject: [PATCH] feat: v1.4.0 Phase 4 - Enhanced Feed Media MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement Phase 4 of v1.4.0 Media release - Enhanced Feed Media support. RSS Feed Enhancements (starpunk/feeds/rss.py): - Wrap size variants in elements - Add for large/medium/small variants with attributes: url, type, medium, isDefault, width, height, fileSize - Add for thumb variant with dimensions - Add for image captions - Implement isDefault logic: largest available variant (large→medium→small fallback) - Maintain backwards compatibility for media without variants (legacy fallback) JSON Feed Enhancements (starpunk/feeds/json_feed.py): - Add _starpunk.about URL (configurable via STARPUNK_ABOUT_URL config) - Add _starpunk.media_variants array with variant data when variants exist - Each variant entry includes: url, width, height, size_in_bytes, mime_type ATOM Feed Enhancements (starpunk/feeds/atom.py): - Add title attribute to enclosure links for captions - Keep simple (no variants in ATOM per design decision) Test Updates (tests/test_feeds_rss.py): - Update streaming media test to search descendants for media:content - Now inside media:group for images with variants (v1.4.0 behavior) Per design document: /docs/design/v1.4.0/media-implementation-design.md Following ADR-059: Full Feed Media Standardization Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- starpunk/feeds/atom.py | 8 ++++- starpunk/feeds/json_feed.py | 40 ++++++++++++++++++++++-- starpunk/feeds/rss.py | 62 +++++++++++++++++++++++++++++++------ tests/test_feeds_rss.py | 8 +++-- 4 files changed, 102 insertions(+), 16 deletions(-) diff --git a/starpunk/feeds/atom.py b/starpunk/feeds/atom.py index 8df3fc7..3935ef3 100644 --- a/starpunk/feeds/atom.py +++ b/starpunk/feeds/atom.py @@ -184,12 +184,18 @@ def generate_atom_streaming( yield f' \n' # Media enclosures (v1.2.0 Phase 3, per Q24 and ADR-057) + # Enhanced with title attribute for captions (v1.4.0 Phase 4) if hasattr(note, 'media') and note.media: for item in note.media: media_url = f"{site_url}/media/{item['path']}" mime_type = item.get('mime_type', 'image/jpeg') size = item.get('size', 0) - yield f' \n' + caption = item.get('caption', '') + + # Include title attribute for caption + title_attr = f' title="{_escape_xml(caption)}"' if caption else '' + + yield f' \n' # Content - include media as HTML (per Q24) if note.html: diff --git a/starpunk/feeds/json_feed.py b/starpunk/feeds/json_feed.py index 16b1783..a58348d 100644 --- a/starpunk/feeds/json_feed.py +++ b/starpunk/feeds/json_feed.py @@ -313,12 +313,46 @@ def _build_item_object(site_url: str, note: Note) -> Dict[str, Any]: if hasattr(note, 'tags') and note.tags: item["tags"] = [tag['display_name'] for tag in note.tags] - # Add custom StarPunk extensions - item["_starpunk"] = { + # Add custom StarPunk extensions (v1.4.0 Phase 4) + from flask import current_app + about_url = current_app.config.get( + "STARPUNK_ABOUT_URL", + "https://github.com/yourusername/starpunk" + ) + starpunk_ext = { "permalink_path": note.permalink, - "word_count": len(note.content.split()) + "word_count": len(note.content.split()), + "about": about_url } + # Add media variants if present + if hasattr(note, 'media') and note.media: + media_variants = [] + + for media_item in note.media: + variants = media_item.get('variants', {}) + + if variants: + media_info = { + "caption": media_item.get('caption', ''), + "variants": {} + } + + for variant_type, variant_data in variants.items(): + media_info["variants"][variant_type] = { + "url": f"{site_url}/media/{variant_data['path']}", + "width": variant_data['width'], + "height": variant_data['height'], + "size_in_bytes": variant_data['size_bytes'] + } + + media_variants.append(media_info) + + if media_variants: + starpunk_ext["media_variants"] = media_variants + + item["_starpunk"] = starpunk_ext + return item diff --git a/starpunk/feeds/rss.py b/starpunk/feeds/rss.py index 35153e7..80d72e9 100644 --- a/starpunk/feeds/rss.py +++ b/starpunk/feeds/rss.py @@ -304,18 +304,62 @@ def generate_rss_streaming( item_xml += f""" {_escape_xml(tag['display_name'])}""" - # Add media:content elements (all images) + # Enhanced media handling with variants (v1.4.0 Phase 4) if hasattr(note, 'media') and note.media: for media_item in note.media: - media_url = f"{site_url}/media/{media_item['path']}" - item_xml += f""" - """ + variants = media_item.get('variants', {}) - # Add media:thumbnail (first image only) - first_media = note.media[0] - media_url = f"{site_url}/media/{first_media['path']}" - item_xml += f""" - """ + # Use media:group for multiple sizes of same image + if variants: + item_xml += '\n ' + + # Determine which variant is the default (largest available) + # Fallback order: large -> medium -> small + default_variant = None + for fallback in ['large', 'medium', 'small']: + if fallback in variants: + default_variant = fallback + break + + # Add each variant as media:content + for variant_type in ['large', 'medium', 'small']: + if variant_type in variants: + v = variants[variant_type] + media_url = f"{site_url}/media/{v['path']}" + is_default = 'true' if variant_type == default_variant else 'false' + item_xml += f''' + ''' + + item_xml += '\n ' + + # Add media:thumbnail + if 'thumb' in variants: + thumb = variants['thumb'] + thumb_url = f"{site_url}/media/{thumb['path']}" + item_xml += f''' + ''' + + # Add media:title for caption + if media_item.get('caption'): + item_xml += f''' + {_escape_xml(media_item['caption'])}''' + + else: + # Fallback for media without variants (legacy) + media_url = f"{site_url}/media/{media_item['path']}" + item_xml += f''' + ''' # Close item item_xml += """ diff --git a/tests/test_feeds_rss.py b/tests/test_feeds_rss.py index 22b7f35..80691ae 100644 --- a/tests/test_feeds_rss.py +++ b/tests/test_feeds_rss.py @@ -402,7 +402,7 @@ class TestRSSStreamingMedia: assert enclosure is not None def test_rss_streaming_includes_media_elements(self, app, note_with_single_media): - """Streaming RSS should include media:content and media:thumbnail""" + """Streaming RSS should include media:content and media:thumbnail (v1.4.0 with variants)""" with app.app_context(): generator = generate_rss_streaming( site_url="https://example.com", @@ -419,8 +419,10 @@ class TestRSSStreamingMedia: channel = root.find("channel") item = channel.find("item") - media_content = item.find("media:content", namespaces) - media_thumbnail = item.find("media:thumbnail", namespaces) + # v1.4.0: media:content is now inside media:group for images with variants + # Use // to search descendants, not direct children + media_content = item.find(".//media:content", namespaces) + media_thumbnail = item.find(".//media:thumbnail", namespaces) assert media_content is not None assert media_thumbnail is not None