spatelier 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. analytics/__init__.py +1 -0
  2. analytics/reporter.py +497 -0
  3. cli/__init__.py +1 -0
  4. cli/app.py +147 -0
  5. cli/audio.py +129 -0
  6. cli/cli_analytics.py +320 -0
  7. cli/cli_utils.py +282 -0
  8. cli/error_handlers.py +122 -0
  9. cli/files.py +299 -0
  10. cli/update.py +325 -0
  11. cli/video.py +823 -0
  12. cli/worker.py +615 -0
  13. core/__init__.py +1 -0
  14. core/analytics_dashboard.py +368 -0
  15. core/base.py +303 -0
  16. core/base_service.py +69 -0
  17. core/config.py +345 -0
  18. core/database_service.py +116 -0
  19. core/decorators.py +263 -0
  20. core/error_handler.py +210 -0
  21. core/file_tracker.py +254 -0
  22. core/interactive_cli.py +366 -0
  23. core/interfaces.py +166 -0
  24. core/job_queue.py +437 -0
  25. core/logger.py +79 -0
  26. core/package_updater.py +469 -0
  27. core/progress.py +228 -0
  28. core/service_factory.py +295 -0
  29. core/streaming.py +299 -0
  30. core/worker.py +765 -0
  31. database/__init__.py +1 -0
  32. database/connection.py +265 -0
  33. database/metadata.py +516 -0
  34. database/models.py +288 -0
  35. database/repository.py +592 -0
  36. database/transcription_storage.py +219 -0
  37. modules/__init__.py +1 -0
  38. modules/audio/__init__.py +5 -0
  39. modules/audio/converter.py +197 -0
  40. modules/video/__init__.py +16 -0
  41. modules/video/converter.py +191 -0
  42. modules/video/fallback_extractor.py +334 -0
  43. modules/video/services/__init__.py +18 -0
  44. modules/video/services/audio_extraction_service.py +274 -0
  45. modules/video/services/download_service.py +852 -0
  46. modules/video/services/metadata_service.py +190 -0
  47. modules/video/services/playlist_service.py +445 -0
  48. modules/video/services/transcription_service.py +491 -0
  49. modules/video/transcription_service.py +385 -0
  50. modules/video/youtube_api.py +397 -0
  51. spatelier/__init__.py +33 -0
  52. spatelier-0.3.0.dist-info/METADATA +260 -0
  53. spatelier-0.3.0.dist-info/RECORD +59 -0
  54. spatelier-0.3.0.dist-info/WHEEL +5 -0
  55. spatelier-0.3.0.dist-info/entry_points.txt +2 -0
  56. spatelier-0.3.0.dist-info/licenses/LICENSE +21 -0
  57. spatelier-0.3.0.dist-info/top_level.txt +7 -0
  58. utils/__init__.py +1 -0
  59. utils/helpers.py +250 -0
