feat: v1.4.0 Phase 4 - Enhanced Feed Media
Implement Phase 4 of v1.4.0 Media release - Enhanced Feed Media support. RSS Feed Enhancements (starpunk/feeds/rss.py): - Wrap size variants in <media:group> elements - Add <media:content> for large/medium/small variants with attributes: url, type, medium, isDefault, width, height, fileSize - Add <media:thumbnail> for thumb variant with dimensions - Add <media:title type="plain"> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -184,12 +184,18 @@ def generate_atom_streaming(
|
|||||||
yield f' <category term="{_escape_xml(tag["name"])}" label="{_escape_xml(tag["display_name"])}"/>\n'
|
yield f' <category term="{_escape_xml(tag["name"])}" label="{_escape_xml(tag["display_name"])}"/>\n'
|
||||||
|
|
||||||
# Media enclosures (v1.2.0 Phase 3, per Q24 and ADR-057)
|
# 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:
|
if hasattr(note, 'media') and note.media:
|
||||||
for item in note.media:
|
for item in note.media:
|
||||||
media_url = f"{site_url}/media/{item['path']}"
|
media_url = f"{site_url}/media/{item['path']}"
|
||||||
mime_type = item.get('mime_type', 'image/jpeg')
|
mime_type = item.get('mime_type', 'image/jpeg')
|
||||||
size = item.get('size', 0)
|
size = item.get('size', 0)
|
||||||
yield f' <link rel="enclosure" type="{_escape_xml(mime_type)}" href="{_escape_xml(media_url)}" length="{size}"/>\n'
|
caption = item.get('caption', '')
|
||||||
|
|
||||||
|
# Include title attribute for caption
|
||||||
|
title_attr = f' title="{_escape_xml(caption)}"' if caption else ''
|
||||||
|
|
||||||
|
yield f' <link rel="enclosure" type="{_escape_xml(mime_type)}" href="{_escape_xml(media_url)}" length="{size}"{title_attr}/>\n'
|
||||||
|
|
||||||
# Content - include media as HTML (per Q24)
|
# Content - include media as HTML (per Q24)
|
||||||
if note.html:
|
if note.html:
|
||||||
|
|||||||
@@ -313,12 +313,46 @@ def _build_item_object(site_url: str, note: Note) -> Dict[str, Any]:
|
|||||||
if hasattr(note, 'tags') and note.tags:
|
if hasattr(note, 'tags') and note.tags:
|
||||||
item["tags"] = [tag['display_name'] for tag in note.tags]
|
item["tags"] = [tag['display_name'] for tag in note.tags]
|
||||||
|
|
||||||
# Add custom StarPunk extensions
|
# Add custom StarPunk extensions (v1.4.0 Phase 4)
|
||||||
item["_starpunk"] = {
|
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,
|
"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
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -304,18 +304,62 @@ def generate_rss_streaming(
|
|||||||
item_xml += f"""
|
item_xml += f"""
|
||||||
<category>{_escape_xml(tag['display_name'])}</category>"""
|
<category>{_escape_xml(tag['display_name'])}</category>"""
|
||||||
|
|
||||||
# Add media:content elements (all images)
|
# Enhanced media handling with variants (v1.4.0 Phase 4)
|
||||||
if hasattr(note, 'media') and note.media:
|
if hasattr(note, 'media') and note.media:
|
||||||
for media_item in note.media:
|
for media_item in note.media:
|
||||||
media_url = f"{site_url}/media/{media_item['path']}"
|
variants = media_item.get('variants', {})
|
||||||
item_xml += f"""
|
|
||||||
<media:content url="{_escape_xml(media_url)}" type="{media_item.get('mime_type', 'image/jpeg')}" medium="image" fileSize="{media_item.get('size', 0)}"/>"""
|
|
||||||
|
|
||||||
# Add media:thumbnail (first image only)
|
# Use media:group for multiple sizes of same image
|
||||||
first_media = note.media[0]
|
if variants:
|
||||||
media_url = f"{site_url}/media/{first_media['path']}"
|
item_xml += '\n <media:group>'
|
||||||
item_xml += f"""
|
|
||||||
<media:thumbnail url="{_escape_xml(media_url)}"/>"""
|
# 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'''
|
||||||
|
<media:content url="{_escape_xml(media_url)}"
|
||||||
|
type="{media_item.get('mime_type', 'image/jpeg')}"
|
||||||
|
medium="image"
|
||||||
|
isDefault="{is_default}"
|
||||||
|
width="{v['width']}"
|
||||||
|
height="{v['height']}"
|
||||||
|
fileSize="{v['size_bytes']}"/>'''
|
||||||
|
|
||||||
|
item_xml += '\n </media:group>'
|
||||||
|
|
||||||
|
# Add media:thumbnail
|
||||||
|
if 'thumb' in variants:
|
||||||
|
thumb = variants['thumb']
|
||||||
|
thumb_url = f"{site_url}/media/{thumb['path']}"
|
||||||
|
item_xml += f'''
|
||||||
|
<media:thumbnail url="{_escape_xml(thumb_url)}"
|
||||||
|
width="{thumb['width']}"
|
||||||
|
height="{thumb['height']}"/>'''
|
||||||
|
|
||||||
|
# Add media:title for caption
|
||||||
|
if media_item.get('caption'):
|
||||||
|
item_xml += f'''
|
||||||
|
<media:title type="plain">{_escape_xml(media_item['caption'])}</media:title>'''
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Fallback for media without variants (legacy)
|
||||||
|
media_url = f"{site_url}/media/{media_item['path']}"
|
||||||
|
item_xml += f'''
|
||||||
|
<media:content url="{_escape_xml(media_url)}"
|
||||||
|
type="{media_item.get('mime_type', 'image/jpeg')}"
|
||||||
|
medium="image"
|
||||||
|
fileSize="{media_item.get('size', 0)}"/>'''
|
||||||
|
|
||||||
# Close item
|
# Close item
|
||||||
item_xml += """
|
item_xml += """
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ class TestRSSStreamingMedia:
|
|||||||
assert enclosure is not None
|
assert enclosure is not None
|
||||||
|
|
||||||
def test_rss_streaming_includes_media_elements(self, app, note_with_single_media):
|
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():
|
with app.app_context():
|
||||||
generator = generate_rss_streaming(
|
generator = generate_rss_streaming(
|
||||||
site_url="https://example.com",
|
site_url="https://example.com",
|
||||||
@@ -419,8 +419,10 @@ class TestRSSStreamingMedia:
|
|||||||
channel = root.find("channel")
|
channel = root.find("channel")
|
||||||
item = channel.find("item")
|
item = channel.find("item")
|
||||||
|
|
||||||
media_content = item.find("media:content", namespaces)
|
# v1.4.0: media:content is now inside media:group for images with variants
|
||||||
media_thumbnail = item.find("media:thumbnail", namespaces)
|
# 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_content is not None
|
||||||
assert media_thumbnail is not None
|
assert media_thumbnail is not None
|
||||||
|
|||||||
Reference in New Issue
Block a user