audio streaming app plyr.fm

feat: add track description field and RSS feed generation (#1045)

* feat: add track description field and RSS feed generation

Add nullable description column to tracks for liner notes/show notes,
and RSS feed endpoints for artist, album, and playlist collections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: move deferred import to top of feeds module

No circular dependency — feeds.py is a leaf module.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

authored by zzstoatzz.io

Claude Opus 4.6 and committed by
GitHub
e8b17118 09325001

+857 -4
+29
backend/alembic/versions/2026_03_06_112610_bcf223076d43_add_track_description_column.py
··· 1 + """add track description column 2 + 3 + Revision ID: bcf223076d43 4 + Revises: a3d9fe3d8d02 5 + Create Date: 2026-03-06 11:26:10.477969 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + 13 + from alembic import op 14 + 15 + # revision identifiers, used by Alembic. 16 + revision: str = "bcf223076d43" 17 + down_revision: str | Sequence[str] | None = "a3d9fe3d8d02" 18 + branch_labels: str | Sequence[str] | None = None 19 + depends_on: str | Sequence[str] | None = None 20 + 21 + 22 + def upgrade() -> None: 23 + """Upgrade schema.""" 24 + op.add_column("tracks", sa.Column("description", sa.String(), nullable=True)) 25 + 26 + 27 + def downgrade() -> None: 28 + """Downgrade schema.""" 29 + op.drop_column("tracks", "description")
+1
backend/pyproject.toml
··· 32 32 "beartype>=0.22.8", 33 33 "turbopuffer>=0.5.0", 34 34 "Pillow>=11.0.0", 35 + "feedgen>=1.0.0", 35 36 ] 36 37 37 38 requires-python = ">=3.11"
+7
backend/src/backend/_internal/atproto/records/fm_plyr/track.py
··· 24 24 image_url: str | None = None, 25 25 support_gate: dict[str, Any] | None = None, 26 26 audio_blob: BlobRef | None = None, 27 + description: str | None = None, 27 28 ) -> dict[str, Any]: 28 29 """Build a track record dict for ATProto. 29 30 ··· 38 39 image_url: optional cover art image URL 39 40 support_gate: optional gating config (e.g., {"type": "any"}) 40 41 audio_blob: optional blob reference from PDS upload (canonical source when present) 42 + description: optional track description (liner notes, show notes) 41 43 42 44 returns: 43 45 record dict ready for ATProto ··· 72 74 record["imageUrl"] = image_url 73 75 if support_gate: 74 76 record["supportGate"] = support_gate 77 + if description: 78 + record["description"] = description 75 79 if audio_blob: 76 80 record["audioBlob"] = audio_blob 77 81 ··· 90 94 image_url: str | None = None, 91 95 support_gate: dict[str, Any] | None = None, 92 96 audio_blob: BlobRef | None = None, 97 + description: str | None = None, 93 98 ) -> tuple[str, str]: 94 99 """Create a track record on the user's PDS using the configured collection. 95 100 ··· 105 110 image_url: optional cover art image URL 106 111 support_gate: optional gating config (e.g., {"type": "any"}) 107 112 audio_blob: optional blob reference from PDS upload (canonical source when present) 113 + description: optional track description (liner notes, show notes) 108 114 109 115 returns: 110 116 tuple of (record_uri, record_cid) ··· 124 130 image_url=image_url, 125 131 support_gate=support_gate, 126 132 audio_blob=audio_blob, 133 + description=description, 127 134 ) 128 135 129 136 payload = {
+2
backend/src/backend/api/__init__.py
··· 8 8 from backend.api.audio import router as audio_router 9 9 from backend.api.auth import router as auth_router 10 10 from backend.api.exports import router as exports_router 11 + from backend.api.feeds import router as feeds_router 11 12 from backend.api.jams import router as jams_router 12 13 from backend.api.moderation import router as moderation_router 13 14 from backend.api.now_playing import router as now_playing_router ··· 28 29 "auth_router", 29 30 "discover_router", 30 31 "exports_router", 32 + "feeds_router", 31 33 "jams_router", 32 34 "meta_router", 33 35 "moderation_router",
+261
backend/src/backend/api/feeds.py
··· 1 + """RSS feed generation for artist, album, and playlist collections.""" 2 + 3 + import logging 4 + from typing import Annotated 5 + 6 + from fastapi import APIRouter, Depends, HTTPException 7 + from fastapi.responses import Response 8 + from feedgen.feed import FeedGenerator 9 + from sqlalchemy import select 10 + from sqlalchemy.ext.asyncio import AsyncSession 11 + from sqlalchemy.orm import selectinload 12 + 13 + from backend._internal.atproto.records.fm_plyr.track import get_record_public 14 + from backend.config import settings 15 + from backend.models import Artist, Track, get_db 16 + from backend.models.album import Album 17 + from backend.models.playlist import Playlist 18 + 19 + logger = logging.getLogger(__name__) 20 + 21 + router = APIRouter(prefix="/feeds", tags=["feeds"]) 22 + 23 + 24 + def _rss_response(fg: FeedGenerator) -> Response: 25 + """Render a FeedGenerator to an RSS XML response.""" 26 + return Response( 27 + content=fg.rss_str(pretty=True), 28 + media_type="application/rss+xml; charset=utf-8", 29 + ) 30 + 31 + 32 + def _add_itunes_ns(fg: FeedGenerator) -> None: 33 + """Register the iTunes podcast namespace on the feed.""" 34 + fg.load_extension("podcast") 35 + 36 + 37 + def _add_track_item( 38 + fg: FeedGenerator, 39 + track: Track, 40 + frontend_url: str, 41 + ) -> None: 42 + """Add a track as an RSS <item> entry.""" 43 + entry = fg.add_entry() 44 + entry.title(track.title) 45 + entry.link(href=f"{frontend_url}/track/{track.id}") 46 + entry.guid( 47 + track.atproto_record_uri or str(track.id), 48 + permalink=False, 49 + ) 50 + entry.pubDate(track.created_at) 51 + 52 + if track.description: 53 + entry.description(track.description) 54 + 55 + if track.r2_url: 56 + entry.enclosure( 57 + url=track.r2_url, 58 + type=_audio_mime_type(track.file_type), 59 + length="0", 60 + ) 61 + 62 + if track.duration: 63 + entry.podcast.itunes_duration(track.duration) 64 + 65 + if track.image_url: 66 + entry.podcast.itunes_image(track.image_url) 67 + 68 + 69 + def _audio_mime_type(file_type: str) -> str: 70 + """Map file extension to MIME type.""" 71 + return { 72 + "mp3": "audio/mpeg", 73 + "wav": "audio/wav", 74 + "m4a": "audio/mp4", 75 + "flac": "audio/flac", 76 + "aiff": "audio/aiff", 77 + "aif": "audio/aiff", 78 + "ogg": "audio/ogg", 79 + }.get(file_type, "audio/mpeg") 80 + 81 + 82 + @router.get("/artist/{handle}") 83 + async def artist_feed( 84 + handle: str, 85 + db: Annotated[AsyncSession, Depends(get_db)], 86 + ) -> Response: 87 + """RSS feed of all public tracks by an artist, newest first.""" 88 + result = await db.execute(select(Artist).where(Artist.handle == handle)) 89 + artist = result.scalar_one_or_none() 90 + if not artist: 91 + raise HTTPException(status_code=404, detail="artist not found") 92 + 93 + frontend_url = str(settings.frontend.url).rstrip("/") 94 + 95 + fg = FeedGenerator() 96 + _add_itunes_ns(fg) 97 + fg.title(f"{artist.display_name} on plyr.fm") 98 + fg.link(href=f"{frontend_url}/u/{handle}") 99 + fg.description(f"tracks by {artist.display_name}") 100 + fg.language("en") 101 + 102 + if artist.avatar_url: 103 + fg.image(artist.avatar_url) 104 + fg.podcast.itunes_image(artist.avatar_url) # type: ignore[attr-defined] 105 + 106 + # self-link for feed readers 107 + fg.link( 108 + href=f"{frontend_url}/api/feeds/artist/{handle}", 109 + rel="self", 110 + type="application/rss+xml", 111 + ) 112 + 113 + # fetch public (non-gated) tracks, newest first 114 + tracks_result = await db.execute( 115 + select(Track) 116 + .options(selectinload(Track.artist)) 117 + .where(Track.artist_did == artist.did, Track.support_gate.is_(None)) 118 + .order_by(Track.created_at.desc()) 119 + .limit(200) 120 + ) 121 + tracks = tracks_result.scalars().all() 122 + 123 + if tracks: 124 + fg.lastBuildDate(tracks[0].created_at) 125 + 126 + for track in tracks: 127 + _add_track_item(fg, track, frontend_url) 128 + 129 + return _rss_response(fg) 130 + 131 + 132 + @router.get("/album/{handle}/{slug}") 133 + async def album_feed( 134 + handle: str, 135 + slug: str, 136 + db: Annotated[AsyncSession, Depends(get_db)], 137 + ) -> Response: 138 + """RSS feed of tracks in an album.""" 139 + result = await db.execute( 140 + select(Album) 141 + .join(Artist) 142 + .options(selectinload(Album.artist)) 143 + .where(Artist.handle == handle, Album.slug == slug) 144 + ) 145 + album = result.scalar_one_or_none() 146 + if not album: 147 + raise HTTPException(status_code=404, detail="album not found") 148 + 149 + frontend_url = str(settings.frontend.url).rstrip("/") 150 + 151 + fg = FeedGenerator() 152 + _add_itunes_ns(fg) 153 + fg.title(f"{album.title} by {album.artist.display_name}") 154 + fg.link(href=f"{frontend_url}/u/{handle}/album/{slug}") 155 + fg.description(album.description or f"album by {album.artist.display_name}") 156 + fg.language("en") 157 + 158 + image_url = album.image_url or album.artist.avatar_url 159 + if image_url: 160 + fg.image(image_url) 161 + fg.podcast.itunes_image(image_url) # type: ignore[attr-defined] 162 + 163 + fg.link( 164 + href=f"{frontend_url}/api/feeds/album/{handle}/{slug}", 165 + rel="self", 166 + type="application/rss+xml", 167 + ) 168 + 169 + # fetch public (non-gated) tracks in album 170 + tracks_result = await db.execute( 171 + select(Track) 172 + .options(selectinload(Track.artist)) 173 + .where(Track.album_id == album.id, Track.support_gate.is_(None)) 174 + .order_by(Track.created_at.asc()) 175 + ) 176 + tracks = tracks_result.scalars().all() 177 + 178 + if tracks: 179 + fg.lastBuildDate(max(t.created_at for t in tracks)) 180 + 181 + for track in tracks: 182 + _add_track_item(fg, track, frontend_url) 183 + 184 + return _rss_response(fg) 185 + 186 + 187 + @router.get("/playlist/{playlist_id}") 188 + async def playlist_feed( 189 + playlist_id: str, 190 + db: Annotated[AsyncSession, Depends(get_db)], 191 + ) -> Response: 192 + """RSS feed of tracks in a playlist.""" 193 + result = await db.execute( 194 + select(Playlist) 195 + .options(selectinload(Playlist.owner)) 196 + .where(Playlist.id == playlist_id) 197 + ) 198 + playlist = result.scalar_one_or_none() 199 + if not playlist: 200 + raise HTTPException(status_code=404, detail="playlist not found") 201 + 202 + frontend_url = str(settings.frontend.url).rstrip("/") 203 + 204 + fg = FeedGenerator() 205 + _add_itunes_ns(fg) 206 + fg.title(f"{playlist.name} by {playlist.owner.display_name}") 207 + fg.link(href=f"{frontend_url}/playlist/{playlist_id}") 208 + fg.description(f"playlist by {playlist.owner.display_name}") 209 + fg.language("en") 210 + 211 + image_url = playlist.image_url or playlist.owner.avatar_url 212 + if image_url: 213 + fg.image(image_url) 214 + fg.podcast.itunes_image(image_url) # type: ignore[attr-defined] 215 + 216 + fg.link( 217 + href=f"{frontend_url}/api/feeds/playlist/{playlist_id}", 218 + rel="self", 219 + type="application/rss+xml", 220 + ) 221 + 222 + # playlists reference tracks by ATProto URI in a list record. 223 + # for simplicity, fetch all tracks owned by the playlist owner that are 224 + # in the playlist's cached track_count. a full implementation would 225 + # resolve the ATProto list items, but the DB-cached tracks are sufficient. 226 + # Instead, query tracks that reference this playlist's list URI. 227 + # Since playlists don't have a direct FK, we resolve via the ATProto list. 228 + # For now, we rely on the list endpoint's approach — fetch list items from PDS. 229 + 230 + track_uris: list[str] = [] 231 + try: 232 + list_data = await get_record_public(playlist.atproto_record_uri) 233 + items = list_data.get("value", {}).get("items", []) 234 + track_uris = [item.get("uri") for item in items if item.get("uri")] 235 + except Exception: 236 + logger.warning( 237 + "failed to fetch playlist list record for feed: %s", 238 + playlist.atproto_record_uri, 239 + ) 240 + 241 + tracks: list[Track] = [] 242 + if track_uris: 243 + tracks_result = await db.execute( 244 + select(Track) 245 + .options(selectinload(Track.artist)) 246 + .where( 247 + Track.atproto_record_uri.in_(track_uris), 248 + Track.support_gate.is_(None), 249 + ) 250 + ) 251 + tracks_by_uri = {t.atproto_record_uri: t for t in tracks_result.scalars().all()} 252 + # maintain playlist order 253 + tracks = [tracks_by_uri[uri] for uri in track_uris if uri in tracks_by_uri] 254 + 255 + if tracks: 256 + fg.lastBuildDate(max(t.created_at for t in tracks)) 257 + 258 + for track in tracks: 259 + _add_track_item(fg, track, frontend_url) 260 + 261 + return _rss_response(fg)
+13
backend/src/backend/api/tracks/mutations.py
··· 149 149 album: Annotated[str | None, Form()] = None, 150 150 features: Annotated[str | None, Form()] = None, 151 151 tags: Annotated[str | None, Form(description="JSON array of tag names")] = None, 152 + description: Annotated[ 153 + str | None, 154 + Form( 155 + description="Track description (liner notes, show notes), or empty string to remove" 156 + ), 157 + ] = None, 152 158 support_gate: Annotated[ 153 159 str | None, 154 160 Form(description="JSON object for supporter gating, or 'null' to remove"), ··· 183 189 track.title = title 184 190 title_changed = True 185 191 192 + description_changed = False 193 + if description is not None: 194 + track.description = description if description != "" else None 195 + description_changed = True 196 + 186 197 # handle support_gate update 187 198 # track migration direction: None = no move, True = to private, False = to public 188 199 move_to_private: bool | None = None ··· 290 301 support_gate_changed = move_to_private is not None 291 302 metadata_changed = ( 292 303 title_changed 304 + or description_changed 293 305 or album is not None 294 306 or features is not None 295 307 or image_changed ··· 380 392 features=track.features if track.features else None, 381 393 image_url=image_url_override or await track.get_image_url(), 382 394 support_gate=track.support_gate, 395 + description=track.description, 383 396 ) 384 397 385 398 result = await update_record(
+10
backend/src/backend/api/tracks/uploads.py
··· 99 99 image_filename: str | None = None 100 100 image_content_type: str | None = None 101 101 102 + # track description (liner notes, show notes, etc.) 103 + description: str | None = None 104 + 102 105 # supporter-gated content (e.g., {"type": "any"}) 103 106 support_gate: dict | None = None 104 107 ··· 665 668 image_url=image_url, 666 669 support_gate=ctx.support_gate, 667 670 audio_blob=pds_result.blob_ref if pds_result else None, 671 + description=ctx.description, 668 672 ) 669 673 if not atproto_result: 670 674 raise ValueError("PDS returned no record data") ··· 713 717 original_file_id=sr.original_file_id, 714 718 original_file_type=sr.original_file_type, 715 719 artist_did=ctx.artist_did, 720 + description=ctx.description, 716 721 extra=extra, 717 722 album_id=album_record.id if album_record else None, 718 723 features=featured_artists, ··· 863 868 str | None, 864 869 Form(description='JSON object for supporter gating, e.g., {"type": "any"}'), 865 870 ] = None, 871 + description: Annotated[ 872 + str | None, 873 + Form(description="Track description (liner notes, show notes, etc.)"), 874 + ] = None, 866 875 auto_tag: Annotated[ 867 876 str | None, 868 877 Form(description="auto-apply recommended genre tags after classification"), ··· 1007 1016 album=album, 1008 1017 features_json=features, 1009 1018 tags=validated_tags, 1019 + description=description, 1010 1020 image_path=image_path, 1011 1021 image_filename=image_filename, 1012 1022 image_content_type=image_content_type,
+2
backend/src/backend/main.py
··· 24 24 auth_router, 25 25 discover_router, 26 26 exports_router, 27 + feeds_router, 27 28 jams_router, 28 29 meta_router, 29 30 moderation_router, ··· 151 152 app.include_router(now_playing_router) 152 153 app.include_router(migration_router) 153 154 app.include_router(exports_router) 155 + app.include_router(feeds_router) 154 156 app.include_router(jams_router) 155 157 app.include_router(pds_backfill_router) 156 158 app.include_router(moderation_router)
+3
backend/src/backend/models/track.py
··· 84 84 pds_blob_cid: Mapped[str | None] = mapped_column(String, nullable=True) 85 85 pds_blob_size: Mapped[int | None] = mapped_column(Integer, nullable=True) 86 86 87 + # track description (liner notes, show notes, etc.) 88 + description: Mapped[str | None] = mapped_column(String, nullable=True) 89 + 87 90 # engagement metrics 88 91 play_count: Mapped[int] = mapped_column( 89 92 Integer, nullable=False, default=0, server_default="0"
+2
backend/src/backend/schemas.py
··· 118 118 original_file_type: str | None = ( 119 119 None # original format if transcoded (e.g., aiff, flac) 120 120 ) 121 + description: str | None = None # track description (liner notes, show notes) 121 122 audio_storage: str = "r2" # "r2" | "pds" | "both" 122 123 pds_blob_cid: str | None = None # CID if stored on user's PDS 123 124 ··· 226 227 copyright_match=copyright_match, 227 228 support_gate=track.support_gate, 228 229 gated=gated, 230 + description=track.description, 229 231 original_file_id=track.original_file_id, 230 232 original_file_type=track.original_file_type, 231 233 audio_storage=track.audio_storage,
+220
backend/tests/api/test_feeds.py
··· 1 + """tests for RSS feed endpoints.""" 2 + 3 + from xml.etree import ElementTree 4 + 5 + import pytest 6 + from fastapi.testclient import TestClient 7 + from sqlalchemy.ext.asyncio import AsyncSession 8 + 9 + from backend.models import Artist, Track 10 + from backend.models.album import Album 11 + 12 + 13 + @pytest.fixture 14 + async def artist(db_session: AsyncSession) -> Artist: 15 + """create a test artist.""" 16 + artist = Artist( 17 + did="did:plc:feed_test_artist", 18 + handle="feed-test.bsky.social", 19 + display_name="Feed Test Artist", 20 + avatar_url="https://example.com/avatar.jpg", 21 + ) 22 + db_session.add(artist) 23 + await db_session.commit() 24 + return artist 25 + 26 + 27 + @pytest.fixture 28 + async def public_tracks(db_session: AsyncSession, artist: Artist) -> list[Track]: 29 + """create public tracks (no support_gate).""" 30 + tracks = [ 31 + Track( 32 + title="Public Track 1", 33 + artist_did=artist.did, 34 + file_id="feed_pub1", 35 + file_type="mp3", 36 + r2_url="https://cdn.example.com/feed_pub1.mp3", 37 + extra={"duration": 180}, 38 + description="first track description", 39 + ), 40 + Track( 41 + title="Public Track 2", 42 + artist_did=artist.did, 43 + file_id="feed_pub2", 44 + file_type="mp3", 45 + r2_url="https://cdn.example.com/feed_pub2.mp3", 46 + extra={"duration": 240}, 47 + ), 48 + ] 49 + for track in tracks: 50 + db_session.add(track) 51 + await db_session.commit() 52 + return tracks 53 + 54 + 55 + @pytest.fixture 56 + async def gated_track(db_session: AsyncSession, artist: Artist) -> Track: 57 + """create a supporter-gated track.""" 58 + track = Track( 59 + title="Gated Track", 60 + artist_did=artist.did, 61 + file_id="feed_gated", 62 + file_type="mp3", 63 + support_gate={"type": "any"}, 64 + ) 65 + db_session.add(track) 66 + await db_session.commit() 67 + return track 68 + 69 + 70 + @pytest.fixture 71 + async def album_with_tracks( 72 + db_session: AsyncSession, artist: Artist 73 + ) -> tuple[Album, list[Track]]: 74 + """create an album with tracks.""" 75 + album = Album( 76 + artist_did=artist.did, 77 + slug="test-album", 78 + title="Test Album", 79 + description="a test album", 80 + ) 81 + db_session.add(album) 82 + await db_session.flush() 83 + 84 + tracks = [ 85 + Track( 86 + title="Album Track 1", 87 + artist_did=artist.did, 88 + file_id="feed_alb1", 89 + file_type="mp3", 90 + r2_url="https://cdn.example.com/feed_alb1.mp3", 91 + album_id=album.id, 92 + extra={"duration": 120}, 93 + ), 94 + Track( 95 + title="Album Track 2", 96 + artist_did=artist.did, 97 + file_id="feed_alb2", 98 + file_type="mp3", 99 + r2_url="https://cdn.example.com/feed_alb2.mp3", 100 + album_id=album.id, 101 + extra={"duration": 200}, 102 + ), 103 + ] 104 + for track in tracks: 105 + db_session.add(track) 106 + await db_session.commit() 107 + return album, tracks 108 + 109 + 110 + def _parse_rss(content: bytes) -> ElementTree.Element: 111 + """parse RSS XML and return the root element.""" 112 + return ElementTree.fromstring(content) 113 + 114 + 115 + async def test_artist_feed_returns_rss( 116 + client: TestClient, 117 + artist: Artist, 118 + public_tracks: list[Track], 119 + ) -> None: 120 + """artist feed returns valid RSS XML with public tracks.""" 121 + response = client.get(f"/feeds/artist/{artist.handle}") 122 + assert response.status_code == 200 123 + assert "application/rss+xml" in response.headers["content-type"] 124 + 125 + root = _parse_rss(response.content) 126 + assert root.tag == "rss" 127 + 128 + channel = root.find("channel") 129 + assert channel is not None 130 + assert channel.findtext("title") == f"{artist.display_name} on plyr.fm" 131 + 132 + items = channel.findall("item") 133 + assert len(items) == 2 134 + 135 + 136 + async def test_artist_feed_excludes_gated_tracks( 137 + client: TestClient, 138 + artist: Artist, 139 + public_tracks: list[Track], 140 + gated_track: Track, 141 + ) -> None: 142 + """artist feed excludes supporter-gated tracks.""" 143 + response = client.get(f"/feeds/artist/{artist.handle}") 144 + assert response.status_code == 200 145 + 146 + root = _parse_rss(response.content) 147 + channel = root.find("channel") 148 + assert channel is not None 149 + items = channel.findall("item") 150 + # only public tracks, not gated 151 + assert len(items) == 2 152 + titles = {item.findtext("title") for item in items} 153 + assert "Gated Track" not in titles 154 + 155 + 156 + async def test_artist_feed_includes_description( 157 + client: TestClient, 158 + artist: Artist, 159 + public_tracks: list[Track], 160 + ) -> None: 161 + """artist feed includes track description when present.""" 162 + response = client.get(f"/feeds/artist/{artist.handle}") 163 + root = _parse_rss(response.content) 164 + channel = root.find("channel") 165 + assert channel is not None 166 + items = channel.findall("item") 167 + 168 + descriptions = [item.findtext("description") for item in items] 169 + assert "first track description" in descriptions 170 + 171 + 172 + async def test_artist_feed_404_for_unknown_handle(client: TestClient) -> None: 173 + """artist feed returns 404 for nonexistent artist.""" 174 + response = client.get("/feeds/artist/nonexistent.bsky.social") 175 + assert response.status_code == 404 176 + 177 + 178 + async def test_album_feed_returns_rss( 179 + client: TestClient, 180 + artist: Artist, 181 + album_with_tracks: tuple[Album, list[Track]], 182 + ) -> None: 183 + """album feed returns valid RSS XML with album tracks.""" 184 + album, _tracks = album_with_tracks 185 + response = client.get(f"/feeds/album/{artist.handle}/{album.slug}") 186 + assert response.status_code == 200 187 + assert "application/rss+xml" in response.headers["content-type"] 188 + 189 + root = _parse_rss(response.content) 190 + channel = root.find("channel") 191 + assert channel is not None 192 + assert "Test Album" in (channel.findtext("title") or "") 193 + 194 + items = channel.findall("item") 195 + assert len(items) == 2 196 + 197 + 198 + async def test_album_feed_404_for_unknown(client: TestClient) -> None: 199 + """album feed returns 404 for nonexistent album.""" 200 + response = client.get("/feeds/album/nobody.bsky.social/no-album") 201 + assert response.status_code == 404 202 + 203 + 204 + async def test_artist_feed_has_enclosure( 205 + client: TestClient, 206 + artist: Artist, 207 + public_tracks: list[Track], 208 + ) -> None: 209 + """RSS items include enclosure with audio URL.""" 210 + response = client.get(f"/feeds/artist/{artist.handle}") 211 + root = _parse_rss(response.content) 212 + channel = root.find("channel") 213 + assert channel is not None 214 + items = channel.findall("item") 215 + assert len(items) > 0 216 + 217 + enclosure = items[0].find("enclosure") 218 + assert enclosure is not None 219 + assert enclosure.get("url", "").startswith("https://") 220 + assert enclosure.get("type") == "audio/mpeg"
+95
backend/tests/api/test_track_description.py
··· 1 + """tests for track description field.""" 2 + 3 + import pytest 4 + from fastapi.testclient import TestClient 5 + from sqlalchemy import select 6 + from sqlalchemy.ext.asyncio import AsyncSession 7 + 8 + from backend.models import Artist, Track 9 + 10 + 11 + @pytest.fixture 12 + async def artist(db_session: AsyncSession) -> Artist: 13 + """create a test artist.""" 14 + artist = Artist( 15 + did="did:plc:desc_test_artist", 16 + handle="desc-test.bsky.social", 17 + display_name="Description Test Artist", 18 + ) 19 + db_session.add(artist) 20 + await db_session.commit() 21 + return artist 22 + 23 + 24 + @pytest.fixture 25 + async def track_with_description(db_session: AsyncSession, artist: Artist) -> Track: 26 + """create a track with a description.""" 27 + track = Track( 28 + title="Described Track", 29 + artist_did=artist.did, 30 + file_id="desc_track1", 31 + file_type="mp3", 32 + description="these are the liner notes for the track", 33 + ) 34 + db_session.add(track) 35 + await db_session.commit() 36 + return track 37 + 38 + 39 + @pytest.fixture 40 + async def track_without_description(db_session: AsyncSession, artist: Artist) -> Track: 41 + """create a track without a description.""" 42 + track = Track( 43 + title="Plain Track", 44 + artist_did=artist.did, 45 + file_id="desc_track2", 46 + file_type="mp3", 47 + ) 48 + db_session.add(track) 49 + await db_session.commit() 50 + return track 51 + 52 + 53 + async def test_description_column_nullable( 54 + db_session: AsyncSession, artist: Artist 55 + ) -> None: 56 + """description column accepts null values.""" 57 + track = Track( 58 + title="No Desc", 59 + artist_did=artist.did, 60 + file_id="desc_null_test", 61 + file_type="mp3", 62 + ) 63 + db_session.add(track) 64 + await db_session.commit() 65 + await db_session.refresh(track) 66 + assert track.description is None 67 + 68 + 69 + async def test_description_stored_and_retrieved( 70 + db_session: AsyncSession, track_with_description: Track 71 + ) -> None: 72 + """description is persisted and can be read back.""" 73 + result = await db_session.execute( 74 + select(Track).where(Track.id == track_with_description.id) 75 + ) 76 + track = result.scalar_one() 77 + assert track.description == "these are the liner notes for the track" 78 + 79 + 80 + async def test_description_in_track_listing( 81 + client: TestClient, 82 + track_with_description: Track, 83 + track_without_description: Track, 84 + ) -> None: 85 + """track listing includes description field.""" 86 + response = client.get("/tracks/") 87 + assert response.status_code == 200 88 + data = response.json() 89 + tracks = data["tracks"] 90 + 91 + described = next(t for t in tracks if t["id"] == track_with_description.id) 92 + assert described["description"] == "these are the liner notes for the track" 93 + 94 + plain = next(t for t in tracks if t["id"] == track_without_description.id) 95 + assert plain["description"] is None
+114
backend/uv.lock
··· 336 336 { name = "boto3" }, 337 337 { name = "cachetools" }, 338 338 { name = "fastapi" }, 339 + { name = "feedgen" }, 339 340 { name = "greenlet" }, 340 341 { name = "httpx" }, 341 342 { name = "logfire", extra = ["fastapi", "sqlalchemy"] }, ··· 386 387 { name = "boto3", specifier = ">=1.37.0" }, 387 388 { name = "cachetools", specifier = ">=6.2.1" }, 388 389 { name = "fastapi", specifier = ">=0.115.0" }, 390 + { name = "feedgen", specifier = ">=1.0.0" }, 389 391 { name = "greenlet", specifier = ">=3.2.4" }, 390 392 { name = "httpx", specifier = ">=0.28.0" }, 391 393 { name = "logfire", extras = ["fastapi", "sqlalchemy"], specifier = ">=4.14.2" }, ··· 982 984 ] 983 985 984 986 [[package]] 987 + name = "feedgen" 988 + version = "1.0.0" 989 + source = { registry = "https://pypi.org/simple" } 990 + dependencies = [ 991 + { name = "lxml" }, 992 + { name = "python-dateutil" }, 993 + ] 994 + sdist = { url = "https://files.pythonhosted.org/packages/6b/59/be0a6f852b5dfbf19e6c8e962c8f41407697f9f52a7902250ed98683ae89/feedgen-1.0.0.tar.gz", hash = "sha256:d9bd51c3b5e956a2a52998c3708c4d2c729f2fcc311188e1e5d3b9726393546a", size = 258496, upload-time = "2023-12-25T18:04:08.421Z" } 995 + 996 + [[package]] 985 997 name = "frozenlist" 986 998 version = "1.8.0" 987 999 source = { registry = "https://pypi.org/simple" } ··· 1477 1489 { url = "https://files.pythonhosted.org/packages/b4/a0/89e6a024c3b4485b89ef86881c9d55e097e7cb0bdb74efb746f2fa6a9a76/lupa-2.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9abb98d5a8fd27c8285302e82199f0e56e463066f88f619d6594a450bf269d80", size = 2153693, upload-time = "2025-10-24T07:19:31.379Z" }, 1478 1490 { url = "https://files.pythonhosted.org/packages/b6/36/a0f007dc58fc1bbf51fb85dcc82fcb1f21b8c4261361de7dab0e3d8521ef/lupa-2.6-cp314-cp314t-win32.whl", hash = "sha256:1849efeba7a8f6fb8aa2c13790bee988fd242ae404bd459509640eeea3d1e291", size = 1590104, upload-time = "2025-10-24T07:19:33.514Z" }, 1479 1491 { url = "https://files.pythonhosted.org/packages/7d/5e/db903ce9cf82c48d6b91bf6d63ae4c8d0d17958939a4e04ba6b9f38b8643/lupa-2.6-cp314-cp314t-win_amd64.whl", hash = "sha256:fc1498d1a4fc028bc521c26d0fad4ca00ed63b952e32fb95949bda76a04bad52", size = 1913818, upload-time = "2025-10-24T07:19:36.039Z" }, 1492 + ] 1493 + 1494 + [[package]] 1495 + name = "lxml" 1496 + version = "6.0.2" 1497 + source = { registry = "https://pypi.org/simple" } 1498 + sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } 1499 + wheels = [ 1500 + { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, 1501 + { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, 1502 + { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, 1503 + { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, 1504 + { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, 1505 + { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, 1506 + { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, 1507 + { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, 1508 + { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, 1509 + { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, 1510 + { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, 1511 + { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, 1512 + { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, 1513 + { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, 1514 + { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, 1515 + { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, 1516 + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, 1517 + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, 1518 + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, 1519 + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, 1520 + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, 1521 + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, 1522 + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, 1523 + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, 1524 + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, 1525 + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, 1526 + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, 1527 + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, 1528 + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, 1529 + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, 1530 + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, 1531 + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, 1532 + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, 1533 + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, 1534 + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, 1535 + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, 1536 + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, 1537 + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, 1538 + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, 1539 + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, 1540 + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, 1541 + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, 1542 + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, 1543 + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, 1544 + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, 1545 + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, 1546 + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, 1547 + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, 1548 + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, 1549 + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, 1550 + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, 1551 + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, 1552 + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, 1553 + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, 1554 + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, 1555 + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, 1556 + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, 1557 + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, 1558 + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, 1559 + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, 1560 + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, 1561 + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, 1562 + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, 1563 + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, 1564 + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, 1565 + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, 1566 + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, 1567 + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, 1568 + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, 1569 + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, 1570 + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, 1571 + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, 1572 + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, 1573 + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, 1574 + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, 1575 + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, 1576 + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, 1577 + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, 1578 + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, 1579 + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, 1580 + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, 1581 + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, 1582 + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, 1583 + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, 1584 + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, 1585 + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, 1586 + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, 1587 + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, 1588 + { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, 1589 + { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, 1590 + { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, 1591 + { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, 1592 + { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, 1593 + { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, 1480 1594 ] 1481 1595 1482 1596 [[package]]
+1
frontend/src/lib/types.ts
··· 61 61 gated?: boolean; // true if track is gated AND viewer lacks access 62 62 original_file_id?: string | null; // original file hash if transcoded 63 63 original_file_type?: string | null; // original format if transcoded (e.g., aiff, flac) 64 + description?: string | null; // track description (liner notes, show notes) 64 65 audio_storage?: 'r2' | 'pds' | 'both'; // where audio is stored 65 66 pds_blob_cid?: string | null; // CID if stored on user's PDS 66 67 }
+4
frontend/src/lib/uploader.svelte.ts
··· 71 71 tags: string[], 72 72 supportGated: boolean, 73 73 autoTag: boolean, 74 + description: string, 74 75 onSuccess?: () => void, 75 76 callbacks?: UploadProgressCallback 76 77 ): void { ··· 112 113 } 113 114 if (autoTag) { 114 115 formData.append('auto_tag', 'true'); 116 + } 117 + if (description) { 118 + formData.append('description', description); 115 119 } 116 120 117 121 const xhr = new XMLHttpRequest();
+6
frontend/src/routes/playlist/[id]/+page.svelte
··· 707 707 {/if} 708 708 <link rel="alternate" type="application/json+oembed" title={playlist.name} 709 709 href="{API_URL}/oembed?url={encodeURIComponent(`${APP_CANONICAL_URL}/playlist/${playlist.id}`)}" /> 710 + <link 711 + rel="alternate" 712 + type="application/rss+xml" 713 + title="{playlist.name} RSS feed" 714 + href="{API_URL}/feeds/playlist/{playlist.id}" 715 + /> 710 716 </svelte:head> 711 717 712 718 <Header
+20
frontend/src/routes/portal/+page.svelte
··· 26 26 // track editing state 27 27 let editingTrackId = $state<number | null>(null); 28 28 let editTitle = $state(''); 29 + let editDescription = $state(''); 29 30 let editAlbum = $state(''); 30 31 let editFeaturedArtists = $state<FeaturedArtist[]>([]); 31 32 let editTags = $state<string[]>([]); ··· 398 399 function startEditTrack(track: typeof tracks[0]) { 399 400 editingTrackId = track.id; 400 401 editTitle = track.title; 402 + editDescription = track.description || ''; 401 403 editAlbum = track.album?.title || ''; 402 404 editFeaturedArtists = track.features || []; 403 405 editTags = track.tags || []; ··· 429 431 function cancelEdit() { 430 432 editingTrackId = null; 431 433 editTitle = ''; 434 + editDescription = ''; 432 435 editAlbum = ''; 433 436 editFeaturedArtists = []; 434 437 editTags = []; ··· 448 451 async function saveTrackEdit(trackId: number) { 449 452 const formData = new FormData(); 450 453 formData.append('title', editTitle); 454 + formData.append('description', editDescription); 451 455 formData.append('album', editAlbum); 452 456 if (editFeaturedArtists.length > 0) { 453 457 const handles = editFeaturedArtists.map(a => a.handle); ··· 818 822 placeholder="track title" 819 823 class="edit-input" 820 824 /> 825 + </div> 826 + <div class="edit-field-group"> 827 + <label for="edit-description" class="edit-label">description (optional)</label> 828 + <textarea 829 + id="edit-description" 830 + bind:value={editDescription} 831 + placeholder="liner notes, show notes, credits..." 832 + rows="3" 833 + maxlength="5000" 834 + class="edit-input" 835 + ></textarea> 821 836 </div> 822 837 <div class="edit-field-group"> 823 838 <label for="edit-album" class="edit-label">album (optional)</label> ··· 2311 2326 color: var(--text-primary); 2312 2327 font-size: var(--text-base); 2313 2328 font-family: inherit; 2329 + } 2330 + 2331 + textarea.edit-input { 2332 + resize: vertical; 2333 + min-height: 4rem; 2314 2334 } 2315 2335 2316 2336 /* artwork editor */
+12
frontend/src/routes/track/[id]/+page.svelte
··· 593 593 </div> 594 594 {/if} 595 595 596 + {#if track.description} 597 + <p class="track-description">{track.description}</p> 598 + {/if} 599 + 596 600 <div class="track-stats"> 597 601 <span class="plays">{track.play_count} {track.play_count === 1 ? 'play' : 'plays'}</span> 598 602 {#if track.original_file_type} ··· 1009 1013 background: color-mix(in srgb, var(--accent) 15%, transparent); 1010 1014 color: var(--accent); 1011 1015 outline: none; 1016 + } 1017 + 1018 + .track-description { 1019 + color: var(--text-secondary); 1020 + font-size: var(--text-base); 1021 + line-height: 1.6; 1022 + margin: 0.75rem 0 0; 1023 + white-space: pre-wrap; 1012 1024 } 1013 1025 1014 1026 .track-tags {
+6
frontend/src/routes/u/[handle]/+page.svelte
··· 394 394 {#if data.artist.avatar_url && !moderation.isSensitive(data.artist.avatar_url)} 395 395 <meta name="twitter:image" content="{data.artist.avatar_url}" /> 396 396 {/if} 397 + <link 398 + rel="alternate" 399 + type="application/rss+xml" 400 + title="{data.artist?.display_name} RSS feed" 401 + href="{API_URL}/feeds/artist/{data.artist?.handle}" 402 + /> 397 403 {/if} 398 404 </svelte:head> 399 405
+6
frontend/src/routes/u/[handle]/album/[slug]/+page.svelte
··· 422 422 href="{API_URL}/oembed?url={encodeURIComponent(`${APP_CANONICAL_URL}/u/${albumMetadata.artist_handle}/album/${albumMetadata.slug}`)}" 423 423 title="{albumMetadata.title} by {albumMetadata.artist}" 424 424 /> 425 + <link 426 + rel="alternate" 427 + type="application/rss+xml" 428 + title="{albumMetadata.title} RSS feed" 429 + href="{API_URL}/feeds/album/{albumMetadata.artist_handle}/{albumMetadata.slug}" 430 + /> 425 431 </svelte:head> 426 432 427 433 <Header user={auth.user} isAuthenticated={auth.isAuthenticated} onLogout={() => goto('/login')} />
+34
frontend/src/routes/upload/+page.svelte
··· 59 59 let imageFile = $state<File | null>(null); 60 60 let featuredArtists = $state<FeaturedArtist[]>([]); 61 61 let uploadTags = $state<string[]>([]); 62 + let description = $state(""); 62 63 let hasUnresolvedFeaturesInput = $state(false); 63 64 let attestedRights = $state(false); 64 65 let supportGated = $state(false); ··· 126 127 const tagsToUpload = [...uploadTags]; 127 128 const isGated = supportGated; 128 129 const shouldAutoTag = autoTag; 130 + const uploadDescription = description; 129 131 130 132 const clearForm = () => { 131 133 title = ""; 132 134 albumTitle = ""; 135 + description = ""; 133 136 file = null; 134 137 imageFile = null; 135 138 featuredArtists = []; ··· 157 160 tagsToUpload, 158 161 isGated, 159 162 shouldAutoTag, 163 + uploadDescription, 160 164 async () => { 161 165 await loadMyAlbums(); 162 166 }, ··· 260 264 required 261 265 placeholder="my awesome song" 262 266 /> 267 + </div> 268 + 269 + <div class="form-group"> 270 + <label for="description">description (optional)</label> 271 + <textarea 272 + id="description" 273 + bind:value={description} 274 + placeholder="liner notes, show notes, credits..." 275 + rows="3" 276 + maxlength="5000" 277 + ></textarea> 263 278 </div> 264 279 265 280 <div class="form-group"> ··· 485 500 } 486 501 487 502 input[type="text"]:focus { 503 + outline: none; 504 + border-color: var(--accent); 505 + } 506 + 507 + textarea { 508 + width: 100%; 509 + padding: 0.75rem; 510 + background: var(--bg-primary); 511 + border: 1px solid var(--border-default); 512 + border-radius: var(--radius-sm); 513 + color: var(--text-primary); 514 + font-size: var(--text-base); 515 + font-family: inherit; 516 + transition: all 0.2s; 517 + resize: vertical; 518 + min-height: 4rem; 519 + } 520 + 521 + textarea:focus { 488 522 outline: none; 489 523 border-color: var(--accent); 490 524 }
+5
lexicons/track.json
··· 67 67 "ref": "#supportGate", 68 68 "description": "If set, this track requires viewer to be a supporter of the artist via atprotofans." 69 69 }, 70 + "description": { 71 + "type": "string", 72 + "description": "Track description (liner notes, show notes, etc.).", 73 + "maxLength": 5000 74 + }, 70 75 "audioBlob": { 71 76 "type": "blob", 72 77 "description": "Audio file stored on the user's PDS. When present, this is the canonical source; audioUrl is the CDN fallback.",
+4 -4
loq.toml
··· 159 159 160 160 [[rules]] 161 161 path = "frontend/src/routes/playlist/\\[id\\]/+page.svelte" 162 - max_lines = 2440 162 + max_lines = 2446 163 163 164 164 [[rules]] 165 165 path = "frontend/src/routes/portal/+page.svelte" 166 - max_lines = 3427 166 + max_lines = 3447 167 167 168 168 [[rules]] 169 169 path = "frontend/src/routes/settings/+page.svelte" ··· 179 179 180 180 [[rules]] 181 181 path = "frontend/src/routes/u/\\[handle\\]/album/\\[slug\\]/+page.svelte" 182 - max_lines = 1293 182 + max_lines = 1299 183 183 184 184 [[rules]] 185 185 path = "frontend/src/routes/upload/+page.svelte" 186 - max_lines = 680 186 + max_lines = 712 187 187 188 188 [[rules]] 189 189 path = "services/moderation/src/admin.rs"