@@ -0,0 +1,334 @@
1
+ """
2
+ Fallback URL extractor for video downloads.
3
+
4
+ This module provides fallback functionality when yt-dlp fails to download a video.
5
+ It attempts to extract video URLs directly from the webpage source code.
6
+ """
7
+
8
+ import re
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Dict, List, Optional, Tuple
12
+ from urllib.parse import urljoin, urlparse
13
+
14
+ try:
15
+ import requests
16
+ from bs4 import BeautifulSoup
17
+ except ImportError: # pragma: no cover - optional dependency
18
+ requests = None
19
+ BeautifulSoup = None
20
+
21
+ from loguru import logger
22
+
23
+ from core.config import Config
24
+
25
+
26
+ class FallbackExtractor:
27
+ """
28
+ Fallback video URL extractor.
29
+
30
+ Extracts video URLs from webpage source when yt-dlp fails.
31
+ Includes safety limits to prevent runaway downloads.
32
+ """
33
+
34
+ def __init__(self, config: Config):
35
+ """
36
+ Initialize the fallback extractor.
37
+
38
+ Args:
39
+ config: Main configuration instance
40
+ """
41
+ if requests is None or BeautifulSoup is None:
42
+ raise RuntimeError(
43
+ "Fallback extraction requires 'web' dependencies. Install the 'web' extra to enable it."
44
+ )
45
+ self.config = config
46
+
47
+ # Use flattened config properties
48
+ self.max_files = config.fallback_max_files
49
+ self.max_total_size_mb = (
50
+ 1000 * 1024 * 1024
51
+ ) # 1GB default (fallback_max_total_size_mb removed, use constant)
52
+ self.timeout = config.fallback_timeout_seconds
53
+
54
+ self.session = requests.Session()
55
+ self.session.headers.update(
56
+ {
57
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
58
+ }
59
+ )
60
+
61
+ # Video file extensions to look for
62
+ self.video_extensions = set(config.video_extensions)
63
+
64
+ # Common video URL patterns
65
+ extensions_pattern = "|".join(config.video_extensions).replace(".", "")
66
+ self.video_patterns = [
67
+ f'https?://[^"\\s]+\\.(?:{extensions_pattern})(?:\\?[^"\\s]*)?',
68
+ f'https?://[^"\\s]*video[^"\\s]*\\.(?:{extensions_pattern})(?:\\?[^"\\s]*)?',
69
+ f'https?://[^"\\s]*stream[^"\\s]*\\.(?:{extensions_pattern})(?:\\?[^"\\s]*)?',
70
+ ]
71
+
72
+ def extract_video_urls(self, url: str) -> List[str]:
73
+ """
74
+ Extract potential video URLs from a webpage.
75
+
76
+ Args:
77
+ url: The webpage URL to analyze
78
+
79
+ Returns:
80
+ List of potential video URLs
81
+ """
82
+ try:
83
+ logger.info(f"Extracting video URLs from: {url}")
84
+
85
+ # Get the webpage content
86
+ response = self.session.get(url, timeout=self.timeout)
87
+ response.raise_for_status()
88
+
89
+ # Parse HTML
90
+ soup = BeautifulSoup(response.content, "html.parser")
91
+
92
+ # Find video URLs using multiple methods
93
+ video_urls = set()
94
+
95
+ # Method 1: Look for direct video links in href/src attributes
96
+ for tag in soup.find_all(["a", "source", "video"]):
97
+ for attr in ["href", "src", "data-src"]:
98
+ if tag.get(attr):
99
+ full_url = urljoin(url, tag[attr])
100
+ if self._is_video_url(full_url):
101
+ video_urls.add(full_url)
102
+
103
+ # Method 2: Search for video URLs in script tags and page content
104
+ page_text = response.text
105
+ for pattern in self.video_patterns:
106
+ matches = re.findall(pattern, page_text, re.IGNORECASE)
107
+ for match in matches:
108
+ full_url = urljoin(url, match)
109
+ if self._is_video_url(full_url):
110
+ video_urls.add(full_url)
111
+
112
+ # Method 3: Look for common video hosting patterns
113
+ video_urls.update(self._extract_hosting_urls(soup, url))
114
+
115
+ logger.info(f"Found {len(video_urls)} potential video URLs")
116
+ return list(video_urls)
117
+
118
+ except Exception as e:
119
+ logger.error(f"Failed to extract video URLs from {url}: {e}")
120
+ return []
121
+
122
+ def _is_video_url(self, url: str) -> bool:
123
+ """Check if a URL points to a video file."""
124
+ try:
125
+ parsed = urlparse(url)
126
+ path = Path(parsed.path)
127
+
128
+ # Check file extension
129
+ if path.suffix.lower() in self.video_extensions:
130
+ return True
131
+
132
+ # Check for common video hosting domains
133
+ video_domains = {
134
+ "youtube.com",
135
+ "youtu.be",
136
+ "vimeo.com",
137
+ "dailymotion.com",
138
+ "twitch.tv",
139
+ "streamable.com",
140
+ "gfycat.com",
141
+ }
142
+
143
+ if any(domain in parsed.netloc.lower() for domain in video_domains):
144
+ return True
145
+
146
+ return False
147
+
148
+ except Exception:
149
+ return False
150
+
151
+ def _extract_hosting_urls(self, soup: BeautifulSoup, base_url: str) -> set:
152
+ """Extract URLs from common video hosting platforms."""
153
+ urls = set()
154
+
155
+ # Look for iframe sources (common for embedded videos)
156
+ for iframe in soup.find_all("iframe"):
157
+ src = iframe.get("src")
158
+ if src:
159
+ full_url = urljoin(base_url, src)
160
+ urls.add(full_url)
161
+
162
+ # Look for video elements
163
+ for video in soup.find_all("video"):
164
+ for source in video.find_all("source"):
165
+ src = source.get("src")
166
+ if src:
167
+ full_url = urljoin(base_url, src)
168
+ urls.add(full_url)
169
+
170
+ return urls
171
+
172
+ def download_video(
173
+ self, video_url: str, output_path: Path
174
+ ) -> Tuple[bool, str, int]:
175
+ """
176
+ Download a video from URL with safety checks.
177
+
178
+ Args:
179
+ video_url: URL of the video to download
180
+ output_path: Path to save the video
181
+
182
+ Returns:
183
+ Tuple of (success, message, file_size_bytes)
184
+ """
185
+ try:
186
+ logger.info(f"Attempting to download: {video_url}")
187
+
188
+ # Get file info first (HEAD request)
189
+ head_response = self.session.head(video_url, timeout=self.timeout)
190
+ head_response.raise_for_status()
191
+
192
+ # Check content type
193
+ content_type = head_response.headers.get("content-type", "").lower()
194
+ if not any(
195
+ video_type in content_type
196
+ for video_type in ["video/", "application/octet-stream"]
197
+ ):
198
+ return (
199
+ False,
200
+ f"URL does not appear to be a video (content-type: {content_type})",
201
+ 0,
202
+ )
203
+
204
+ # Check file size
205
+ content_length = head_response.headers.get("content-length")
206
+ if content_length:
207
+ file_size = int(content_length)
208
+ if file_size > self.max_total_size_mb:
209
+ return (
210
+ False,
211
+ f"File too large: {file_size / (1024*1024):.1f}MB",
212
+ file_size,
213
+ )
214
+ else:
215
+ # If we can't determine size, we'll check during download
216
+ file_size = 0
217
+
218
+ # Download the file
219
+ response = self.session.get(video_url, stream=True, timeout=self.timeout)
220
+ response.raise_for_status()
221
+
222
+ # Ensure output directory exists
223
+ output_path.parent.mkdir(parents=True, exist_ok=True)
224
+
225
+ # Download with size checking
226
+ downloaded_size = 0
227
+ with open(output_path, "wb") as f:
228
+ for chunk in response.iter_content(chunk_size=8192):
229
+ if chunk:
230
+ f.write(chunk)
231
+ downloaded_size += len(chunk)
232
+
233
+ # Check size limit during download
234
+ if downloaded_size > self.max_total_size_mb:
235
+ output_path.unlink() # Remove partial file
236
+ return (
237
+ False,
238
+ f"Download exceeded size limit: {downloaded_size / (1024*1024):.1f}MB",
239
+ downloaded_size,
240
+ )
241
+
242
+ logger.info(
243
+ f"Successfully downloaded: {output_path} ({downloaded_size / (1024*1024):.1f}MB)"
244
+ )
245
+ return True, "Download successful", downloaded_size
246
+
247
+ except Exception as e:
248
+ logger.error(f"Failed to download {video_url}: {e}")
249
+ return False, str(e), 0
250
+
251
+ def fallback_download(self, url: str, output_dir: Path) -> List[Dict]:
252
+ """
253
+ Attempt fallback download when yt-dlp fails.
254
+
255
+ Args:
256
+ url: Original URL that failed
257
+ output_dir: Directory to save downloaded files
258
+
259
+ Returns:
260
+ List of download results with success status and file paths
261
+ """
262
+ logger.info(f"Starting fallback download for: {url}")
263
+
264
+ # Extract potential video URLs
265
+ video_urls = self.extract_video_urls(url)
266
+
267
+ if not video_urls:
268
+ logger.warning("No video URLs found in fallback extraction")
269
+ return [
270
+ {"success": False, "message": "No video URLs found", "file_path": None}
271
+ ]
272
+
273
+ # Limit number of URLs to try
274
+ video_urls = video_urls[: self.max_files]
275
+ logger.info(f"Attempting to download {len(video_urls)} potential videos")
276
+
277
+ results = []
278
+ total_downloaded = 0
279
+
280
+ for i, video_url in enumerate(video_urls):
281
+ if total_downloaded >= self.max_total_size_mb:
282
+ logger.warning(
283
+ f"Reached total size limit: {total_downloaded / (1024*1024):.1f}MB"
284
+ )
285
+ break
286
+
287
+ # Create output filename
288
+ parsed_url = urlparse(video_url)
289
+ filename = Path(parsed_url.path).name
290
+ if not filename or "." not in filename:
291
+ filename = f"video_{i+1}.mp4"
292
+
293
+ output_path = output_dir / filename
294
+
295
+ # Skip if file already exists
296
+ if output_path.exists():
297
+ logger.info(f"File already exists, skipping: {output_path}")
298
+ results.append(
299
+ {
300
+ "success": True,
301
+ "message": "File already exists",
302
+ "file_path": output_path,
303
+ "size": output_path.stat().st_size,
304
+ }
305
+ )
306
+ continue
307
+
308
+ # Attempt download
309
+ success, message, file_size = self.download_video(video_url, output_path)
310
+
311
+ results.append(
312
+ {
313
+ "success": success,
314
+ "message": message,
315
+ "file_path": output_path if success else None,
316
+ "size": file_size,
317
+ }
318
+ )
319
+
320
+ if success:
321
+ total_downloaded += file_size
322
+ logger.info(f"Downloaded {i+1}/{len(video_urls)}: {output_path}")
323
+ else:
324
+ logger.warning(f"Failed to download {i+1}/{len(video_urls)}: {message}")
325
+
326
+ # Small delay between downloads
327
+ time.sleep(1)
328
+
329
+ successful_downloads = [r for r in results if r["success"]]
330
+ logger.info(
331
+ f"Fallback download completed: {len(successful_downloads)}/{len(results)} successful"
332
+ )
333
+
334
+ return results
@@ -0,0 +1,18 @@
1
+ """
2
+ Video services module.
3
+
4
+ This module provides focused services for video processing,
5
+ separated by concern for better maintainability.
6
+ """
7
+
8
+ from .download_service import VideoDownloadService
9
+ from .metadata_service import MetadataService
10
+ from .playlist_service import PlaylistService
11
+ from .transcription_service import TranscriptionService
12
+
13
+ __all__ = [
14
+ "VideoDownloadService",
15
+ "MetadataService",
16
+ "TranscriptionService",
17
+ "PlaylistService",
18
+ ]
@@ -0,0 +1,274 @@
1
+ """
2
+ Audio extraction service for YouTube videos.
3
+
4
+ This service provides clean, object-oriented audio extraction from YouTube videos
5
+ with proper separation of concerns and error handling.
6
+ """
7
+
8
+ import shutil
9
+ import tempfile
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Optional
12
+
13
+ from core.base import ProcessingResult
14
+ from core.base_service import BaseService
15
+ from core.progress import track_progress
16
+ from modules.audio.converter import AudioConverter
17
+ from modules.video.services.download_service import VideoDownloadService
18
+
19
+
20
+ class AudioExtractionService(BaseService):
21
+ """
22
+ Service for extracting audio from YouTube videos.
23
+
24
+ Provides clean, object-oriented audio extraction with proper error handling,
25
+ progress tracking, and resource management.
26
+ """
27
+
28
+ def __init__(self, config, verbose: bool = False, db_service=None):
29
+ """
30
+ Initialize audio extraction service.
31
+
32
+ Args:
33
+ config: Configuration instance
34
+ verbose: Enable verbose logging
35
+ db_service: Optional database service instance
36
+ """
37
+ super().__init__(config, verbose, db_service)
38
+ self.logger = self.logger.bind(service="AudioExtractionService")
39
+
40
+ # Initialize dependencies
41
+ self._audio_converter: Optional[AudioConverter] = None
42
+ self._download_service: Optional[VideoDownloadService] = None
43
+
44
+ @property
45
+ def audio_converter(self) -> AudioConverter:
46
+ """Get audio converter service (lazy initialization)."""
47
+ if self._audio_converter is None:
48
+ self._audio_converter = AudioConverter(
49
+ self.config, verbose=self.verbose, db_service=self.db_factory
50
+ )
51
+ return self._audio_converter
52
+
53
+ @property
54
+ def download_service(self) -> VideoDownloadService:
55
+ """Get video download service (lazy initialization)."""
56
+ if self._download_service is None:
57
+ self._download_service = VideoDownloadService(
58
+ self.config, verbose=self.verbose, db_service=self.db_factory
59
+ )
60
+ return self._download_service
61
+
62
+ def extract_audio_from_url(
63
+ self, url: str, output_dir: Path, format: str = "mp3", bitrate: int = 320
64
+ ) -> ProcessingResult:
65
+ """
66
+ Extract audio from YouTube URL.
67
+
68
+ Args:
69
+ url: YouTube video URL
70
+ output_dir: Output directory for audio file
71
+ format: Audio format (mp3, wav, flac, aac, ogg, m4a)
72
+ bitrate: Audio bitrate in kbps
73
+
74
+ Returns:
75
+ ProcessingResult with extraction details
76
+ """
77
+ self.logger.info(f"Starting audio extraction from URL: {url}")
78
+
79
+ # Validate inputs
80
+ validation_result = self._validate_inputs(url, output_dir, format, bitrate)
81
+ if not validation_result.is_successful():
82
+ return validation_result
83
+
84
+ # Create output directory
85
+ output_dir.mkdir(parents=True, exist_ok=True)
86
+
87
+ temp_dir = None
88
+ try:
89
+ with track_progress(
90
+ "Extracting audio from video...", verbose=self.verbose
91
+ ) as progress:
92
+ # Step 1: Download video to temporary location
93
+ temp_dir = self._create_temp_directory()
94
+ download_result = self._download_video(url, temp_dir)
95
+
96
+ if not download_result.is_successful():
97
+ return ProcessingResult.fail(
98
+ f"Failed to download video: {download_result.message}",
99
+ errors=download_result.errors,
100
+ )
101
+
102
+ progress.update(0.3, "Video downloaded, extracting audio...")
103
+
104
+ # Step 2: Find downloaded file
105
+ input_file = self._find_downloaded_file(temp_dir)
106
+ if not input_file:
107
+ return ProcessingResult.fail("No audio file found after download")
108
+
109
+ progress.update(0.2, "Converting audio format...")
110
+
111
+ # Step 3: Convert to desired format
112
+ output_file = self._generate_output_path(input_file, output_dir, format)
113
+ conversion_result = self._convert_audio(
114
+ input_file, output_file, format, bitrate
115
+ )
116
+
117
+ if not conversion_result.is_successful():
118
+ return ProcessingResult.fail(
119
+ f"Audio conversion failed: {conversion_result.message}",
120
+ errors=conversion_result.errors,
121
+ )
122
+
123
+ progress.update(0.5, "Audio extraction completed!")
124
+
125
+ # Step 4: Prepare success result
126
+ return self._create_success_result(output_file, format, bitrate)
127
+
128
+ except Exception as e:
129
+ self.logger.error(f"Audio extraction failed: {e}")
130
+ return ProcessingResult.fail(f"Audio extraction failed: {e}")
131
+
132
+ finally:
133
+ # Clean up temporary directory
134
+ if temp_dir and temp_dir.exists():
135
+ self._cleanup_temp_directory(temp_dir)
136
+
137
+ def _validate_inputs(
138
+ self, url: str, output_dir: Path, format: str, bitrate: int
139
+ ) -> ProcessingResult:
140
+ """Validate input parameters."""
141
+ errors = []
142
+
143
+ if not url or not url.strip():
144
+ errors.append("URL cannot be empty")
145
+
146
+ if not url.startswith(("http://", "https://")):
147
+ errors.append("URL must start with http:// or https://")
148
+
149
+ if format.lower() not in ["mp3", "wav", "flac", "aac", "ogg", "m4a"]:
150
+ errors.append(f"Unsupported format: {format}")
151
+
152
+ if bitrate < 64 or bitrate > 512:
153
+ errors.append("Bitrate must be between 64 and 512 kbps")
154
+
155
+ if errors:
156
+ return ProcessingResult.fail("Input validation failed", errors=errors)
157
+
158
+ return ProcessingResult.success("Input validation passed")
159
+
160
+ def _create_temp_directory(self) -> Path:
161
+ """Create temporary directory for processing."""
162
+ temp_dir = Path(tempfile.mkdtemp(prefix="spatelier_audio_"))
163
+ self.logger.debug(f"Created temp directory: {temp_dir}")
164
+ return temp_dir
165
+
166
+ def _download_video(self, url: str, temp_dir: Path) -> ProcessingResult:
167
+ """Download video to temporary directory."""
168
+ try:
169
+ self.logger.info(f"Downloading video to: {temp_dir}")
170
+
171
+ result = self.download_service.download_video(
172
+ url=url,
173
+ output_path=temp_dir,
174
+ quality="bestaudio", # Get best audio quality
175
+ format="bestaudio/best", # Prefer audio-only formats
176
+ )
177
+
178
+ return result
179
+
180
+ except Exception as e:
181
+ self.logger.error(f"Video download failed: {e}")
182
+ return ProcessingResult.fail(f"Video download failed: {e}")
183
+
184
+ def _find_downloaded_file(self, temp_dir: Path) -> Optional[Path]:
185
+ """Find the downloaded audio/video file."""
186
+ # Look for common audio/video extensions
187
+ extensions = [".mp4", ".webm", ".mkv", ".avi", ".mov", ".m4a", ".mp3", ".wav"]
188
+
189
+ for ext in extensions:
190
+ files = list(temp_dir.glob(f"*{ext}"))
191
+ if files:
192
+ file_path = files[0]
193
+ self.logger.info(f"Found downloaded file: {file_path}")
194
+ return file_path
195
+
196
+ self.logger.warning("No audio/video file found after download")
197
+ return None
198
+
199
+ def _generate_output_path(
200
+ self, input_file: Path, output_dir: Path, format: str
201
+ ) -> Path:
202
+ """Generate output file path."""
203
+ output_filename = f"{input_file.stem}.{format}"
204
+ return output_dir / output_filename
205
+
206
+ def _convert_audio(
207
+ self, input_file: Path, output_file: Path, format: str, bitrate: int
208
+ ) -> ProcessingResult:
209
+ """Convert audio to desired format."""
210
+ try:
211
+ self.logger.info(f"Converting audio: {input_file} -> {output_file}")
212
+
213
+ result = self.audio_converter.convert(
214
+ input_path=input_file,
215
+ output_path=output_file,
216
+ bitrate=bitrate,
217
+ format=format,
218
+ )
219
+
220
+ return result
221
+
222
+ except Exception as e:
223
+ self.logger.error(f"Audio conversion failed: {e}")
224
+ return ProcessingResult.fail(f"Audio conversion failed: {e}")
225
+
226
+ def _create_success_result(
227
+ self, output_file: Path, format: str, bitrate: int
228
+ ) -> ProcessingResult:
229
+ """Create success result with metadata."""
230
+ try:
231
+ file_size = output_file.stat().st_size
232
+ file_size_mb = file_size / (1024 * 1024)
233
+
234
+ metadata = {
235
+ "file_size": file_size,
236
+ "file_size_mb": file_size_mb,
237
+ "format": format,
238
+ "bitrate": bitrate,
239
+ "output_path": str(output_file),
240
+ }
241
+
242
+ return ProcessingResult.success(
243
+ message=f"Audio extracted successfully: {output_file.name}",
244
+ output_path=output_file,
245
+ metadata=metadata,
246
+ )
247
+
248
+ except Exception as e:
249
+ self.logger.error(f"Failed to create success result: {e}")
250
+ return ProcessingResult.fail(f"Failed to create success result: {e}")
251
+
252
+ def _cleanup_temp_directory(self, temp_dir: Path):
253
+ """Clean up temporary directory."""
254
+ try:
255
+ if temp_dir.exists():
256
+ shutil.rmtree(temp_dir)
257
+ self.logger.debug(f"Cleaned up temp directory: {temp_dir}")
258
+ except Exception as e:
259
+ self.logger.warning(f"Failed to cleanup temp directory {temp_dir}: {e}")
260
+
261
+ def get_supported_formats(self) -> list[str]:
262
+ """Get list of supported audio formats."""
263
+ return ["mp3", "wav", "flac", "aac", "ogg", "m4a"]
264
+
265
+ def get_supported_bitrates(self) -> Dict[str, Any]:
266
+ """Get supported bitrate ranges by format."""
267
+ return {
268
+ "mp3": {"min": 64, "max": 320, "default": 320},
269
+ "wav": {"min": 128, "max": 1536, "default": 1411},
270
+ "flac": {"min": 128, "max": 1536, "default": 1411},
271
+ "aac": {"min": 64, "max": 320, "default": 256},
272
+ "ogg": {"min": 64, "max": 320, "default": 192},
273
+ "m4a": {"min": 64, "max": 320, "default": 256},
274
+ }