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,190 @@
1
+ """
2
+ Video metadata service.
3
+
4
+ This module provides focused metadata extraction and management functionality.
5
+ """
6
+
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Optional, Union
9
+
10
+ from core.base_service import BaseService
11
+ from core.config import Config
12
+ from database.metadata import MetadataExtractor, MetadataManager
13
+
14
+
15
+ class MetadataService(BaseService):
16
+ """
17
+ Focused metadata service.
18
+
19
+ Handles metadata extraction, enrichment, and management for video files.
20
+ """
21
+
22
+ def __init__(self, config: Config, verbose: bool = False, db_service=None):
23
+ """Initialize the metadata service."""
24
+ # Initialize base service
25
+ super().__init__(config, verbose, db_service)
26
+
27
+ # Initialize metadata management
28
+ self.metadata_extractor = MetadataExtractor(config, verbose=verbose)
29
+ self.metadata_manager = MetadataManager(config, verbose=verbose)
30
+
31
+ def extract_video_metadata(self, url: str) -> Dict[str, Any]:
32
+ """
33
+ Extract metadata from video URL.
34
+
35
+ Args:
36
+ url: Video URL to extract metadata from
37
+
38
+ Returns:
39
+ Dictionary containing extracted metadata
40
+ """
41
+ try:
42
+ if "youtube.com" in url or "youtu.be" in url:
43
+ metadata = self.metadata_extractor.extract_youtube_metadata(url)
44
+ self.logger.info(
45
+ f"Extracted YouTube metadata: {metadata.get('title', 'Unknown')}"
46
+ )
47
+ return metadata
48
+ else:
49
+ self.logger.warning(f"Unsupported URL for metadata extraction: {url}")
50
+ return {}
51
+ except Exception as e:
52
+ self.logger.error(f"Failed to extract metadata from {url}: {e}")
53
+ return {}
54
+
55
+ def enrich_media_file(self, media_file_id: int) -> bool:
56
+ """
57
+ Enrich media file with additional metadata.
58
+
59
+ Args:
60
+ media_file_id: ID of media file to enrich
61
+
62
+ Returns:
63
+ True if enrichment successful, False otherwise
64
+ """
65
+ try:
66
+ media_file = self.repos.media.get_by_id(media_file_id)
67
+ if not media_file:
68
+ self.logger.error(f"Media file not found: {media_file_id}")
69
+ return False
70
+
71
+ # Enrich with additional metadata
72
+ self.metadata_manager.enrich_media_file(
73
+ media_file, self.repos.media, extract_source_metadata=True
74
+ )
75
+
76
+ self.logger.info(f"Enriched metadata for media file: {media_file_id}")
77
+ return True
78
+
79
+ except Exception as e:
80
+ self.logger.error(f"Failed to enrich media file {media_file_id}: {e}")
81
+ return False
82
+
83
+ def update_media_file_metadata(
84
+ self, media_file_id: int, metadata: Dict[str, Any]
85
+ ) -> bool:
86
+ """
87
+ Update media file with new metadata.
88
+
89
+ Args:
90
+ media_file_id: ID of media file to update
91
+ metadata: New metadata to apply
92
+
93
+ Returns:
94
+ True if update successful, False otherwise
95
+ """
96
+ try:
97
+ media_file = self.repos.media.get_by_id(media_file_id)
98
+ if not media_file:
99
+ self.logger.error(f"Media file not found: {media_file_id}")
100
+ return False
101
+
102
+ # Update media file with new metadata
103
+ self.repos.media.update(media_file_id, **metadata)
104
+
105
+ self.logger.info(f"Updated metadata for media file: {media_file_id}")
106
+ return True
107
+
108
+ except Exception as e:
109
+ self.logger.error(
110
+ f"Failed to update metadata for media file {media_file_id}: {e}"
111
+ )
112
+ return False
113
+
114
+ def get_media_file_metadata(self, media_file_id: int) -> Optional[Dict[str, Any]]:
115
+ """
116
+ Get metadata for a media file.
117
+
118
+ Args:
119
+ media_file_id: ID of media file
120
+
121
+ Returns:
122
+ Dictionary containing media file metadata, or None if not found
123
+ """
124
+ try:
125
+ media_file = self.repos.media.get_by_id(media_file_id)
126
+ if not media_file:
127
+ return None
128
+
129
+ # Convert SQLAlchemy object to dictionary
130
+ metadata = {
131
+ "id": media_file.id,
132
+ "file_path": media_file.file_path,
133
+ "file_name": media_file.file_name,
134
+ "file_size": media_file.file_size,
135
+ "file_hash": media_file.file_hash,
136
+ "media_type": media_file.media_type,
137
+ "mime_type": media_file.mime_type,
138
+ "source_url": media_file.source_url,
139
+ "source_platform": media_file.source_platform,
140
+ "source_id": media_file.source_id,
141
+ "title": media_file.title,
142
+ "description": media_file.description,
143
+ "uploader": media_file.uploader,
144
+ "uploader_id": media_file.uploader_id,
145
+ "upload_date": media_file.upload_date,
146
+ "view_count": media_file.view_count,
147
+ "like_count": media_file.like_count,
148
+ "duration": media_file.duration,
149
+ "language": media_file.language,
150
+ "created_at": media_file.created_at,
151
+ "updated_at": media_file.updated_at,
152
+ }
153
+
154
+ return metadata
155
+
156
+ except Exception as e:
157
+ self.logger.error(
158
+ f"Failed to get metadata for media file {media_file_id}: {e}"
159
+ )
160
+ return None
161
+
162
+ def search_media_files(self, query: str, media_type: Optional[str] = None) -> list:
163
+ """
164
+ Search media files by metadata.
165
+
166
+ Args:
167
+ query: Search query
168
+ media_type: Optional media type filter
169
+
170
+ Returns:
171
+ List of matching media files
172
+ """
173
+ try:
174
+ from database.models import MediaType
175
+
176
+ media_type_enum = None
177
+ if media_type:
178
+ try:
179
+ media_type_enum = MediaType(media_type)
180
+ except ValueError:
181
+ self.logger.warning(f"Invalid media type: {media_type}")
182
+
183
+ results = self.repos.media.search(query, media_type_enum)
184
+
185
+ self.logger.info(f"Found {len(results)} media files matching '{query}'")
186
+ return results
187
+
188
+ except Exception as e:
189
+ self.logger.error(f"Failed to search media files: {e}")
190
+ return []
@@ -0,0 +1,445 @@
1
+ """
2
+ Playlist service.
3
+
4
+ This module provides focused playlist management functionality.
5
+ """
6
+
7
+ import shutil
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional, Union
10
+
11
+ from core.base import ProcessingResult
12
+ from core.base_service import BaseService
13
+ from core.config import Config
14
+ from database.models import MediaType, ProcessingStatus
15
+
16
+
17
+ class PlaylistService(BaseService):
18
+ """
19
+ Focused playlist service.
20
+
21
+ Handles playlist downloading and management without transcription concerns.
22
+ """
23
+
24
+ def __init__(self, config: Config, verbose: bool = False, db_service=None):
25
+ """Initialize the playlist service."""
26
+ # Initialize base service
27
+ super().__init__(config, verbose, db_service)
28
+
29
+ # Initialize metadata management
30
+ from database.metadata import MetadataExtractor, MetadataManager
31
+
32
+ self.metadata_extractor = MetadataExtractor(config, verbose=verbose)
33
+ self.metadata_manager = MetadataManager(config, verbose=verbose)
34
+
35
+ def download_playlist(
36
+ self, url: str, output_path: Optional[Union[str, Path]] = None, **kwargs
37
+ ) -> ProcessingResult:
38
+ """
39
+ Download playlist without transcription.
40
+
41
+ Args:
42
+ url: Playlist URL to download
43
+ output_path: Optional output directory (will create playlist folder)
44
+ **kwargs: Additional download options
45
+
46
+ Returns:
47
+ Dictionary with download results
48
+ """
49
+ try:
50
+ # Get playlist metadata first
51
+ playlist_info = self._get_playlist_info(url)
52
+ if not playlist_info:
53
+ return ProcessingResult(
54
+ success=False,
55
+ message="Failed to get playlist information",
56
+ errors=["Could not extract playlist metadata"],
57
+ )
58
+
59
+ # Create playlist folder
60
+ playlist_name = self._sanitize_filename(
61
+ playlist_info.get("title", "Unknown Playlist")
62
+ )
63
+ playlist_id = playlist_info.get("id", "unknown")
64
+ folder_name = f"{playlist_name} [{playlist_id}]"
65
+
66
+ if output_path:
67
+ playlist_dir = Path(output_path) / folder_name
68
+ else:
69
+ from core.config import get_default_data_dir
70
+
71
+ repo_root = get_default_data_dir().parent
72
+ playlist_dir = repo_root / "downloads" / folder_name
73
+
74
+ # Check if output is on NAS and set up temp processing if needed
75
+ is_nas = self._is_nas_path(playlist_dir)
76
+
77
+ # Create processing job
78
+ job = self.repos.jobs.create(
79
+ media_file_id=None, # Will be updated after processing
80
+ job_type="download_playlist",
81
+ input_path=url,
82
+ output_path=str(playlist_dir),
83
+ parameters=str(kwargs),
84
+ )
85
+ self.logger.info(f"Created playlist processing job: {job.id}")
86
+
87
+ temp_dir = None
88
+ processing_dir = playlist_dir
89
+
90
+ if is_nas:
91
+ # Create job-specific temp processing directory with playlist folder
92
+ temp_dir = self._get_temp_processing_dir(job.id)
93
+ processing_dir = temp_dir / folder_name
94
+ self.logger.info(
95
+ f"NAS detected for playlist, using temp processing: {temp_dir}"
96
+ )
97
+ self.logger.info(f"Playlist will be processed in: {processing_dir}")
98
+
99
+ processing_dir.mkdir(parents=True, exist_ok=True)
100
+
101
+ # Create or update playlist record in database
102
+ existing_playlist = self.repos.playlists.get_by_playlist_id(playlist_id)
103
+ if existing_playlist:
104
+ # Update existing playlist
105
+ existing_playlist.title = playlist_name
106
+ existing_playlist.description = playlist_info.get("description")
107
+ existing_playlist.uploader = playlist_info.get("uploader")
108
+ existing_playlist.uploader_id = playlist_info.get("uploader_id")
109
+ existing_playlist.source_url = url
110
+ existing_playlist.source_platform = "youtube"
111
+ existing_playlist.video_count = playlist_info.get("playlist_count")
112
+ existing_playlist.view_count = playlist_info.get("view_count")
113
+ existing_playlist.thumbnail_url = playlist_info.get("thumbnail")
114
+ playlist_record = existing_playlist
115
+ else:
116
+ playlist_record = self.repos.playlists.create(
117
+ playlist_id=playlist_id,
118
+ title=playlist_name,
119
+ description=playlist_info.get("description"),
120
+ uploader=playlist_info.get("uploader"),
121
+ uploader_id=playlist_info.get("uploader_id"),
122
+ source_url=url,
123
+ source_platform="youtube",
124
+ video_count=playlist_info.get("playlist_count"),
125
+ view_count=playlist_info.get("view_count"),
126
+ thumbnail_url=playlist_info.get("thumbnail"),
127
+ )
128
+
129
+ self.logger.info(f"Downloading playlist to: {playlist_dir}")
130
+ self.logger.info(f"Playlist record: {playlist_record.id}")
131
+
132
+ # Mark job as processing (sets started_at for duration tracking)
133
+ self.repos.jobs.update_status(job.id, ProcessingStatus.PROCESSING)
134
+
135
+ # Download playlist using yt-dlp Python package
136
+ self.logger.info(f"Downloading playlist from: {url}")
137
+
138
+ # Build yt-dlp options
139
+ ydl_opts = self._build_playlist_ydl_opts(processing_dir, **kwargs)
140
+
141
+ # Execute download
142
+ import yt_dlp
143
+
144
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
145
+ ydl.download([url])
146
+
147
+ # Check if download was successful by looking for files
148
+ downloaded_videos = self._find_playlist_videos(processing_dir)
149
+ max_videos = kwargs.get("max_videos")
150
+ if (
151
+ isinstance(max_videos, int)
152
+ and max_videos > 0
153
+ and len(downloaded_videos) > max_videos
154
+ ):
155
+ downloaded_videos = sorted(
156
+ downloaded_videos,
157
+ key=lambda path: path.stat().st_mtime,
158
+ reverse=True,
159
+ )[:max_videos]
160
+ self.logger.info(
161
+ f"Limiting processed videos to most recent {max_videos}"
162
+ )
163
+
164
+ if downloaded_videos:
165
+ self.logger.info(
166
+ f"Processing {len(downloaded_videos)} downloaded videos from playlist"
167
+ )
168
+
169
+ # Process each video (metadata only, no transcription)
170
+ successful_downloads = []
171
+ failed_downloads = []
172
+
173
+ for position, video_path in enumerate(downloaded_videos, 1):
174
+ try:
175
+ # Extract video metadata and create/update media file record
176
+ video_id = self._extract_video_id_from_path(video_path)
177
+
178
+ # Get source metadata for this video
179
+ source_metadata = (
180
+ self.metadata_extractor.extract_youtube_metadata(
181
+ f"https://www.youtube.com/watch?v={video_id}"
182
+ )
183
+ )
184
+
185
+ # Create media file record
186
+ from utils.helpers import get_file_hash, get_file_type
187
+
188
+ media_file = self.repos.media.create(
189
+ file_path=str(video_path),
190
+ file_name=video_path.name,
191
+ file_size=video_path.stat().st_size,
192
+ file_hash=get_file_hash(video_path),
193
+ media_type=MediaType.VIDEO,
194
+ mime_type=get_file_type(video_path),
195
+ source_url=f"https://www.youtube.com/watch?v={video_id}",
196
+ source_platform="youtube",
197
+ source_id=video_id,
198
+ title=source_metadata.get("title", video_path.stem),
199
+ description=source_metadata.get("description"),
200
+ uploader=source_metadata.get("uploader"),
201
+ uploader_id=source_metadata.get("uploader_id"),
202
+ upload_date=source_metadata.get("upload_date"),
203
+ view_count=source_metadata.get("view_count"),
204
+ like_count=source_metadata.get("like_count"),
205
+ duration=source_metadata.get("duration"),
206
+ language=source_metadata.get("language"),
207
+ )
208
+
209
+ # Enrich with additional metadata
210
+ self.metadata_manager.enrich_media_file(
211
+ media_file, self.repos.media, extract_source_metadata=True
212
+ )
213
+
214
+ # Link video to playlist
215
+ self.repos.playlist_videos.add_video_to_playlist(
216
+ playlist_id=playlist_record.id,
217
+ media_file_id=media_file.id,
218
+ position=position,
219
+ video_title=media_file.title,
220
+ )
221
+
222
+ successful_downloads.append(str(video_path))
223
+
224
+ except Exception as e:
225
+ self.logger.error(f"Failed to process {video_path.name}: {e}")
226
+ failed_downloads.append(str(video_path))
227
+
228
+ # If we used temp processing, move entire playlist directory to final destination
229
+ if is_nas and temp_dir:
230
+ self.logger.info("Moving playlist directory to NAS destination...")
231
+
232
+ # Move the entire playlist directory from temp to final destination
233
+ if self._move_playlist_to_final_destination(
234
+ processing_dir, playlist_dir
235
+ ):
236
+ self.logger.info(
237
+ f"Successfully moved playlist directory to NAS: {playlist_dir}"
238
+ )
239
+
240
+ # Update job status to completed
241
+ self.repos.jobs.update_status(
242
+ job.id, ProcessingStatus.COMPLETED
243
+ )
244
+
245
+ # Clean up temp directory after successful move
246
+ self._cleanup_temp_directory(temp_dir)
247
+ self.logger.info(f"Cleaned up temp directory: {temp_dir}")
248
+ else:
249
+ self.logger.error("Failed to move playlist directory to NAS")
250
+ self.repos.jobs.update_status(
251
+ job.id,
252
+ ProcessingStatus.FAILED,
253
+ error_message="Failed to move playlist to NAS",
254
+ )
255
+ return ProcessingResult.error_result(
256
+ message="Playlist downloaded but failed to move to NAS",
257
+ errors=[
258
+ "Failed to move playlist directory to final destination"
259
+ ],
260
+ )
261
+ else:
262
+ # For local downloads, update job status
263
+ self.repos.jobs.update_status(job.id, ProcessingStatus.COMPLETED)
264
+
265
+ return ProcessingResult.success_result(
266
+ message=f"Playlist downloaded successfully: {len(successful_downloads)} videos",
267
+ output_path=playlist_dir,
268
+ metadata={
269
+ "playlist_title": playlist_name,
270
+ "playlist_id": playlist_id,
271
+ "total_videos": len(downloaded_videos),
272
+ "successful_downloads": len(successful_downloads),
273
+ "failed_downloads": len(failed_downloads),
274
+ "nas_processing": is_nas,
275
+ "videos_downloaded": len(successful_downloads),
276
+ },
277
+ )
278
+ else:
279
+ return ProcessingResult.error_result(
280
+ message="Playlist download completed but no videos found",
281
+ errors=["No video files found in download directory"],
282
+ )
283
+
284
+ except Exception as e:
285
+ self.logger.error(f"Playlist download failed: {e}")
286
+ return ProcessingResult.error_result(
287
+ message=f"Playlist download failed: {e}", errors=[str(e)]
288
+ )
289
+
290
+ def _get_playlist_info(self, url: str) -> Optional[Dict[str, Any]]:
291
+ """Get playlist information."""
292
+ try:
293
+ import yt_dlp
294
+
295
+ ydl_opts = {
296
+ "quiet": True,
297
+ "no_warnings": True,
298
+ "extract_flat": True,
299
+ }
300
+
301
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
302
+ info = ydl.extract_info(url, download=False)
303
+ return info
304
+
305
+ except Exception as e:
306
+ self.logger.error(f"Failed to get playlist info: {e}")
307
+ return None
308
+
309
+ def _get_cookies_from_browser(self) -> Optional[tuple]:
310
+ """Try to get cookies from common browsers automatically.
311
+
312
+ Returns a tuple of browsers to try in order. yt-dlp will try each browser
313
+ until one works, or continue without cookies if none are available.
314
+
315
+ Note: On macOS, Chrome is more reliable than Safari for cookie extraction.
316
+ """
317
+ # Try browsers in order of preference
318
+ # On macOS, Chrome is more reliable than Safari (Safari cookies are harder to access)
319
+ # yt-dlp will try each browser until one works
320
+ import platform
321
+
322
+ system = platform.system().lower()
323
+
324
+ if system == "darwin": # macOS - prioritize Chrome over Safari
325
+ browsers = ("chrome", "safari", "firefox", "edge")
326
+ else: # Linux, Windows, etc.
327
+ browsers = ("chrome", "firefox", "safari", "edge")
328
+
329
+ return browsers
330
+
331
+ def _build_playlist_ydl_opts(self, output_dir: Path, **kwargs) -> Dict:
332
+ """Build yt-dlp options for playlist download."""
333
+ # Output template for playlist
334
+ output_template = str(output_dir / "%(title)s [%(id)s].%(ext)s")
335
+
336
+ ydl_opts = {
337
+ "outtmpl": output_template,
338
+ "format": self._get_format_selector(
339
+ kwargs.get("quality", self.config.video.quality),
340
+ kwargs.get("format", self.config.video.default_format),
341
+ ),
342
+ "writeinfojson": False, # Don't write info files
343
+ "writesubtitles": False, # We handle subtitles separately
344
+ "writeautomaticsub": False,
345
+ "ignoreerrors": True, # Continue on individual video errors
346
+ "no_warnings": not self.verbose,
347
+ "quiet": not self.verbose,
348
+ "extract_flat": False, # We want to download, not just extract info
349
+ }
350
+
351
+ max_videos = kwargs.get("max_videos")
352
+ if isinstance(max_videos, int) and max_videos > 0:
353
+ ydl_opts["playlistend"] = max_videos
354
+
355
+ # Automatically try to use cookies from browser for age-restricted content
356
+ cookies_browser = self._get_cookies_from_browser()
357
+ if cookies_browser:
358
+ ydl_opts["cookies_from_browser"] = cookies_browser
359
+
360
+ # Additional options
361
+ if self.verbose:
362
+ ydl_opts["verbose"] = True
363
+
364
+ return ydl_opts
365
+
366
+ def _get_format_selector(self, quality: str, format: str) -> str:
367
+ """Get format selector for yt-dlp."""
368
+ if quality == "best":
369
+ return f"best[ext={format}]/best"
370
+ elif quality == "worst":
371
+ return f"worst[ext={format}]/worst"
372
+ else:
373
+ return f"best[height<={quality}][ext={format}]/best"
374
+
375
+ def _find_playlist_videos(self, directory: Path) -> List[Path]:
376
+ """Find downloaded video files in playlist directory."""
377
+ video_extensions = self.config.video_extensions
378
+ video_files = []
379
+
380
+ for ext in video_extensions:
381
+ video_files.extend(directory.rglob(f"*{ext}"))
382
+
383
+ return video_files
384
+
385
+ def _extract_video_id_from_path(self, video_path: Path) -> str:
386
+ """Extract video ID from file path."""
387
+ # Look for [video_id] pattern in filename
388
+ import re
389
+
390
+ match = re.search(r"\[([a-zA-Z0-9_-]{11})\]", video_path.name)
391
+ if match:
392
+ return match.group(1)
393
+ return "unknown"
394
+
395
+ def _sanitize_filename(self, filename: str) -> str:
396
+ """Sanitize filename for filesystem."""
397
+ import re
398
+
399
+ # Remove or replace invalid characters
400
+ filename = re.sub(r'[<>:"/\\|?*]', "_", filename)
401
+ # Limit length
402
+ max_length = self.config.max_filename_length
403
+ if len(filename) > max_length:
404
+ filename = filename[:max_length]
405
+ return filename
406
+
407
+ def _is_nas_path(self, path: Union[str, Path]) -> bool:
408
+ """Check if path is on NAS."""
409
+ path_str = str(path)
410
+ return any(
411
+ nas_indicator in path_str.lower()
412
+ for nas_indicator in [
413
+ "/volumes/",
414
+ "/mnt/",
415
+ "nas",
416
+ "network",
417
+ "smb://",
418
+ "nfs://",
419
+ ]
420
+ )
421
+
422
+ def _get_temp_processing_dir(self, job_id: int) -> Path:
423
+ """Get temporary processing directory for job."""
424
+ temp_dir = self.config.video.temp_dir / str(job_id)
425
+ temp_dir.mkdir(parents=True, exist_ok=True)
426
+ return temp_dir
427
+
428
+ def _move_playlist_to_final_destination(
429
+ self, source_dir: Path, dest_dir: Path
430
+ ) -> bool:
431
+ """Move playlist directory to final destination."""
432
+ try:
433
+ dest_dir.parent.mkdir(parents=True, exist_ok=True)
434
+ shutil.move(str(source_dir), str(dest_dir))
435
+ return True
436
+ except Exception as e:
437
+ self.logger.error(f"Failed to move playlist directory: {e}")
438
+ return False
439
+
440
+ def _cleanup_temp_directory(self, temp_dir: Path):
441
+ """Clean up temporary directory."""
442
+ try:
443
+ shutil.rmtree(temp_dir)
444
+ except Exception as e:
445
+ self.logger.warning(f"Failed to clean up temp directory {temp_dir}: {e}")