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,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}")
|