django-nativemojo 0.1.10__py3-none-any.whl → 0.1.15__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.
- django_nativemojo-0.1.15.dist-info/METADATA +136 -0
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.15.dist-info}/RECORD +105 -65
- mojo/__init__.py +1 -1
- mojo/apps/account/management/__init__.py +5 -0
- mojo/apps/account/management/commands/__init__.py +6 -0
- mojo/apps/account/management/commands/serializer_admin.py +531 -0
- mojo/apps/account/migrations/0004_user_avatar.py +20 -0
- mojo/apps/account/migrations/0005_group_last_activity.py +18 -0
- mojo/apps/account/models/group.py +25 -7
- mojo/apps/account/models/member.py +15 -4
- mojo/apps/account/models/user.py +197 -20
- mojo/apps/account/rest/group.py +1 -0
- mojo/apps/account/rest/user.py +6 -2
- mojo/apps/aws/rest/__init__.py +1 -0
- mojo/apps/aws/rest/s3.py +64 -0
- mojo/apps/fileman/README.md +8 -8
- mojo/apps/fileman/backends/base.py +76 -70
- mojo/apps/fileman/backends/filesystem.py +86 -86
- mojo/apps/fileman/backends/s3.py +200 -108
- mojo/apps/fileman/migrations/0001_initial.py +106 -0
- mojo/apps/fileman/migrations/0002_filemanager_parent_alter_filemanager_max_file_size.py +24 -0
- mojo/apps/fileman/migrations/0003_remove_file_fileman_fil_upload__c4bc35_idx_and_more.py +25 -0
- mojo/apps/fileman/migrations/0004_remove_file_original_filename_and_more.py +39 -0
- mojo/apps/fileman/migrations/0005_alter_file_upload_token.py +18 -0
- mojo/apps/fileman/migrations/0006_file_download_url_filemanager_forever_urls.py +23 -0
- mojo/apps/fileman/migrations/0007_remove_filemanager_forever_urls_and_more.py +22 -0
- mojo/apps/fileman/migrations/0008_file_category.py +18 -0
- mojo/apps/fileman/migrations/0009_rename_file_path_file_storage_file_path.py +18 -0
- mojo/apps/fileman/migrations/0010_filerendition.py +33 -0
- mojo/apps/fileman/migrations/0011_alter_filerendition_original_file.py +19 -0
- mojo/apps/fileman/models/__init__.py +1 -5
- mojo/apps/fileman/models/file.py +204 -58
- mojo/apps/fileman/models/manager.py +161 -31
- mojo/apps/fileman/models/rendition.py +118 -0
- mojo/apps/fileman/renderer/__init__.py +111 -0
- mojo/apps/fileman/renderer/audio.py +403 -0
- mojo/apps/fileman/renderer/base.py +205 -0
- mojo/apps/fileman/renderer/document.py +404 -0
- mojo/apps/fileman/renderer/image.py +222 -0
- mojo/apps/fileman/renderer/utils.py +297 -0
- mojo/apps/fileman/renderer/video.py +304 -0
- mojo/apps/fileman/rest/__init__.py +1 -18
- mojo/apps/fileman/rest/upload.py +22 -32
- mojo/apps/fileman/signals.py +58 -0
- mojo/apps/fileman/tasks.py +254 -0
- mojo/apps/fileman/utils/__init__.py +40 -16
- mojo/apps/incident/migrations/0005_incidenthistory.py +39 -0
- mojo/apps/incident/migrations/0006_alter_incident_state.py +18 -0
- mojo/apps/incident/models/__init__.py +1 -0
- mojo/apps/incident/models/history.py +36 -0
- mojo/apps/incident/models/incident.py +1 -1
- mojo/apps/incident/reporter.py +3 -1
- mojo/apps/incident/rest/event.py +7 -1
- mojo/apps/logit/migrations/0004_alter_log_level.py +18 -0
- mojo/apps/logit/models/log.py +4 -1
- mojo/apps/metrics/utils.py +2 -2
- mojo/apps/notify/handlers/ses/message.py +1 -1
- mojo/apps/notify/providers/aws.py +2 -2
- mojo/apps/tasks/__init__.py +34 -1
- mojo/apps/tasks/manager.py +200 -45
- mojo/apps/tasks/rest/tasks.py +24 -10
- mojo/apps/tasks/runner.py +283 -18
- mojo/apps/tasks/task.py +99 -0
- mojo/apps/tasks/tq_handlers.py +118 -0
- mojo/decorators/auth.py +6 -1
- mojo/decorators/http.py +7 -2
- mojo/helpers/aws/__init__.py +41 -0
- mojo/helpers/aws/ec2.py +804 -0
- mojo/helpers/aws/iam.py +748 -0
- mojo/helpers/aws/s3.py +451 -11
- mojo/helpers/aws/ses.py +483 -0
- mojo/helpers/aws/sns.py +461 -0
- mojo/helpers/crypto/__pycache__/hash.cpython-310.pyc +0 -0
- mojo/helpers/crypto/__pycache__/sign.cpython-310.pyc +0 -0
- mojo/helpers/crypto/__pycache__/utils.cpython-310.pyc +0 -0
- mojo/helpers/dates.py +18 -0
- mojo/helpers/response.py +6 -2
- mojo/helpers/settings/__init__.py +2 -0
- mojo/helpers/{settings.py → settings/helper.py} +1 -37
- mojo/helpers/settings/parser.py +132 -0
- mojo/middleware/logging.py +1 -1
- mojo/middleware/mojo.py +5 -0
- mojo/models/rest.py +261 -46
- mojo/models/secrets.py +13 -4
- mojo/serializers/__init__.py +100 -0
- mojo/serializers/advanced/README.md +363 -0
- mojo/serializers/advanced/__init__.py +247 -0
- mojo/serializers/advanced/formats/__init__.py +28 -0
- mojo/serializers/advanced/formats/csv.py +416 -0
- mojo/serializers/advanced/formats/excel.py +516 -0
- mojo/serializers/advanced/formats/json.py +239 -0
- mojo/serializers/advanced/formats/localizers.py +509 -0
- mojo/serializers/advanced/formats/response.py +485 -0
- mojo/serializers/advanced/serializer.py +568 -0
- mojo/serializers/manager.py +501 -0
- mojo/serializers/optimized.py +618 -0
- mojo/serializers/settings_example.py +322 -0
- mojo/serializers/{models.py → simple.py} +38 -15
- testit/helpers.py +21 -4
- django_nativemojo-0.1.10.dist-info/METADATA +0 -96
- mojo/apps/metrics/rest/db.py +0 -0
- mojo/helpers/aws/setup_email.py +0 -0
- mojo/ws4redis/README.md +0 -174
- mojo/ws4redis/__init__.py +0 -2
- mojo/ws4redis/client.py +0 -283
- mojo/ws4redis/connection.py +0 -327
- mojo/ws4redis/exceptions.py +0 -32
- mojo/ws4redis/redis.py +0 -183
- mojo/ws4redis/servers/base.py +0 -86
- mojo/ws4redis/servers/django.py +0 -171
- mojo/ws4redis/servers/uwsgi.py +0 -63
- mojo/ws4redis/settings.py +0 -45
- mojo/ws4redis/utf8validator.py +0 -128
- mojo/ws4redis/websocket.py +0 -403
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.15.dist-info}/LICENSE +0 -0
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.15.dist-info}/NOTICE +0 -0
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.15.dist-info}/WHEEL +0 -0
- /mojo/{ws4redis/servers → apps/aws}/__init__.py +0 -0
- /mojo/apps/{fileman/models/render.py → aws/models/__init__.py} +0 -0
- /mojo/apps/fileman/{rest/__init__ → migrations/__init__.py} +0 -0
@@ -0,0 +1,297 @@
|
|
1
|
+
import os
|
2
|
+
import mimetypes
|
3
|
+
import tempfile
|
4
|
+
import logging
|
5
|
+
import shutil
|
6
|
+
import hashlib
|
7
|
+
from typing import Dict, List, Tuple, Optional, Union, Any
|
8
|
+
|
9
|
+
logger = logging.getLogger(__name__)
|
10
|
+
|
11
|
+
# Additional MIME type mappings that might not be in the standard library
|
12
|
+
ADDITIONAL_MIME_TYPES = {
|
13
|
+
'.webp': 'image/webp',
|
14
|
+
'.heic': 'image/heic',
|
15
|
+
'.heif': 'image/heif',
|
16
|
+
'.webm': 'video/webm',
|
17
|
+
'.mkv': 'video/x-matroska',
|
18
|
+
'.m4a': 'audio/mp4',
|
19
|
+
'.flac': 'audio/flac',
|
20
|
+
}
|
21
|
+
|
22
|
+
# Register additional MIME types
|
23
|
+
for ext, type_name in ADDITIONAL_MIME_TYPES.items():
|
24
|
+
mimetypes.add_type(type_name, ext)
|
25
|
+
|
26
|
+
# Map of file categories by MIME type prefix
|
27
|
+
CATEGORY_MAP = {
|
28
|
+
'image/': 'image',
|
29
|
+
'video/': 'video',
|
30
|
+
'audio/': 'audio',
|
31
|
+
'text/': 'document',
|
32
|
+
'application/pdf': 'pdf',
|
33
|
+
'application/msword': 'document',
|
34
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml': 'document',
|
35
|
+
'application/vnd.ms-excel': 'spreadsheet',
|
36
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml': 'spreadsheet',
|
37
|
+
'application/vnd.ms-powerpoint': 'presentation',
|
38
|
+
'application/vnd.openxmlformats-officedocument.presentationml': 'presentation',
|
39
|
+
'application/zip': 'archive',
|
40
|
+
'application/x-rar-compressed': 'archive',
|
41
|
+
'application/x-7z-compressed': 'archive',
|
42
|
+
'application/x-tar': 'archive',
|
43
|
+
'application/gzip': 'archive',
|
44
|
+
}
|
45
|
+
|
46
|
+
def get_file_category(mime_type: str) -> str:
|
47
|
+
"""
|
48
|
+
Determine the category of a file based on its MIME type.
|
49
|
+
|
50
|
+
Args:
|
51
|
+
mime_type: The MIME type of the file
|
52
|
+
|
53
|
+
Returns:
|
54
|
+
str: The category of the file (image, video, audio, document, etc.)
|
55
|
+
"""
|
56
|
+
if not mime_type:
|
57
|
+
return 'unknown'
|
58
|
+
|
59
|
+
# Check for exact matches
|
60
|
+
if mime_type in CATEGORY_MAP:
|
61
|
+
return CATEGORY_MAP[mime_type]
|
62
|
+
|
63
|
+
# Check for prefix matches
|
64
|
+
for prefix, category in CATEGORY_MAP.items():
|
65
|
+
if prefix.endswith('/'):
|
66
|
+
if mime_type.startswith(prefix):
|
67
|
+
return category
|
68
|
+
|
69
|
+
# Default category
|
70
|
+
return 'other'
|
71
|
+
|
72
|
+
def get_file_info(file_path: str) -> Dict[str, Any]:
|
73
|
+
"""
|
74
|
+
Get information about a file
|
75
|
+
|
76
|
+
Args:
|
77
|
+
file_path: Path to the file
|
78
|
+
|
79
|
+
Returns:
|
80
|
+
Dict with file information (size, mime_type, category, etc.)
|
81
|
+
"""
|
82
|
+
if not os.path.exists(file_path):
|
83
|
+
return {
|
84
|
+
'exists': False,
|
85
|
+
'size': 0,
|
86
|
+
'mime_type': None,
|
87
|
+
'category': 'unknown',
|
88
|
+
}
|
89
|
+
|
90
|
+
file_size = os.path.getsize(file_path)
|
91
|
+
mime_type, _ = mimetypes.guess_type(file_path)
|
92
|
+
category = get_file_category(mime_type)
|
93
|
+
|
94
|
+
return {
|
95
|
+
'exists': True,
|
96
|
+
'size': file_size,
|
97
|
+
'mime_type': mime_type,
|
98
|
+
'category': category,
|
99
|
+
'extension': os.path.splitext(file_path)[1].lower(),
|
100
|
+
}
|
101
|
+
|
102
|
+
def create_temp_directory() -> str:
|
103
|
+
"""
|
104
|
+
Create a temporary directory for file processing
|
105
|
+
|
106
|
+
Returns:
|
107
|
+
str: Path to the temporary directory
|
108
|
+
"""
|
109
|
+
return tempfile.mkdtemp()
|
110
|
+
|
111
|
+
def create_temp_file(suffix: str = '') -> str:
|
112
|
+
"""
|
113
|
+
Create a temporary file for processing
|
114
|
+
|
115
|
+
Args:
|
116
|
+
suffix: Optional suffix for the temp file (e.g., '.jpg')
|
117
|
+
|
118
|
+
Returns:
|
119
|
+
str: Path to the temporary file
|
120
|
+
"""
|
121
|
+
fd, path = tempfile.mkstemp(suffix=suffix)
|
122
|
+
os.close(fd)
|
123
|
+
return path
|
124
|
+
|
125
|
+
def cleanup_temp_files(paths: List[str]):
|
126
|
+
"""
|
127
|
+
Clean up temporary files and directories
|
128
|
+
|
129
|
+
Args:
|
130
|
+
paths: List of paths to clean up
|
131
|
+
"""
|
132
|
+
for path in paths:
|
133
|
+
if not path:
|
134
|
+
continue
|
135
|
+
try:
|
136
|
+
if os.path.isdir(path):
|
137
|
+
shutil.rmtree(path, ignore_errors=True)
|
138
|
+
elif os.path.exists(path):
|
139
|
+
os.unlink(path)
|
140
|
+
except Exception as e:
|
141
|
+
logger.warning(f"Failed to clean up temporary path {path}: {str(e)}")
|
142
|
+
|
143
|
+
def calculate_file_hash(file_path: str, algorithm: str = 'md5') -> str:
|
144
|
+
"""
|
145
|
+
Calculate hash for a file
|
146
|
+
|
147
|
+
Args:
|
148
|
+
file_path: Path to the file
|
149
|
+
algorithm: Hash algorithm to use (md5, sha1, sha256)
|
150
|
+
|
151
|
+
Returns:
|
152
|
+
str: Hex digest of the hash
|
153
|
+
"""
|
154
|
+
if algorithm == 'md5':
|
155
|
+
hash_obj = hashlib.md5()
|
156
|
+
elif algorithm == 'sha1':
|
157
|
+
hash_obj = hashlib.sha1()
|
158
|
+
elif algorithm == 'sha256':
|
159
|
+
hash_obj = hashlib.sha256()
|
160
|
+
else:
|
161
|
+
raise ValueError(f"Unsupported hash algorithm: {algorithm}")
|
162
|
+
|
163
|
+
with open(file_path, 'rb') as f:
|
164
|
+
for chunk in iter(lambda: f.read(4096), b''):
|
165
|
+
hash_obj.update(chunk)
|
166
|
+
|
167
|
+
return hash_obj.hexdigest()
|
168
|
+
|
169
|
+
def calculate_dimensions(original_width: int, original_height: int,
|
170
|
+
target_width: int, target_height: int,
|
171
|
+
mode: str = 'contain') -> Tuple[int, int]:
|
172
|
+
"""
|
173
|
+
Calculate dimensions for resizing an image or video
|
174
|
+
|
175
|
+
Args:
|
176
|
+
original_width: Original width
|
177
|
+
original_height: Original height
|
178
|
+
target_width: Target width
|
179
|
+
target_height: Target height
|
180
|
+
mode: Resize mode ('contain', 'cover', 'stretch')
|
181
|
+
|
182
|
+
Returns:
|
183
|
+
Tuple[int, int]: (new_width, new_height)
|
184
|
+
"""
|
185
|
+
if mode == 'stretch':
|
186
|
+
return target_width, target_height
|
187
|
+
|
188
|
+
# Calculate aspect ratios
|
189
|
+
original_ratio = original_width / original_height
|
190
|
+
target_ratio = target_width / target_height
|
191
|
+
|
192
|
+
if mode == 'contain':
|
193
|
+
# Fit within target dimensions while maintaining aspect ratio
|
194
|
+
if original_ratio > target_ratio:
|
195
|
+
# Width is the limiting factor
|
196
|
+
new_width = target_width
|
197
|
+
new_height = int(new_width / original_ratio)
|
198
|
+
else:
|
199
|
+
# Height is the limiting factor
|
200
|
+
new_height = target_height
|
201
|
+
new_width = int(new_height * original_ratio)
|
202
|
+
else: # cover
|
203
|
+
# Fill target dimensions while maintaining aspect ratio
|
204
|
+
if original_ratio > target_ratio:
|
205
|
+
# Height is the limiting factor
|
206
|
+
new_height = target_height
|
207
|
+
new_width = int(new_height * original_ratio)
|
208
|
+
else:
|
209
|
+
# Width is the limiting factor
|
210
|
+
new_width = target_width
|
211
|
+
new_height = int(new_width / original_ratio)
|
212
|
+
|
213
|
+
return new_width, new_height
|
214
|
+
|
215
|
+
def get_video_duration(video_path: str) -> Optional[float]:
|
216
|
+
"""
|
217
|
+
Get the duration of a video file in seconds
|
218
|
+
|
219
|
+
Args:
|
220
|
+
video_path: Path to the video file
|
221
|
+
|
222
|
+
Returns:
|
223
|
+
float: Duration in seconds, or None if duration couldn't be determined
|
224
|
+
"""
|
225
|
+
try:
|
226
|
+
import subprocess
|
227
|
+
|
228
|
+
# Use ffprobe to get video duration
|
229
|
+
cmd = [
|
230
|
+
"ffprobe",
|
231
|
+
"-v", "error",
|
232
|
+
"-show_entries", "format=duration",
|
233
|
+
"-of", "default=noprint_wrappers=1:nokey=1",
|
234
|
+
video_path
|
235
|
+
]
|
236
|
+
|
237
|
+
result = subprocess.run(cmd,
|
238
|
+
stdout=subprocess.PIPE,
|
239
|
+
stderr=subprocess.PIPE,
|
240
|
+
text=True,
|
241
|
+
check=True)
|
242
|
+
|
243
|
+
duration = float(result.stdout.strip())
|
244
|
+
return duration
|
245
|
+
except Exception as e:
|
246
|
+
logger.error(f"Failed to get video duration: {str(e)}")
|
247
|
+
return None
|
248
|
+
|
249
|
+
def get_audio_duration(audio_path: str) -> Optional[float]:
|
250
|
+
"""
|
251
|
+
Get the duration of an audio file in seconds
|
252
|
+
|
253
|
+
Args:
|
254
|
+
audio_path: Path to the audio file
|
255
|
+
|
256
|
+
Returns:
|
257
|
+
float: Duration in seconds, or None if duration couldn't be determined
|
258
|
+
"""
|
259
|
+
return get_video_duration(audio_path) # Use the same ffprobe method
|
260
|
+
|
261
|
+
def get_image_dimensions(image_path: str) -> Optional[Tuple[int, int]]:
|
262
|
+
"""
|
263
|
+
Get dimensions of an image
|
264
|
+
|
265
|
+
Args:
|
266
|
+
image_path: Path to the image file
|
267
|
+
|
268
|
+
Returns:
|
269
|
+
Tuple[int, int]: (width, height), or None if dimensions couldn't be determined
|
270
|
+
"""
|
271
|
+
try:
|
272
|
+
from PIL import Image
|
273
|
+
with Image.open(image_path) as img:
|
274
|
+
return img.size
|
275
|
+
except Exception as e:
|
276
|
+
logger.error(f"Failed to get image dimensions: {str(e)}")
|
277
|
+
return None
|
278
|
+
|
279
|
+
def format_filesize(size_in_bytes: int) -> str:
|
280
|
+
"""
|
281
|
+
Format a file size in bytes to a human-readable string
|
282
|
+
|
283
|
+
Args:
|
284
|
+
size_in_bytes: Size in bytes
|
285
|
+
|
286
|
+
Returns:
|
287
|
+
str: Human-readable file size (e.g., "1.2 MB")
|
288
|
+
"""
|
289
|
+
if size_in_bytes < 1024:
|
290
|
+
return f"{size_in_bytes} B"
|
291
|
+
|
292
|
+
for unit in ['KB', 'MB', 'GB', 'TB', 'PB']:
|
293
|
+
size_in_bytes /= 1024.0
|
294
|
+
if size_in_bytes < 1024.0:
|
295
|
+
return f"{size_in_bytes:.1f} {unit}"
|
296
|
+
|
297
|
+
return f"{size_in_bytes:.1f} PB"
|
@@ -0,0 +1,304 @@
|
|
1
|
+
import os
|
2
|
+
import io
|
3
|
+
import subprocess
|
4
|
+
import tempfile
|
5
|
+
import logging
|
6
|
+
import mimetypes
|
7
|
+
import shutil
|
8
|
+
from typing import Dict, Optional, Tuple, Union, BinaryIO, List
|
9
|
+
|
10
|
+
from mojo.apps.fileman.models import File, FileRendition
|
11
|
+
from mojo.apps.fileman.renderer.base import BaseRenderer, RenditionRole
|
12
|
+
|
13
|
+
logger = logging.getLogger(__name__)
|
14
|
+
|
15
|
+
class VideoRenderer(BaseRenderer):
|
16
|
+
"""
|
17
|
+
Renderer for video files
|
18
|
+
|
19
|
+
Creates various renditions like thumbnails, previews, and different formats using ffmpeg
|
20
|
+
"""
|
21
|
+
|
22
|
+
# Video file categories
|
23
|
+
supported_categories = ['video']
|
24
|
+
|
25
|
+
# Default rendition definitions with options
|
26
|
+
default_renditions = {
|
27
|
+
RenditionRole.VIDEO_THUMBNAIL: {
|
28
|
+
'width': 300,
|
29
|
+
'height': 169,
|
30
|
+
'time_offset': '00:00:03',
|
31
|
+
'format': 'jpg'
|
32
|
+
},
|
33
|
+
RenditionRole.THUMBNAIL: {
|
34
|
+
'width': 300,
|
35
|
+
'height': 169,
|
36
|
+
'time_offset': '00:00:03',
|
37
|
+
'format': 'jpg'
|
38
|
+
},
|
39
|
+
RenditionRole.VIDEO_PREVIEW: {
|
40
|
+
'width': 640,
|
41
|
+
'height': 360,
|
42
|
+
'bitrate': '500k',
|
43
|
+
'duration': 10,
|
44
|
+
'format': 'mp4',
|
45
|
+
'audio': True,
|
46
|
+
},
|
47
|
+
RenditionRole.VIDEO_MP4: {
|
48
|
+
'width': 1280,
|
49
|
+
'height': 720,
|
50
|
+
'bitrate': '2000k',
|
51
|
+
'format': 'mp4',
|
52
|
+
'audio': True,
|
53
|
+
},
|
54
|
+
RenditionRole.VIDEO_WEBM: {
|
55
|
+
'width': 1280,
|
56
|
+
'height': 720,
|
57
|
+
'bitrate': '2000k',
|
58
|
+
'format': 'webm',
|
59
|
+
'audio': True,
|
60
|
+
},
|
61
|
+
}
|
62
|
+
|
63
|
+
def __init__(self, file: File):
|
64
|
+
super().__init__(file)
|
65
|
+
# Check if ffmpeg is available
|
66
|
+
self._check_ffmpeg()
|
67
|
+
|
68
|
+
def _check_ffmpeg(self):
|
69
|
+
"""Check if ffmpeg is available in the system"""
|
70
|
+
try:
|
71
|
+
subprocess.run(["ffmpeg", "-version"],
|
72
|
+
stdout=subprocess.PIPE,
|
73
|
+
stderr=subprocess.PIPE,
|
74
|
+
check=True)
|
75
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
76
|
+
logger.warning("ffmpeg is not available. Video rendering may not work properly.")
|
77
|
+
|
78
|
+
def _download_original(self) -> Union[str, None]:
|
79
|
+
"""
|
80
|
+
Download the original file to a temporary location
|
81
|
+
|
82
|
+
Returns:
|
83
|
+
str: Path to the downloaded file, or None if download failed
|
84
|
+
"""
|
85
|
+
try:
|
86
|
+
file_manager = self.file.file_manager
|
87
|
+
backend = file_manager.backend
|
88
|
+
|
89
|
+
# Get file extension
|
90
|
+
_, ext = os.path.splitext(self.file.filename)
|
91
|
+
temp_path = self.get_temp_path(ext)
|
92
|
+
|
93
|
+
# Download file from storage
|
94
|
+
with open(temp_path, 'wb') as f:
|
95
|
+
backend.download(self.file.storage_file_path, f)
|
96
|
+
|
97
|
+
return temp_path
|
98
|
+
except Exception as e:
|
99
|
+
logger.error(f"Failed to download original video file: {str(e)}")
|
100
|
+
return None
|
101
|
+
|
102
|
+
def _create_thumbnail(self, source_path: str, width: int, height: int,
|
103
|
+
time_offset: str, output_format: str) -> Tuple[str, str, int]:
|
104
|
+
"""
|
105
|
+
Create a thumbnail from a video at specified time offset
|
106
|
+
|
107
|
+
Args:
|
108
|
+
source_path: Path to the source video
|
109
|
+
width: Target width
|
110
|
+
height: Target height
|
111
|
+
time_offset: Time offset for thumbnail (format: HH:MM:SS)
|
112
|
+
output_format: Output format (jpg, png)
|
113
|
+
|
114
|
+
Returns:
|
115
|
+
Tuple[str, str, int]: (Output path, mime type, file size)
|
116
|
+
"""
|
117
|
+
temp_output = self.get_temp_path(f".{output_format}")
|
118
|
+
|
119
|
+
try:
|
120
|
+
# Use ffmpeg to extract a frame
|
121
|
+
cmd = [
|
122
|
+
"ffmpeg",
|
123
|
+
"-y", # Overwrite output files
|
124
|
+
"-ss", time_offset, # Seek to time offset
|
125
|
+
"-i", source_path, # Input file
|
126
|
+
"-vframes", "1", # Extract one frame
|
127
|
+
"-s", f"{width}x{height}", # Set size
|
128
|
+
"-f", "image2", # Force image2 format
|
129
|
+
temp_output # Output file
|
130
|
+
]
|
131
|
+
|
132
|
+
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
133
|
+
|
134
|
+
# Get file size
|
135
|
+
file_size = os.path.getsize(temp_output)
|
136
|
+
|
137
|
+
# Get mime type
|
138
|
+
mime_type = mimetypes.guess_type(f"file.{output_format}")[0]
|
139
|
+
|
140
|
+
return temp_output, mime_type, file_size
|
141
|
+
|
142
|
+
except subprocess.SubprocessError as e:
|
143
|
+
logger.error(f"Failed to create video thumbnail: {str(e)}")
|
144
|
+
if os.path.exists(temp_output):
|
145
|
+
os.unlink(temp_output)
|
146
|
+
raise
|
147
|
+
|
148
|
+
def _create_video_rendition(self, source_path: str, options: Dict) -> Tuple[str, str, int]:
|
149
|
+
"""
|
150
|
+
Create a video rendition with specified options
|
151
|
+
|
152
|
+
Args:
|
153
|
+
source_path: Path to the source video
|
154
|
+
options: Video processing options
|
155
|
+
|
156
|
+
Returns:
|
157
|
+
Tuple[str, str, int]: (Output path, mime type, file size)
|
158
|
+
"""
|
159
|
+
width = options.get('width', 1280)
|
160
|
+
height = options.get('height', 720)
|
161
|
+
bitrate = options.get('bitrate', '2000k')
|
162
|
+
output_format = options.get('format', 'mp4')
|
163
|
+
duration = options.get('duration') # Optional duration limit in seconds
|
164
|
+
audio = options.get('audio', True)
|
165
|
+
|
166
|
+
temp_output = self.get_temp_path(f".{output_format}")
|
167
|
+
|
168
|
+
try:
|
169
|
+
# Build ffmpeg command
|
170
|
+
cmd = [
|
171
|
+
"ffmpeg",
|
172
|
+
"-y", # Overwrite output files
|
173
|
+
"-i", source_path, # Input file
|
174
|
+
]
|
175
|
+
|
176
|
+
# Add duration limit if specified
|
177
|
+
if duration:
|
178
|
+
cmd.extend(["-t", str(duration)])
|
179
|
+
|
180
|
+
# Video settings
|
181
|
+
cmd.extend([
|
182
|
+
"-vf", f"scale={width}:{height}",
|
183
|
+
"-c:v", "libx264" if output_format == "mp4" else "libvpx",
|
184
|
+
"-b:v", bitrate,
|
185
|
+
])
|
186
|
+
|
187
|
+
# Audio settings
|
188
|
+
if audio:
|
189
|
+
if output_format == "mp4":
|
190
|
+
cmd.extend(["-c:a", "aac", "-b:a", "128k"])
|
191
|
+
else: # webm
|
192
|
+
cmd.extend(["-c:a", "libvorbis", "-b:a", "128k"])
|
193
|
+
else:
|
194
|
+
cmd.extend(["-an"]) # No audio
|
195
|
+
|
196
|
+
# Add output file
|
197
|
+
cmd.append(temp_output)
|
198
|
+
|
199
|
+
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
200
|
+
|
201
|
+
# Get file size
|
202
|
+
file_size = os.path.getsize(temp_output)
|
203
|
+
|
204
|
+
# Get mime type
|
205
|
+
mime_type = mimetypes.guess_type(f"file.{output_format}")[0]
|
206
|
+
|
207
|
+
return temp_output, mime_type, file_size
|
208
|
+
|
209
|
+
except subprocess.SubprocessError as e:
|
210
|
+
logger.error(f"Failed to create video rendition: {str(e)}")
|
211
|
+
if os.path.exists(temp_output):
|
212
|
+
os.unlink(temp_output)
|
213
|
+
raise
|
214
|
+
|
215
|
+
def create_rendition(self, role: str, options: Dict = None) -> Optional[FileRendition]:
|
216
|
+
"""
|
217
|
+
Create a video rendition for the specified role
|
218
|
+
|
219
|
+
Args:
|
220
|
+
role: The role of the rendition
|
221
|
+
options: Additional options for creating the rendition
|
222
|
+
|
223
|
+
Returns:
|
224
|
+
FileRendition: The created rendition, or None if creation failed
|
225
|
+
"""
|
226
|
+
try:
|
227
|
+
# Get rendition settings
|
228
|
+
settings = self.default_renditions.get(role, {})
|
229
|
+
if options:
|
230
|
+
settings.update(options)
|
231
|
+
|
232
|
+
# Download the original file
|
233
|
+
source_path = self._download_original()
|
234
|
+
if not source_path:
|
235
|
+
return None
|
236
|
+
|
237
|
+
try:
|
238
|
+
temp_output = None
|
239
|
+
mime_type = None
|
240
|
+
file_size = None
|
241
|
+
|
242
|
+
# Process based on role type
|
243
|
+
if role in [RenditionRole.THUMBNAIL, RenditionRole.VIDEO_THUMBNAIL]:
|
244
|
+
# Create thumbnail image
|
245
|
+
width = settings.get('width', 300)
|
246
|
+
height = settings.get('height', 169)
|
247
|
+
time_offset = settings.get('time_offset', '00:00:03')
|
248
|
+
output_format = settings.get('format', 'jpg')
|
249
|
+
|
250
|
+
temp_output, mime_type, file_size = self._create_thumbnail(
|
251
|
+
source_path, width, height, time_offset, output_format
|
252
|
+
)
|
253
|
+
|
254
|
+
# Set filename
|
255
|
+
name, _ = os.path.splitext(self.file.filename)
|
256
|
+
filename = f"{name}_{role}.{output_format}"
|
257
|
+
category = 'image' # Thumbnails are images
|
258
|
+
|
259
|
+
else:
|
260
|
+
# Create video rendition
|
261
|
+
temp_output, mime_type, file_size = self._create_video_rendition(
|
262
|
+
source_path, settings
|
263
|
+
)
|
264
|
+
|
265
|
+
# Set filename
|
266
|
+
name, _ = os.path.splitext(self.file.filename)
|
267
|
+
output_format = settings.get('format', 'mp4')
|
268
|
+
filename = f"{name}_{role}.{output_format}"
|
269
|
+
category = 'video'
|
270
|
+
|
271
|
+
# Save to storage
|
272
|
+
file_manager = self.file.file_manager
|
273
|
+
backend = file_manager.backend
|
274
|
+
storage_path = os.path.join(
|
275
|
+
os.path.dirname(self.file.storage_file_path),
|
276
|
+
filename
|
277
|
+
)
|
278
|
+
|
279
|
+
# Upload to storage
|
280
|
+
with open(temp_output, 'rb') as f:
|
281
|
+
backend.save(f, storage_path, mime_type)
|
282
|
+
|
283
|
+
# Create rendition record
|
284
|
+
rendition = self._create_rendition_object(
|
285
|
+
role=role,
|
286
|
+
filename=filename,
|
287
|
+
storage_path=storage_path,
|
288
|
+
content_type=mime_type,
|
289
|
+
category=category,
|
290
|
+
file_size=file_size
|
291
|
+
)
|
292
|
+
|
293
|
+
return rendition
|
294
|
+
|
295
|
+
finally:
|
296
|
+
# Clean up temporary files
|
297
|
+
if source_path and os.path.exists(source_path):
|
298
|
+
os.unlink(source_path)
|
299
|
+
if temp_output and os.path.exists(temp_output):
|
300
|
+
os.unlink(temp_output)
|
301
|
+
|
302
|
+
except Exception as e:
|
303
|
+
logger.error(f"Failed to create video rendition '{role}': {str(e)}")
|
304
|
+
return None
|
@@ -3,21 +3,4 @@ File Manager REST API endpoints
|
|
3
3
|
"""
|
4
4
|
|
5
5
|
from .fileman import on_filemanager, on_file
|
6
|
-
from .upload import
|
7
|
-
on_upload_initiate,
|
8
|
-
on_upload_finalize,
|
9
|
-
on_direct_upload,
|
10
|
-
on_download
|
11
|
-
)
|
12
|
-
|
13
|
-
__all__ = [
|
14
|
-
# File manager model endpoints
|
15
|
-
'on_filemanager',
|
16
|
-
'on_file',
|
17
|
-
|
18
|
-
# File upload/download endpoints
|
19
|
-
'on_upload_initiate',
|
20
|
-
'on_upload_finalize',
|
21
|
-
'on_direct_upload',
|
22
|
-
'on_download'
|
23
|
-
]
|
6
|
+
from .upload import *
|
mojo/apps/fileman/rest/upload.py
CHANGED
@@ -1,12 +1,7 @@
|
|
1
1
|
from mojo import decorators as md
|
2
2
|
from mojo import JsonResponse
|
3
|
+
import mojo.errors
|
3
4
|
from mojo.apps.fileman.models import File, FileManager
|
4
|
-
from mojo.apps.fileman.utils.upload import (
|
5
|
-
initiate_upload,
|
6
|
-
finalize_upload,
|
7
|
-
direct_upload,
|
8
|
-
get_download_url
|
9
|
-
)
|
10
5
|
|
11
6
|
|
12
7
|
@md.POST('upload/initiate')
|
@@ -20,40 +15,35 @@ def on_upload_initiate(request):
|
|
20
15
|
{
|
21
16
|
"filename": "document.pdf",
|
22
17
|
"content_type": "application/pdf",
|
23
|
-
"
|
18
|
+
"file_size": 1024000
|
24
19
|
}
|
25
20
|
],
|
26
|
-
"
|
27
|
-
"
|
21
|
+
"file_manager": 123, // optional
|
22
|
+
"group": 456, // optional
|
23
|
+
"user": 789, // optional
|
28
24
|
"metadata": { // optional global metadata
|
29
25
|
"source": "web_upload",
|
30
26
|
"category": "documents"
|
31
27
|
}
|
32
28
|
}
|
33
29
|
"""
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
}
|
52
|
-
}
|
53
|
-
"""
|
54
|
-
response_data = finalize_upload(request, request.DATA)
|
55
|
-
status_code = response_data.pop('status_code', 200)
|
56
|
-
return JsonResponse(response_data, status=status_code)
|
30
|
+
# first we need to get the correct file manager
|
31
|
+
file_manager = FileManager.get_from_request(request)
|
32
|
+
if file_manager is None:
|
33
|
+
raise mojo.errors.ValueException("No file manager found")
|
34
|
+
# new lets create a new file
|
35
|
+
file = File(
|
36
|
+
filename=request.DATA['filename'],
|
37
|
+
content_type=request.DATA['content_type'],
|
38
|
+
file_size=request.DATA['file_size'],
|
39
|
+
file_manager=file_manager,
|
40
|
+
group=file_manager.group,
|
41
|
+
user=request.user)
|
42
|
+
file.on_rest_pre_save({}, True)
|
43
|
+
file.mark_as_uploading()
|
44
|
+
file.save()
|
45
|
+
file.request_upload_url()
|
46
|
+
return file.on_rest_get(request, "upload")
|
57
47
|
|
58
48
|
|
59
49
|
@md.POST('upload/<str:upload_token>')
|