audio streaming app plyr.fm
38
fork

Configure Feed

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

reject AIFF/AIF uploads due to browser compatibility (#152)

* fix: support .aif file extension for AIFF audio files

problem:
- users uploading .aif files would see "upload successful" but files were unusable
- files were saved to R2 with .aif extension
- get_url() only checked for .aiff extension
- resulted in 404 errors when trying to play the track

solution:
- add AIF as a separate AudioFormat enum value alongside AIFF
- both map to the same media type (audio/aiff)
- storage backends now automatically check for both extensions
- no special-casing needed since enum iteration handles both

fixes #147

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* reject AIFF/AIF uploads and add format hints

- remove AIFF/AIF from AudioFormat enum (browser compatibility)
- add format hints to audio and artwork upload inputs
- add logfire instrumentation to upload background tasks
- document logfire querying patterns for background tasks
- clean up debug console.log statements from Player
- update tests to reflect removed AIFF support
- add root node_modules to .gitignore

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

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

authored by zzstoatzz.io

Claude and committed by
GitHub
3f901a07 833112d5

+342 -218
+1
.gitignore
··· 43 43 Thumbs.db 44 44 45 45 # Frontend 46 + node_modules/ 46 47 frontend/node_modules/ 47 48 frontend/.svelte-kit/ 48 49 frontend/build/
+80
docs/logfire-querying.md
··· 100 100 ORDER BY duration DESC 101 101 ``` 102 102 103 + ## Background Task and Storage Queries 104 + 105 + **Search by message content:** 106 + ```sql 107 + SELECT 108 + span_name, 109 + message, 110 + start_timestamp, 111 + attributes 112 + FROM records 113 + WHERE message LIKE '%R2%' OR message LIKE '%upload%' 114 + ORDER BY start_timestamp DESC 115 + LIMIT 10 116 + ``` 117 + 118 + **Get full trace for a background task:** 119 + ```sql 120 + -- First, find the trace_id for your background task 121 + SELECT trace_id, message, start_timestamp 122 + FROM records 123 + WHERE span_name = 'process upload background' 124 + ORDER BY start_timestamp DESC 125 + LIMIT 1; 126 + 127 + -- Then get all spans in that trace 128 + SELECT 129 + span_name, 130 + message, 131 + start_timestamp, 132 + duration * 1000 as duration_ms 133 + FROM records 134 + WHERE trace_id = '<trace-id-from-above>' 135 + ORDER BY start_timestamp ASC; 136 + ``` 137 + 138 + **Extract nested attributes from JSONB:** 139 + ```sql 140 + -- Get bucket and key from R2 upload logs 141 + SELECT 142 + message, 143 + attributes->>'bucket' as bucket, 144 + attributes->>'key' as key, 145 + attributes->>'file_id' as file_id, 146 + start_timestamp 147 + FROM records 148 + WHERE message = 'uploading to R2' 149 + ORDER BY start_timestamp DESC 150 + LIMIT 5; 151 + ``` 152 + 153 + **Find spans within a time range:** 154 + ```sql 155 + SELECT 156 + span_name, 157 + message, 158 + start_timestamp, 159 + duration * 1000 as duration_ms 160 + FROM records 161 + WHERE start_timestamp > '2025-11-11T04:56:50Z' 162 + AND start_timestamp < '2025-11-11T04:57:10Z' 163 + AND (span_name LIKE '%R2%' OR message LIKE '%save%') 164 + ORDER BY start_timestamp ASC; 165 + ``` 166 + 167 + **Common mistake: Not all log levels create spans** 168 + 169 + When using `logfire.info()`, these create log events, not spans. To find them: 170 + - Search by `message` field, not `span_name` 171 + - Use LIKE with wildcards: `message LIKE '%preparing%'` 172 + - Filter by `kind = 'event'` if you only want logs (not spans) 173 + 174 + Example: 175 + ```sql 176 + -- WRONG: This won't find logfire.info() calls 177 + SELECT * FROM records WHERE span_name = 'preparing to save audio file'; 178 + 179 + -- RIGHT: Search by message instead 180 + SELECT * FROM records WHERE message LIKE '%preparing%'; 181 + ``` 182 + 103 183 ## Known Issues 104 184 105 185 ### `/tracks/` 500 Error on First Load
+12 -5
frontend/src/routes/portal/+page.svelte
··· 8 8 import { API_URL } from '$lib/config'; 9 9 import { uploader } from '$lib/uploader.svelte'; 10 10 11 - const ACCEPTED_AUDIO_EXTENSIONS = ['.mp3', '.wav', '.m4a', '.aif', '.aiff']; 12 - const ACCEPTED_AUDIO_MIME_TYPES = ['audio/mpeg', 'audio/wav', 'audio/mp4', 'audio/aiff', 'audio/x-aiff']; 11 + // browser-compatible audio formats only 12 + // note: aiff/aif not supported in most browsers (safari only) 13 + const ACCEPTED_AUDIO_EXTENSIONS = ['.mp3', '.wav', '.m4a']; 14 + const ACCEPTED_AUDIO_MIME_TYPES = ['audio/mpeg', 'audio/wav', 'audio/mp4']; 13 15 const FILE_INPUT_ACCEPT = [...ACCEPTED_AUDIO_EXTENSIONS, ...ACCEPTED_AUDIO_MIME_TYPES].join(','); 14 16 15 17 function isSupportedAudioFile(name: string): boolean { 16 18 const dotIndex = name.lastIndexOf('.'); 17 19 if (dotIndex === -1) return false; 18 20 const ext = name.slice(dotIndex).toLowerCase(); 19 - if (ext === '.aif') { 20 - return true; 21 - } 22 21 return ACCEPTED_AUDIO_EXTENSIONS.includes(ext); 23 22 } 24 23 ··· 443 442 onchange={handleFileChange} 444 443 required 445 444 /> 445 + <p class="format-hint">supported: mp3, wav, m4a</p> 446 446 {#if file} 447 447 <p class="file-info">{file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)</p> 448 448 {/if} ··· 459 459 imageFile = target.files?.[0] ?? null; 460 460 }} 461 461 /> 462 + <p class="format-hint">supported: jpg, png, webp, gif</p> 462 463 {#if imageFile} 463 464 <p class="file-info">{imageFile.name} ({(imageFile.size / 1024 / 1024).toFixed(2)} MB)</p> 464 465 {/if} ··· 802 803 input[type='file']:disabled { 803 804 opacity: 0.5; 804 805 cursor: not-allowed; 806 + } 807 + 808 + .format-hint { 809 + margin-top: 0.25rem; 810 + font-size: 0.8rem; 811 + color: #888; 805 812 } 806 813 807 814 .file-info {
+188 -158
src/backend/api/tracks.py
··· 8 8 from pathlib import Path 9 9 from typing import Annotated 10 10 11 + import logfire 11 12 from fastapi import ( 12 13 APIRouter, 13 14 BackgroundTasks, ··· 90 91 image_filename: str | None = None, 91 92 ) -> None: 92 93 """background task to process upload.""" 93 - try: 94 - upload_tracker.update_status( 95 - upload_id, UploadStatus.PROCESSING, "processing upload..." 96 - ) 97 - 98 - # validate file type 99 - ext = Path(filename).suffix.lower() 100 - audio_format = AudioFormat.from_extension(ext) 101 - if not audio_format: 102 - upload_tracker.update_status( 103 - upload_id, 104 - UploadStatus.FAILED, 105 - "upload failed", 106 - error=f"unsupported file type: {ext}", 107 - ) 108 - return 109 - 110 - # save audio file 111 - upload_tracker.update_status( 112 - upload_id, UploadStatus.PROCESSING, "saving audio file..." 113 - ) 94 + with logfire.span( 95 + "process upload background", upload_id=upload_id, filename=filename 96 + ): 114 97 try: 115 - file_obj = BytesIO(file_data) 116 - file_id = await storage.save(file_obj, filename) 117 - except ValueError as e: 118 98 upload_tracker.update_status( 119 - upload_id, UploadStatus.FAILED, "upload failed", error=str(e) 120 - ) 121 - return 122 - 123 - # get R2 URL 124 - r2_url = None 125 - if settings.storage.backend == "r2": 126 - from backend.storage.r2 import R2Storage 127 - 128 - if isinstance(storage, R2Storage): 129 - r2_url = await storage.get_url(file_id) 130 - 131 - # save image if provided 132 - image_id = None 133 - image_url = None 134 - if image_data and image_filename: 135 - upload_tracker.update_status( 136 - upload_id, UploadStatus.PROCESSING, "saving image..." 99 + upload_id, UploadStatus.PROCESSING, "processing upload..." 137 100 ) 138 - image_format, is_valid = ImageFormat.validate_and_extract(image_filename) 139 - if is_valid and image_format: 140 - try: 141 - image_obj = BytesIO(image_data) 142 - # save with images/ prefix to namespace it 143 - image_id = await storage.save(image_obj, f"images/{image_filename}") 144 - # get R2 URL for image if using R2 storage 145 - if settings.storage.backend == "r2" and isinstance( 146 - storage, R2Storage 147 - ): 148 - image_url = await storage.get_url(image_id) 149 - except Exception as e: 150 - logger.warning(f"failed to save image: {e}", exc_info=True) 151 - # continue without image - it's optional 152 - else: 153 - logger.warning(f"unsupported image format: {image_filename}") 154 101 155 - # get artist and resolve features 156 - async with db_session() as db: 157 - result = await db.execute(select(Artist).where(Artist.did == artist_did)) 158 - artist = result.scalar_one_or_none() 159 - if not artist: 102 + # validate file type 103 + ext = Path(filename).suffix.lower() 104 + audio_format = AudioFormat.from_extension(ext) 105 + if not audio_format: 160 106 upload_tracker.update_status( 161 107 upload_id, 162 108 UploadStatus.FAILED, 163 109 "upload failed", 164 - error="artist profile not found", 110 + error=f"unsupported file type: {ext}", 165 111 ) 166 112 return 167 113 168 - # resolve featured artist handles 169 - featured_artists = [] 170 - if features: 114 + # save audio file 115 + upload_tracker.update_status( 116 + upload_id, UploadStatus.PROCESSING, "saving audio file..." 117 + ) 118 + try: 119 + logfire.info( 120 + "preparing to save audio file", 121 + filename=filename, 122 + file_data_size=len(file_data), 123 + ) 124 + file_obj = BytesIO(file_data) 125 + logfire.info("calling storage.save") 126 + file_id = await storage.save(file_obj, filename) 127 + logfire.info("storage.save completed", file_id=file_id) 128 + except ValueError as e: 129 + logfire.error("ValueError during storage.save", error=str(e)) 171 130 upload_tracker.update_status( 172 - upload_id, UploadStatus.PROCESSING, "resolving featured artists..." 131 + upload_id, UploadStatus.FAILED, "upload failed", error=str(e) 173 132 ) 174 - try: 175 - handles_list = json.loads(features) 176 - if isinstance(handles_list, list): 177 - for handle in handles_list: 178 - if ( 179 - isinstance(handle, str) 180 - and handle.lstrip("@") != artist.handle 181 - ): 182 - resolved = await resolve_handle(handle) 183 - if resolved: 184 - featured_artists.append(resolved) 185 - except json.JSONDecodeError: 186 - pass # ignore malformed features 187 - 188 - # create ATProto record 189 - atproto_uri = None 190 - atproto_cid = None 191 - if r2_url: 133 + return 134 + except Exception as e: 135 + logfire.error( 136 + "unexpected exception during storage.save", 137 + error=str(e), 138 + exc_info=True, 139 + ) 192 140 upload_tracker.update_status( 193 - upload_id, UploadStatus.PROCESSING, "creating atproto record..." 141 + upload_id, UploadStatus.FAILED, "upload failed", error=str(e) 194 142 ) 195 - try: 196 - result = await create_track_record( 197 - auth_session=auth_session, 198 - title=title, 199 - artist=artist.display_name, 200 - audio_url=r2_url, 201 - file_type=ext[1:], 202 - album=album, 203 - duration=None, 204 - features=featured_artists if featured_artists else None, 205 - image_url=image_url, 206 - ) 207 - if result: 208 - atproto_uri, atproto_cid = result 209 - except Exception as e: 210 - logger.warning( 211 - f"failed to create ATProto record: {e}", exc_info=True 212 - ) 143 + return 213 144 214 - # create track record 215 - upload_tracker.update_status( 216 - upload_id, UploadStatus.PROCESSING, "saving track metadata..." 217 - ) 218 - extra = {} 219 - if album: 220 - extra["album"] = album 145 + # get R2 URL 146 + r2_url = None 147 + if settings.storage.backend == "r2": 148 + from backend.storage.r2 import R2Storage 221 149 222 - track = Track( 223 - title=title, 224 - file_id=file_id, 225 - file_type=ext[1:], 226 - artist_did=artist_did, 227 - extra=extra, 228 - features=featured_artists, 229 - r2_url=r2_url, 230 - atproto_record_uri=atproto_uri, 231 - atproto_record_cid=atproto_cid, 232 - image_id=image_id, 233 - ) 150 + if isinstance(storage, R2Storage): 151 + r2_url = await storage.get_url(file_id) 234 152 235 - db.add(track) 236 - try: 237 - await db.commit() 238 - await db.refresh(track) 153 + # save image if provided 154 + image_id = None 155 + image_url = None 156 + if image_data and image_filename: 157 + upload_tracker.update_status( 158 + upload_id, UploadStatus.PROCESSING, "saving image..." 159 + ) 160 + image_format, is_valid = ImageFormat.validate_and_extract( 161 + image_filename 162 + ) 163 + if is_valid and image_format: 164 + try: 165 + image_obj = BytesIO(image_data) 166 + # save with images/ prefix to namespace it 167 + image_id = await storage.save( 168 + image_obj, f"images/{image_filename}" 169 + ) 170 + # get R2 URL for image if using R2 storage 171 + if settings.storage.backend == "r2" and isinstance( 172 + storage, R2Storage 173 + ): 174 + image_url = await storage.get_url(image_id) 175 + except Exception as e: 176 + logger.warning(f"failed to save image: {e}", exc_info=True) 177 + # continue without image - it's optional 178 + else: 179 + logger.warning(f"unsupported image format: {image_filename}") 239 180 240 - # send notification about new track 241 - from backend._internal.notifications import notification_service 181 + # get artist and resolve features 182 + async with db_session() as db: 183 + result = await db.execute( 184 + select(Artist).where(Artist.did == artist_did) 185 + ) 186 + artist = result.scalar_one_or_none() 187 + if not artist: 188 + upload_tracker.update_status( 189 + upload_id, 190 + UploadStatus.FAILED, 191 + "upload failed", 192 + error="artist profile not found", 193 + ) 194 + return 242 195 243 - try: 244 - # eagerly load artist for notification 245 - await db.refresh(track, ["artist"]) 246 - await notification_service.send_track_notification(track) 247 - except Exception as e: 248 - logger.warning( 249 - f"failed to send notification for track {track.id}: {e}" 196 + # resolve featured artist handles 197 + featured_artists = [] 198 + if features: 199 + upload_tracker.update_status( 200 + upload_id, 201 + UploadStatus.PROCESSING, 202 + "resolving featured artists...", 250 203 ) 204 + try: 205 + handles_list = json.loads(features) 206 + if isinstance(handles_list, list): 207 + for handle in handles_list: 208 + if ( 209 + isinstance(handle, str) 210 + and handle.lstrip("@") != artist.handle 211 + ): 212 + resolved = await resolve_handle(handle) 213 + if resolved: 214 + featured_artists.append(resolved) 215 + except json.JSONDecodeError: 216 + pass # ignore malformed features 251 217 218 + # create ATProto record 219 + atproto_uri = None 220 + atproto_cid = None 221 + if r2_url: 222 + upload_tracker.update_status( 223 + upload_id, UploadStatus.PROCESSING, "creating atproto record..." 224 + ) 225 + try: 226 + result = await create_track_record( 227 + auth_session=auth_session, 228 + title=title, 229 + artist=artist.display_name, 230 + audio_url=r2_url, 231 + file_type=ext[1:], 232 + album=album, 233 + duration=None, 234 + features=featured_artists if featured_artists else None, 235 + image_url=image_url, 236 + ) 237 + if result: 238 + atproto_uri, atproto_cid = result 239 + except Exception as e: 240 + logger.warning( 241 + f"failed to create ATProto record: {e}", exc_info=True 242 + ) 243 + 244 + # create track record 252 245 upload_tracker.update_status( 253 - upload_id, 254 - UploadStatus.COMPLETED, 255 - "upload completed successfully", 256 - track_id=track.id, 246 + upload_id, UploadStatus.PROCESSING, "saving track metadata..." 257 247 ) 248 + extra = {} 249 + if album: 250 + extra["album"] = album 258 251 259 - except IntegrityError as e: 260 - await db.rollback() 261 - # integrity errors now only occur for foreign key violations or other constraints 262 - error_msg = f"database constraint violation: {e!s}" 263 - upload_tracker.update_status( 264 - upload_id, UploadStatus.FAILED, "upload failed", error=error_msg 252 + track = Track( 253 + title=title, 254 + file_id=file_id, 255 + file_type=ext[1:], 256 + artist_did=artist_did, 257 + extra=extra, 258 + features=featured_artists, 259 + r2_url=r2_url, 260 + atproto_record_uri=atproto_uri, 261 + atproto_record_cid=atproto_cid, 262 + image_id=image_id, 265 263 ) 266 - # cleanup: delete uploaded file 267 - with contextlib.suppress(Exception): 268 - await storage.delete(file_id) 269 264 270 - except Exception as e: 271 - logger.exception(f"upload {upload_id} failed with unexpected error") 272 - upload_tracker.update_status( 273 - upload_id, 274 - UploadStatus.FAILED, 275 - "upload failed", 276 - error=f"unexpected error: {e!s}", 277 - ) 265 + db.add(track) 266 + try: 267 + await db.commit() 268 + await db.refresh(track) 269 + 270 + # send notification about new track 271 + from backend._internal.notifications import notification_service 272 + 273 + try: 274 + # eagerly load artist for notification 275 + await db.refresh(track, ["artist"]) 276 + await notification_service.send_track_notification(track) 277 + except Exception as e: 278 + logger.warning( 279 + f"failed to send notification for track {track.id}: {e}" 280 + ) 281 + 282 + upload_tracker.update_status( 283 + upload_id, 284 + UploadStatus.COMPLETED, 285 + "upload completed successfully", 286 + track_id=track.id, 287 + ) 288 + 289 + except IntegrityError as e: 290 + await db.rollback() 291 + # integrity errors now only occur for foreign key violations or other constraints 292 + error_msg = f"database constraint violation: {e!s}" 293 + upload_tracker.update_status( 294 + upload_id, UploadStatus.FAILED, "upload failed", error=error_msg 295 + ) 296 + # cleanup: delete uploaded file 297 + with contextlib.suppress(Exception): 298 + await storage.delete(file_id) 299 + 300 + except Exception as e: 301 + logger.exception(f"upload {upload_id} failed with unexpected error") 302 + upload_tracker.update_status( 303 + upload_id, 304 + UploadStatus.FAILED, 305 + "upload failed", 306 + error=f"unexpected error: {e!s}", 307 + ) 278 308 279 309 280 310 @router.post("/")
-5
src/backend/models/audio.py
··· 9 9 MP3 = "mp3" 10 10 WAV = "wav" 11 11 M4A = "m4a" 12 - AIFF = "aiff" 13 12 14 13 @property 15 14 def extension(self) -> str: ··· 23 22 AudioFormat.MP3: "audio/mpeg", 24 23 AudioFormat.WAV: "audio/wav", 25 24 AudioFormat.M4A: "audio/mp4", 26 - AudioFormat.AIFF: "audio/aiff", 27 25 } 28 26 return media_types[self] 29 27 ··· 31 29 def from_extension(cls, ext: str) -> "AudioFormat | None": 32 30 """get format from file extension (with or without dot).""" 33 31 ext = ext.lower().lstrip(".") 34 - # handle .aif as alias for .aiff 35 - if ext == "aif": 36 - ext = "aiff" 37 32 for format in cls: 38 33 if format.value == ext: 39 34 return format
+56 -38
src/backend/storage/r2.py
··· 5 5 6 6 import aioboto3 7 7 import boto3 8 + import logfire 8 9 from botocore.config import Config 9 10 10 11 from backend.config import settings ··· 59 60 60 61 supports both audio and image files. 61 62 """ 62 - # compute hash in chunks (constant memory) 63 - file_id = hash_file_chunked(file)[:16] 63 + with logfire.span("R2 save", filename=filename): 64 + # compute hash in chunks (constant memory) 65 + file_id = hash_file_chunked(file)[:16] 66 + logfire.info("computed file hash", file_id=file_id) 64 67 65 - # determine file extension and type 66 - ext = Path(filename).suffix.lower() 68 + # determine file extension and type 69 + ext = Path(filename).suffix.lower() 67 70 68 - # try audio format first 69 - audio_format = AudioFormat.from_extension(ext) 70 - if audio_format: 71 - key = f"audio/{file_id}{ext}" 72 - media_type = audio_format.media_type 73 - image_format = None 74 - else: 75 - # try image format 76 - from backend.models.image import ImageFormat 77 - 78 - image_format, is_valid = ImageFormat.validate_and_extract(filename) 79 - if is_valid and image_format: 80 - key = f"{file_id}{ext}" 81 - media_type = image_format.media_type 71 + # try audio format first 72 + audio_format = AudioFormat.from_extension(ext) 73 + if audio_format: 74 + key = f"audio/{file_id}{ext}" 75 + media_type = audio_format.media_type 76 + image_format = None 82 77 else: 83 - raise ValueError( 84 - f"unsupported file type: {ext}. " 85 - f"supported audio: {AudioFormat.supported_extensions_str()}, " 86 - f"supported image: jpg, jpeg, png, webp, gif" 87 - ) 78 + # try image format 79 + from backend.models.image import ImageFormat 88 80 89 - # stream upload to R2 (constant memory, non-blocking) 90 - # file pointer already reset by hash_file_chunked 91 - bucket = self.image_bucket_name if image_format else self.audio_bucket_name 92 - async with self.async_session.client( 93 - "s3", 94 - endpoint_url=self.endpoint_url, 95 - aws_access_key_id=self.aws_access_key_id, 96 - aws_secret_access_key=self.aws_secret_access_key, 97 - ) as client: 98 - await client.upload_fileobj( 99 - Fileobj=file, 100 - Bucket=bucket, 101 - Key=key, 102 - ExtraArgs={"ContentType": media_type}, 81 + image_format, is_valid = ImageFormat.validate_and_extract(filename) 82 + if is_valid and image_format: 83 + key = f"{file_id}{ext}" 84 + media_type = image_format.media_type 85 + else: 86 + raise ValueError( 87 + f"unsupported file type: {ext}. " 88 + f"supported audio: {AudioFormat.supported_extensions_str()}, " 89 + f"supported image: jpg, jpeg, png, webp, gif" 90 + ) 91 + 92 + # stream upload to R2 (constant memory, non-blocking) 93 + # file pointer already reset by hash_file_chunked 94 + bucket = self.image_bucket_name if image_format else self.audio_bucket_name 95 + logfire.info( 96 + "uploading to R2", bucket=bucket, key=key, media_type=media_type 103 97 ) 104 98 105 - return file_id 99 + try: 100 + async with self.async_session.client( 101 + "s3", 102 + endpoint_url=self.endpoint_url, 103 + aws_access_key_id=self.aws_access_key_id, 104 + aws_secret_access_key=self.aws_secret_access_key, 105 + ) as client: 106 + await client.upload_fileobj( 107 + Fileobj=file, 108 + Bucket=bucket, 109 + Key=key, 110 + ExtraArgs={"ContentType": media_type}, 111 + ) 112 + except Exception as e: 113 + logfire.error( 114 + "R2 upload failed", 115 + error=str(e), 116 + bucket=bucket, 117 + key=key, 118 + exc_info=True, 119 + ) 120 + raise 121 + 122 + logfire.info("R2 upload complete", file_id=file_id, key=key) 123 + return file_id 106 124 107 125 async def get_url(self, file_id: str) -> str | None: 108 126 """get public URL for media file (audio or image)."""
+5 -12
tests/test_audio_formats.py
··· 23 23 (".m4a", AudioFormat.M4A), 24 24 ("m4a", AudioFormat.M4A), 25 25 (".M4A", AudioFormat.M4A), 26 - # aiff 27 - (".aiff", AudioFormat.AIFF), 28 - ("aiff", AudioFormat.AIFF), 29 - (".AIFF", AudioFormat.AIFF), 30 - # aif (alias for aiff) 31 - (".aif", AudioFormat.AIFF), 32 - ("aif", AudioFormat.AIFF), 33 - (".AIF", AudioFormat.AIFF), 34 26 ], 35 27 ) 36 28 def test_from_extension_supported( ··· 46 38 ".ogg", 47 39 ".aac", 48 40 ".wma", 41 + ".aiff", 42 + ".aif", 49 43 "", 50 44 "invalid", 51 45 ], ··· 59 53 assert AudioFormat.MP3.media_type == "audio/mpeg" 60 54 assert AudioFormat.WAV.media_type == "audio/wav" 61 55 assert AudioFormat.M4A.media_type == "audio/mp4" 62 - assert AudioFormat.AIFF.media_type == "audio/aiff" 63 56 64 57 def test_extensions_with_dots(self): 65 58 """test extension property includes dots.""" 66 59 assert AudioFormat.MP3.extension == ".mp3" 67 60 assert AudioFormat.WAV.extension == ".wav" 68 61 assert AudioFormat.M4A.extension == ".m4a" 69 - assert AudioFormat.AIFF.extension == ".aiff" 70 62 71 63 def test_all_extensions(self): 72 64 """test all_extensions returns complete list.""" ··· 74 66 assert ".mp3" in extensions 75 67 assert ".wav" in extensions 76 68 assert ".m4a" in extensions 77 - assert ".aiff" in extensions 78 - assert len(extensions) == 4 69 + assert ".aiff" not in extensions 70 + assert ".aif" not in extensions 71 + assert len(extensions) == 3 79 72 80 73 def test_supported_extensions_str(self): 81 74 """test formatted string of supported extensions."""