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.
- analytics/__init__.py +1 -0
- analytics/reporter.py +497 -0
- cli/__init__.py +1 -0
- cli/app.py +147 -0
- cli/audio.py +129 -0
- cli/cli_analytics.py +320 -0
- cli/cli_utils.py +282 -0
- cli/error_handlers.py +122 -0
- cli/files.py +299 -0
- cli/update.py +325 -0
- cli/video.py +823 -0
- cli/worker.py +615 -0
- core/__init__.py +1 -0
- core/analytics_dashboard.py +368 -0
- core/base.py +303 -0
- core/base_service.py +69 -0
- core/config.py +345 -0
- core/database_service.py +116 -0
- core/decorators.py +263 -0
- core/error_handler.py +210 -0
- core/file_tracker.py +254 -0
- core/interactive_cli.py +366 -0
- core/interfaces.py +166 -0
- core/job_queue.py +437 -0
- core/logger.py +79 -0
- core/package_updater.py +469 -0
- core/progress.py +228 -0
- core/service_factory.py +295 -0
- core/streaming.py +299 -0
- core/worker.py +765 -0
- database/__init__.py +1 -0
- database/connection.py +265 -0
- database/metadata.py +516 -0
- database/models.py +288 -0
- database/repository.py +592 -0
- database/transcription_storage.py +219 -0
- modules/__init__.py +1 -0
- modules/audio/__init__.py +5 -0
- modules/audio/converter.py +197 -0
- modules/video/__init__.py +16 -0
- modules/video/converter.py +191 -0
- modules/video/fallback_extractor.py +334 -0
- modules/video/services/__init__.py +18 -0
- modules/video/services/audio_extraction_service.py +274 -0
- modules/video/services/download_service.py +852 -0
- modules/video/services/metadata_service.py +190 -0
- modules/video/services/playlist_service.py +445 -0
- modules/video/services/transcription_service.py +491 -0
- modules/video/transcription_service.py +385 -0
- modules/video/youtube_api.py +397 -0
- spatelier/__init__.py +33 -0
- spatelier-0.3.0.dist-info/METADATA +260 -0
- spatelier-0.3.0.dist-info/RECORD +59 -0
- spatelier-0.3.0.dist-info/WHEEL +5 -0
- spatelier-0.3.0.dist-info/entry_points.txt +2 -0
- spatelier-0.3.0.dist-info/licenses/LICENSE +21 -0
- spatelier-0.3.0.dist-info/top_level.txt +7 -0
- utils/__init__.py +1 -0
- 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
|
+
}
|