audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: R2 CDN caching via custom domains + CacheControl headers (#1278)

* feat: add CacheControl headers to R2 uploads, consolidate S3 client config

Set Cache-Control: public, max-age=31536000, immutable on all R2 uploads
(audio, images, thumbnails). Objects are content-hashed so they never
change — this tells Cloudflare's CDN and browsers to cache aggressively.

Also consolidate the S3 client connection config into _s3_client() helper
method. The same 5-line endpoint/credentials block was repeated 9 times.
Now it's one method, making an S3/R2 swap a one-line change.

Prep for switching from r2.dev URLs (no CDN caching) to custom domains
(audio.plyr.fm, images.plyr.fm) which are already provisioned.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: update R2 references for custom domain CDN migration

Replace r2.dev URLs with custom domain URLs (audio.plyr.fm,
images.plyr.fm) in public docs, internal docs, and config examples.
Drop "R2" from "R2 CDN" references — the CDN is Cloudflare's edge
cache, not R2 itself.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4.6 (1M context)
and committed by
GitHub
6cc737ca 8b154031

+55 -85
+1 -1
backend/src/backend/api/CLAUDE.md
··· 12 12 - **albums**: CRUD with cover art, track ordering, ATProto list records 13 13 - **playlists**: CRUD with drag-and-drop reordering, ATProto list records 14 14 - **artists**: profiles synced from ATProto identities, support links 15 - - **audio**: streaming via 307 redirects to R2 CDN 15 + - **audio**: streaming via 307 redirects to CDN 16 16 - **queue**: server-authoritative with optimistic client updates 17 17 - **preferences**: user settings (accent color, auto-play, teal scrobbling, sensitive artwork) 18 18 - **exports**: media export with SSE progress tracking, concurrent downloads
+1 -6
backend/src/backend/api/tracks/listing.py
··· 561 561 r2 = cast("R2Storage", storage) 562 562 async with semaphore: 563 563 try: 564 - async with r2.async_session.client( 565 - "s3", 566 - endpoint_url=r2.endpoint_url, 567 - aws_access_key_id=r2.aws_access_key_id, 568 - aws_secret_access_key=r2.aws_secret_access_key, 569 - ) as client: 564 + async with r2._s3_client() as client: 570 565 response = await client.head_object( 571 566 Bucket=storage.audio_bucket_name, Key=key 572 567 )
+37 -64
backend/src/backend/storage/r2.py
··· 3 3 import time 4 4 from collections.abc import Callable 5 5 from io import BytesIO 6 - from pathlib import Path 6 + from pathlib import Path, PurePosixPath 7 7 from typing import BinaryIO 8 8 9 9 import aioboto3 ··· 18 18 from backend.config import settings 19 19 from backend.utilities.database import db_session 20 20 from backend.utilities.hashing import hash_file_chunked 21 + 22 + # content-hashed files never change — cache forever 23 + IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable" 21 24 22 25 23 26 class UploadProgressTracker: ··· 115 118 116 119 # async session for read operations 117 120 self.async_session = aioboto3.Session() 118 - self.endpoint_url = settings.storage.r2_endpoint_url 119 - self.aws_access_key_id = settings.storage.aws_access_key_id 120 - self.aws_secret_access_key = settings.storage.aws_secret_access_key 121 + self._s3_kwargs = { 122 + "endpoint_url": settings.storage.r2_endpoint_url, 123 + "aws_access_key_id": settings.storage.aws_access_key_id, 124 + "aws_secret_access_key": settings.storage.aws_secret_access_key, 125 + } 126 + 127 + def _s3_client(self, **extra_kwargs): 128 + """create an async S3 client context manager. 129 + 130 + centralizes connection config so an S3/R2 swap is one-line. 131 + """ 132 + return self.async_session.client("s3", **self._s3_kwargs, **extra_kwargs) 121 133 122 134 async def save( 123 135 self, ··· 169 181 ) 170 182 171 183 try: 172 - async with self.async_session.client( 173 - "s3", 174 - endpoint_url=self.endpoint_url, 175 - aws_access_key_id=self.aws_access_key_id, 176 - aws_secret_access_key=self.aws_secret_access_key, 177 - ) as client: 184 + async with self._s3_client() as client: 178 185 # prepare upload arguments 179 186 upload_kwargs = { 180 187 "Fileobj": file, 181 188 "Bucket": bucket, 182 189 "Key": key, 183 - "ExtraArgs": {"ContentType": media_type}, 190 + "ExtraArgs": { 191 + "ContentType": media_type, 192 + "CacheControl": IMMUTABLE_CACHE_CONTROL, 193 + }, 184 194 } 185 195 186 196 # add progress callback if provided ··· 224 234 with logfire.span( 225 235 "R2 get_url", file_id=file_id, file_type=file_type, extension=extension 226 236 ): 227 - async with self.async_session.client( 228 - "s3", 229 - endpoint_url=self.endpoint_url, 230 - aws_access_key_id=self.aws_access_key_id, 231 - aws_secret_access_key=self.aws_secret_access_key, 232 - ) as client: 237 + async with self._s3_client() as client: 233 238 # if file_type is "image", skip audio checks 234 239 if file_type != "image": 235 240 # if extension is provided, try single format ··· 306 311 file bytes if found, None otherwise 307 312 """ 308 313 with logfire.span("R2 get_file_data", file_id=file_id, file_type=file_type): 309 - async with self.async_session.client( 310 - "s3", 311 - endpoint_url=self.endpoint_url, 312 - aws_access_key_id=self.aws_access_key_id, 313 - aws_secret_access_key=self.aws_secret_access_key, 314 - ) as client: 314 + async with self._s3_client() as client: 315 315 # build key from file_id and file_type 316 316 audio_format = AudioFormat.from_extension(f".{file_type.lower()}") 317 317 if not audio_format: ··· 393 393 file_type=file_type, 394 394 ) 395 395 396 - async with self.async_session.client( 397 - "s3", 398 - endpoint_url=self.endpoint_url, 399 - aws_access_key_id=self.aws_access_key_id, 400 - aws_secret_access_key=self.aws_secret_access_key, 401 - ) as client: 396 + async with self._s3_client() as client: 402 397 # if file_type is provided, delete the exact key 403 398 if file_type: 404 399 audio_format = AudioFormat.from_extension(f".{file_type.lower()}") ··· 455 450 continue 456 451 457 452 # try image formats 458 - from backend._internal.image import ImageFormat 459 - 460 453 for image_format in ImageFormat: 461 454 key = f"images/{file_id}.{image_format.value}" 462 455 ··· 498 491 wasted round trips). the image URL already encodes the correct 499 492 extension, so we can build the key directly. 500 493 """ 501 - from pathlib import PurePosixPath 502 - 503 494 # extract extension from URL: ".../images/abc123.png?..." → ".png" 504 495 ext = PurePosixPath(image_url.split("?")[0]).suffix.lower() 505 496 if not ext: ··· 511 502 return await self.delete(file_id) 512 503 513 504 key = f"images/{file_id}{ext}" 514 - async with self.async_session.client( 515 - "s3", 516 - endpoint_url=self.endpoint_url, 517 - aws_access_key_id=self.aws_access_key_id, 518 - aws_secret_access_key=self.aws_secret_access_key, 519 - ) as client: 505 + async with self._s3_client() as client: 520 506 try: 521 507 await client.delete_object(Bucket=self.image_bucket_name, Key=key) 522 508 logfire.info( ··· 575 561 ) 576 562 577 563 try: 578 - async with self.async_session.client( 579 - "s3", 580 - endpoint_url=self.endpoint_url, 581 - aws_access_key_id=self.aws_access_key_id, 582 - aws_secret_access_key=self.aws_secret_access_key, 583 - ) as client: 564 + async with self._s3_client() as client: 584 565 upload_kwargs = { 585 566 "Fileobj": file, 586 567 "Bucket": self.private_audio_bucket_name, 587 568 "Key": key, 588 - "ExtraArgs": {"ContentType": media_type}, 569 + "ExtraArgs": { 570 + "ContentType": media_type, 571 + "CacheControl": IMMUTABLE_CACHE_CONTROL, 572 + }, 589 573 } 590 574 591 575 if progress_callback and file_size > 0: ··· 642 626 key=key, 643 627 expires_in=expiry, 644 628 ): 645 - async with self.async_session.client( 646 - "s3", 647 - endpoint_url=self.endpoint_url, 648 - aws_access_key_id=self.aws_access_key_id, 649 - aws_secret_access_key=self.aws_secret_access_key, 629 + async with self._s3_client( 650 630 config=Config(signature_version="s3v4"), 651 631 ) as client: 652 632 url = await client.generate_presigned_url( ··· 672 652 """save a WebP thumbnail alongside the original image in R2.""" 673 653 key = f"images/{image_id}_thumb.webp" 674 654 with logfire.span("R2 save_thumbnail", image_id=image_id, key=key): 675 - async with self.async_session.client( 676 - "s3", 677 - endpoint_url=self.endpoint_url, 678 - aws_access_key_id=self.aws_access_key_id, 679 - aws_secret_access_key=self.aws_secret_access_key, 680 - ) as client: 655 + async with self._s3_client() as client: 681 656 await client.upload_fileobj( 682 657 BytesIO(thumbnail_data), 683 658 self.image_bucket_name, 684 659 key, 685 - ExtraArgs={"ContentType": "image/webp"}, 660 + ExtraArgs={ 661 + "ContentType": "image/webp", 662 + "CacheControl": IMMUTABLE_CACHE_CONTROL, 663 + }, 686 664 ) 687 665 return f"{self.public_image_bucket_url}/{key}" 688 666 ··· 728 706 to_private=to_private, 729 707 ): 730 708 try: 731 - async with self.async_session.client( 732 - "s3", 733 - endpoint_url=self.endpoint_url, 734 - aws_access_key_id=self.aws_access_key_id, 735 - aws_secret_access_key=self.aws_secret_access_key, 736 - ) as client: 709 + async with self._s3_client() as client: 737 710 # copy to destination 738 711 await client.copy_object( 739 712 CopySource={"Bucket": src_bucket, "Key": key},
+5 -3
backend/tests/test_storage_types.py
··· 18 18 s.public_audio_bucket_url = "https://audio.test.dev" 19 19 s.public_image_bucket_url = "https://images.test.dev" 20 20 s.presigned_url_expiry = 3600 21 - s.endpoint_url = "https://test.r2.dev" 22 - s.aws_access_key_id = "test" 23 - s.aws_secret_access_key = "test" 21 + s._s3_kwargs = { 22 + "endpoint_url": "https://test.r2.dev", 23 + "aws_access_key_id": "test", 24 + "aws_secret_access_key": "test", 25 + } 24 26 25 27 mock_client = AsyncMock() 26 28 mock_client.upload_fileobj = AsyncMock()
+2 -2
docs/internal/backend/configuration.md
··· 90 90 R2_PRIVATE_BUCKET=your-private-audio-bucket # for supporter-gated content 91 91 R2_IMAGE_BUCKET=your-image-bucket 92 92 R2_ENDPOINT_URL=https://xxx.r2.cloudflarestorage.com 93 - R2_PUBLIC_BUCKET_URL=https://pub-xxx.r2.dev # for audio files 94 - R2_PUBLIC_IMAGE_BUCKET_URL=https://pub-xxx.r2.dev # for image files 93 + R2_PUBLIC_BUCKET_URL=https://audio.plyr.fm # audio files (custom domain for CDN caching) 94 + R2_PUBLIC_IMAGE_BUCKET_URL=https://images.plyr.fm # image files (custom domain for CDN caching) 95 95 AWS_ACCESS_KEY_ID=your-r2-access-key 96 96 AWS_SECRET_ACCESS_KEY=your-r2-secret 97 97 ```
+2 -2
docs/internal/local-development/setup.md
··· 58 58 R2_PRIVATE_BUCKET=audio-private-dev # for supporter-gated content 59 59 R2_IMAGE_BUCKET=images-dev 60 60 R2_ENDPOINT_URL=<your-r2-endpoint> 61 - R2_PUBLIC_BUCKET_URL=<your-r2-public-url> 62 - R2_PUBLIC_IMAGE_BUCKET_URL=<your-r2-image-public-url> 61 + R2_PUBLIC_BUCKET_URL=<your-r2-public-url> # local dev can use r2.dev URLs; production uses audio.plyr.fm 62 + R2_PUBLIC_IMAGE_BUCKET_URL=<your-r2-image-public-url> # local dev can use r2.dev URLs; production uses images.plyr.fm 63 63 AWS_ACCESS_KEY_ID=<your-r2-access-key> 64 64 AWS_SECRET_ACCESS_KEY=<your-r2-secret> 65 65
+1 -1
docs/internal/moderation/sensitive-content.md
··· 158 158 ### example: flagging an R2 image 159 159 160 160 ```sql 161 - -- image URL: https://pub-xxx.r2.dev/images/abc123.jpg 161 + -- image URL: https://images.plyr.fm/images/abc123.jpg 162 162 INSERT INTO sensitive_images (image_id, reason, flagged_by) 163 163 VALUES ('abc123', 'nudity', 'admin'); 164 164 ```
+1 -1
docs/internal/security.md
··· 67 67 ### ATProto Record Behavior 68 68 69 69 when a track is gated, the ATProto `fm.plyr.track` record's `audioUrl` changes: 70 - - **public**: points to R2 CDN URL (e.g., `https://cdn.plyr.fm/audio/abc123.mp3`) 70 + - **public**: points to CDN URL (e.g., `https://audio.plyr.fm/audio/abc123.mp3`) 71 71 - **gated**: points to API endpoint (e.g., `https://api.plyr.fm/audio/abc123`) 72 72 73 73 this means ATProto clients cannot stream gated content without authentication through plyr.fm's API.
+3 -3
docs/public/developers/api-reference/audio.md
··· 17 17 ``` 18 18 19 19 20 - stream audio file by redirecting to R2 CDN URL. 20 + stream audio file by redirecting to CDN URL. 21 21 22 - for public tracks: redirects to R2 CDN URL. 22 + for public tracks: redirects to CDN URL. 23 23 for gated tracks: validates supporter status and returns presigned URL. 24 24 25 25 HEAD requests are used for pre-flight auth checks - they return ··· 38 38 39 39 return direct URL for audio file. 40 40 41 - for public tracks: returns R2 CDN URL for offline caching. 41 + for public tracks: returns CDN URL for offline caching. 42 42 for gated tracks: returns presigned URL after supporter validation. 43 43 44 44 used for offline mode - frontend fetches and caches locally.
+1 -1
docs/public/index.mdx
··· 74 74 "album": "2026", 75 75 "title": "plyr.fm update - February 27, 2026", 76 76 "artist": "plyr.fm", 77 - "audioUrl": "https://pub-d4ed8a1e39d44dac85263d86ad5676fd.r2.dev/audio/ada9cadc63efd822.wav", 77 + "audioUrl": "https://audio.plyr.fm/audio/ada9cadc63efd822.wav", 78 78 "duration": 265, 79 79 "fileType": "wav", 80 80 "audioBlob": {
+1 -1
docs/public/lexicons/overview.md
··· 46 46 "title": "plyr.fm update - February 27, 2026", 47 47 "artist": "plyr.fm", 48 48 "album": "2026", 49 - "audioUrl": "https://pub-d4ed8a1e39d44dac85263d86ad5676fd.r2.dev/audio/ada9cadc63efd822.wav", 49 + "audioUrl": "https://audio.plyr.fm/audio/ada9cadc63efd822.wav", 50 50 "audioBlob": { 51 51 "$type": "blob", 52 52 "ref": { "$link": "bafkreifnvhfnyy7p3ara2gdyv6krztsd26luv2mi45j7hw3sreq7xjpd24" },