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,403 @@
|
|
1
|
+
import os
|
2
|
+
import subprocess
|
3
|
+
import io
|
4
|
+
import logging
|
5
|
+
import mimetypes
|
6
|
+
import tempfile
|
7
|
+
from typing import Dict, Optional, Tuple, Union, BinaryIO, List
|
8
|
+
import shutil
|
9
|
+
from PIL import Image
|
10
|
+
|
11
|
+
from mojo.apps.fileman.models import File, FileRendition
|
12
|
+
from mojo.apps.fileman.renderer.base import BaseRenderer, RenditionRole
|
13
|
+
from mojo.apps.fileman.renderer.utils import get_audio_duration
|
14
|
+
|
15
|
+
logger = logging.getLogger(__name__)
|
16
|
+
|
17
|
+
class AudioRenderer(BaseRenderer):
|
18
|
+
"""
|
19
|
+
Renderer for audio files
|
20
|
+
|
21
|
+
Creates various renditions like thumbnails, previews, and different formats
|
22
|
+
"""
|
23
|
+
|
24
|
+
# Audio file categories
|
25
|
+
supported_categories = ['audio']
|
26
|
+
|
27
|
+
# Default rendition definitions with options
|
28
|
+
default_renditions = {
|
29
|
+
RenditionRole.AUDIO_THUMBNAIL: {
|
30
|
+
'width': 300,
|
31
|
+
'height': 300,
|
32
|
+
'format': 'jpg',
|
33
|
+
'waveform': False, # If true, generates a waveform image instead of using embedded artwork
|
34
|
+
},
|
35
|
+
RenditionRole.THUMBNAIL: {
|
36
|
+
'width': 200,
|
37
|
+
'height': 200,
|
38
|
+
'format': 'jpg',
|
39
|
+
'waveform': False,
|
40
|
+
},
|
41
|
+
RenditionRole.AUDIO_PREVIEW: {
|
42
|
+
'bitrate': '128k',
|
43
|
+
'format': 'mp3',
|
44
|
+
'duration': 30, # First 30 seconds as preview
|
45
|
+
},
|
46
|
+
RenditionRole.AUDIO_MP3: {
|
47
|
+
'bitrate': '192k',
|
48
|
+
'format': 'mp3',
|
49
|
+
},
|
50
|
+
}
|
51
|
+
|
52
|
+
def __init__(self, file: File):
|
53
|
+
super().__init__(file)
|
54
|
+
# Check if ffmpeg is available
|
55
|
+
self._check_ffmpeg()
|
56
|
+
|
57
|
+
def _check_ffmpeg(self):
|
58
|
+
"""Check if ffmpeg is available in the system"""
|
59
|
+
try:
|
60
|
+
subprocess.run(["ffmpeg", "-version"],
|
61
|
+
stdout=subprocess.PIPE,
|
62
|
+
stderr=subprocess.PIPE,
|
63
|
+
check=True)
|
64
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
65
|
+
logger.warning("ffmpeg is not available. Audio rendering may not work properly.")
|
66
|
+
|
67
|
+
def _download_original(self) -> Union[str, None]:
|
68
|
+
"""
|
69
|
+
Download the original file to a temporary location
|
70
|
+
|
71
|
+
Returns:
|
72
|
+
str: Path to the downloaded file, or None if download failed
|
73
|
+
"""
|
74
|
+
try:
|
75
|
+
file_manager = self.file.file_manager
|
76
|
+
backend = file_manager.backend
|
77
|
+
|
78
|
+
# Get file extension
|
79
|
+
_, ext = os.path.splitext(self.file.filename)
|
80
|
+
temp_path = self.get_temp_path(ext)
|
81
|
+
|
82
|
+
# Download file from storage
|
83
|
+
with open(temp_path, 'wb') as f:
|
84
|
+
backend.download(self.file.storage_file_path, f)
|
85
|
+
|
86
|
+
return temp_path
|
87
|
+
except Exception as e:
|
88
|
+
logger.error(f"Failed to download original audio file: {str(e)}")
|
89
|
+
return None
|
90
|
+
|
91
|
+
def _extract_audio_cover(self, source_path: str, width: int, height: int,
|
92
|
+
output_format: str) -> Tuple[str, str, int]:
|
93
|
+
"""
|
94
|
+
Extract album artwork from audio file
|
95
|
+
|
96
|
+
Args:
|
97
|
+
source_path: Path to the source audio
|
98
|
+
width: Target width
|
99
|
+
height: Target height
|
100
|
+
output_format: Output format (jpg, png)
|
101
|
+
|
102
|
+
Returns:
|
103
|
+
Tuple[str, str, int]: (Output path, mime type, file size) or (None, None, 0) if extraction failed
|
104
|
+
"""
|
105
|
+
temp_output = self.get_temp_path(f".{output_format}")
|
106
|
+
|
107
|
+
try:
|
108
|
+
# Use ffmpeg to extract album art
|
109
|
+
cmd = [
|
110
|
+
"ffmpeg",
|
111
|
+
"-y", # Overwrite output files
|
112
|
+
"-i", source_path, # Input file
|
113
|
+
"-an", # No audio
|
114
|
+
"-vcodec", "copy", # Copy video codec (album art)
|
115
|
+
temp_output # Output file
|
116
|
+
]
|
117
|
+
|
118
|
+
# Run command, but don't fail if artwork doesn't exist
|
119
|
+
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
120
|
+
|
121
|
+
# Check if the output file exists and has content
|
122
|
+
if os.path.exists(temp_output) and os.path.getsize(temp_output) > 0:
|
123
|
+
# Resize the image to the requested dimensions
|
124
|
+
from PIL import Image
|
125
|
+
with Image.open(temp_output) as img:
|
126
|
+
img.thumbnail((width, height), Image.Resampling.LANCZOS)
|
127
|
+
img.save(temp_output, format=output_format.upper())
|
128
|
+
|
129
|
+
# Get file size
|
130
|
+
file_size = os.path.getsize(temp_output)
|
131
|
+
|
132
|
+
# Get mime type
|
133
|
+
mime_type = mimetypes.guess_type(f"file.{output_format}")[0]
|
134
|
+
|
135
|
+
return temp_output, mime_type, file_size
|
136
|
+
else:
|
137
|
+
# If no artwork, create a default audio thumbnail
|
138
|
+
return self._create_default_audio_thumbnail(width, height, output_format)
|
139
|
+
|
140
|
+
except Exception as e:
|
141
|
+
logger.error(f"Failed to extract audio cover: {str(e)}")
|
142
|
+
return self._create_default_audio_thumbnail(width, height, output_format)
|
143
|
+
|
144
|
+
def _create_default_audio_thumbnail(self, width: int, height: int,
|
145
|
+
output_format: str) -> Tuple[str, str, int]:
|
146
|
+
"""
|
147
|
+
Create a default audio thumbnail when no album art is available
|
148
|
+
|
149
|
+
Args:
|
150
|
+
width: Target width
|
151
|
+
height: Target height
|
152
|
+
output_format: Output format (jpg, png)
|
153
|
+
|
154
|
+
Returns:
|
155
|
+
Tuple[str, str, int]: (Output path, mime type, file size)
|
156
|
+
"""
|
157
|
+
temp_output = self.get_temp_path(f".{output_format}")
|
158
|
+
|
159
|
+
try:
|
160
|
+
# Create a simple gradient image with a music note icon
|
161
|
+
from PIL import Image, ImageDraw, ImageFont
|
162
|
+
|
163
|
+
# Create a gradient background
|
164
|
+
img = Image.new('RGB', (width, height), color=(60, 60, 60))
|
165
|
+
draw = ImageDraw.Draw(img)
|
166
|
+
|
167
|
+
# Draw a music note icon or text
|
168
|
+
try:
|
169
|
+
# Draw a circle in the center
|
170
|
+
circle_x, circle_y = width // 2, height // 2
|
171
|
+
circle_radius = min(width, height) // 4
|
172
|
+
draw.ellipse(
|
173
|
+
(circle_x - circle_radius, circle_y - circle_radius,
|
174
|
+
circle_x + circle_radius, circle_y + circle_radius),
|
175
|
+
fill=(100, 100, 100)
|
176
|
+
)
|
177
|
+
|
178
|
+
# Draw audio file name
|
179
|
+
name, _ = os.path.splitext(self.file.filename)
|
180
|
+
draw.text((width//2, height//2), name, fill=(220, 220, 220),
|
181
|
+
anchor="mm")
|
182
|
+
except Exception:
|
183
|
+
# If text drawing fails, just use the background
|
184
|
+
pass
|
185
|
+
|
186
|
+
# Save the image
|
187
|
+
img.save(temp_output, format=output_format.upper())
|
188
|
+
|
189
|
+
# Get file size
|
190
|
+
file_size = os.path.getsize(temp_output)
|
191
|
+
|
192
|
+
# Get mime type
|
193
|
+
mime_type = mimetypes.guess_type(f"file.{output_format}")[0]
|
194
|
+
|
195
|
+
return temp_output, mime_type, file_size
|
196
|
+
|
197
|
+
except Exception as e:
|
198
|
+
logger.error(f"Failed to create default audio thumbnail: {str(e)}")
|
199
|
+
# Create a very basic fallback
|
200
|
+
img = Image.new('RGB', (width, height), color=(80, 80, 80))
|
201
|
+
img.save(temp_output, format=output_format.upper())
|
202
|
+
file_size = os.path.getsize(temp_output)
|
203
|
+
mime_type = mimetypes.guess_type(f"file.{output_format}")[0]
|
204
|
+
return temp_output, mime_type, file_size
|
205
|
+
|
206
|
+
def _create_waveform_image(self, source_path: str, width: int, height: int,
|
207
|
+
output_format: str) -> Tuple[str, str, int]:
|
208
|
+
"""
|
209
|
+
Create a waveform visualization of the audio file
|
210
|
+
|
211
|
+
Args:
|
212
|
+
source_path: Path to the source audio
|
213
|
+
width: Target width
|
214
|
+
height: Target height
|
215
|
+
output_format: Output format (jpg, png)
|
216
|
+
|
217
|
+
Returns:
|
218
|
+
Tuple[str, str, int]: (Output path, mime type, file size)
|
219
|
+
"""
|
220
|
+
temp_output = self.get_temp_path(f".{output_format}")
|
221
|
+
|
222
|
+
try:
|
223
|
+
# Use ffmpeg to generate a waveform image
|
224
|
+
cmd = [
|
225
|
+
"ffmpeg",
|
226
|
+
"-y", # Overwrite output files
|
227
|
+
"-i", source_path, # Input file
|
228
|
+
"-filter_complex", f"showwavespic=s={width}x{height}:colors=white",
|
229
|
+
"-frames:v", "1",
|
230
|
+
temp_output # Output file
|
231
|
+
]
|
232
|
+
|
233
|
+
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
234
|
+
|
235
|
+
# Get file size
|
236
|
+
file_size = os.path.getsize(temp_output)
|
237
|
+
|
238
|
+
# Get mime type
|
239
|
+
mime_type = mimetypes.guess_type(f"file.{output_format}")[0]
|
240
|
+
|
241
|
+
return temp_output, mime_type, file_size
|
242
|
+
|
243
|
+
except subprocess.SubprocessError as e:
|
244
|
+
logger.error(f"Failed to create audio waveform: {str(e)}")
|
245
|
+
return self._create_default_audio_thumbnail(width, height, output_format)
|
246
|
+
|
247
|
+
def _convert_audio(self, source_path: str, options: Dict) -> Tuple[str, str, int]:
|
248
|
+
"""
|
249
|
+
Convert audio to different format with specified options
|
250
|
+
|
251
|
+
Args:
|
252
|
+
source_path: Path to the source audio
|
253
|
+
options: Conversion options
|
254
|
+
|
255
|
+
Returns:
|
256
|
+
Tuple[str, str, int]: (Output path, mime type, file size)
|
257
|
+
"""
|
258
|
+
output_format = options.get('format', 'mp3')
|
259
|
+
bitrate = options.get('bitrate', '192k')
|
260
|
+
duration = options.get('duration') # Optional duration limit in seconds
|
261
|
+
|
262
|
+
temp_output = self.get_temp_path(f".{output_format}")
|
263
|
+
|
264
|
+
try:
|
265
|
+
# Build ffmpeg command
|
266
|
+
cmd = [
|
267
|
+
"ffmpeg",
|
268
|
+
"-y", # Overwrite output files
|
269
|
+
"-i", source_path, # Input file
|
270
|
+
]
|
271
|
+
|
272
|
+
# Add duration limit if specified
|
273
|
+
if duration:
|
274
|
+
cmd.extend(["-t", str(duration)])
|
275
|
+
|
276
|
+
# Audio settings
|
277
|
+
cmd.extend([
|
278
|
+
"-b:a", bitrate,
|
279
|
+
"-ar", "44100", # Sample rate
|
280
|
+
])
|
281
|
+
|
282
|
+
# Specific format settings
|
283
|
+
if output_format == 'mp3':
|
284
|
+
cmd.extend(["-c:a", "libmp3lame"])
|
285
|
+
elif output_format == 'ogg':
|
286
|
+
cmd.extend(["-c:a", "libvorbis"])
|
287
|
+
elif output_format == 'aac' or output_format == 'm4a':
|
288
|
+
cmd.extend(["-c:a", "aac"])
|
289
|
+
|
290
|
+
# Add output file
|
291
|
+
cmd.append(temp_output)
|
292
|
+
|
293
|
+
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
294
|
+
|
295
|
+
# Get file size
|
296
|
+
file_size = os.path.getsize(temp_output)
|
297
|
+
|
298
|
+
# Get mime type
|
299
|
+
mime_type = mimetypes.guess_type(f"file.{output_format}")[0]
|
300
|
+
|
301
|
+
return temp_output, mime_type, file_size
|
302
|
+
|
303
|
+
except subprocess.SubprocessError as e:
|
304
|
+
logger.error(f"Failed to convert audio: {str(e)}")
|
305
|
+
if os.path.exists(temp_output):
|
306
|
+
os.unlink(temp_output)
|
307
|
+
raise
|
308
|
+
|
309
|
+
def create_rendition(self, role: str, options: Dict = None) -> Optional[FileRendition]:
|
310
|
+
"""
|
311
|
+
Create an audio rendition for the specified role
|
312
|
+
|
313
|
+
Args:
|
314
|
+
role: The role of the rendition
|
315
|
+
options: Additional options for creating the rendition
|
316
|
+
|
317
|
+
Returns:
|
318
|
+
FileRendition: The created rendition, or None if creation failed
|
319
|
+
"""
|
320
|
+
try:
|
321
|
+
# Get rendition settings
|
322
|
+
settings = self.default_renditions.get(role, {})
|
323
|
+
if options:
|
324
|
+
settings.update(options)
|
325
|
+
|
326
|
+
# Download the original file
|
327
|
+
source_path = self._download_original()
|
328
|
+
if not source_path:
|
329
|
+
return None
|
330
|
+
|
331
|
+
try:
|
332
|
+
temp_output = None
|
333
|
+
mime_type = None
|
334
|
+
file_size = None
|
335
|
+
|
336
|
+
# Process based on role type
|
337
|
+
if role in [RenditionRole.THUMBNAIL, RenditionRole.AUDIO_THUMBNAIL]:
|
338
|
+
# Create thumbnail image (either from album art or waveform)
|
339
|
+
width = settings.get('width', 300)
|
340
|
+
height = settings.get('height', 300)
|
341
|
+
output_format = settings.get('format', 'jpg')
|
342
|
+
use_waveform = settings.get('waveform', False)
|
343
|
+
|
344
|
+
if use_waveform:
|
345
|
+
temp_output, mime_type, file_size = self._create_waveform_image(
|
346
|
+
source_path, width, height, output_format
|
347
|
+
)
|
348
|
+
else:
|
349
|
+
temp_output, mime_type, file_size = self._extract_audio_cover(
|
350
|
+
source_path, width, height, output_format
|
351
|
+
)
|
352
|
+
|
353
|
+
# Set filename
|
354
|
+
name, _ = os.path.splitext(self.file.filename)
|
355
|
+
filename = f"{name}_{role}.{output_format}"
|
356
|
+
category = 'image' # Thumbnails are images
|
357
|
+
|
358
|
+
else:
|
359
|
+
# Create audio rendition
|
360
|
+
temp_output, mime_type, file_size = self._convert_audio(
|
361
|
+
source_path, settings
|
362
|
+
)
|
363
|
+
|
364
|
+
# Set filename
|
365
|
+
name, _ = os.path.splitext(self.file.filename)
|
366
|
+
output_format = settings.get('format', 'mp3')
|
367
|
+
filename = f"{name}_{role}.{output_format}"
|
368
|
+
category = 'audio'
|
369
|
+
|
370
|
+
# Save to storage
|
371
|
+
file_manager = self.file.file_manager
|
372
|
+
backend = file_manager.backend
|
373
|
+
storage_path = os.path.join(
|
374
|
+
os.path.dirname(self.file.storage_file_path),
|
375
|
+
filename
|
376
|
+
)
|
377
|
+
|
378
|
+
# Upload to storage
|
379
|
+
with open(temp_output, 'rb') as f:
|
380
|
+
backend.save(f, storage_path, mime_type)
|
381
|
+
|
382
|
+
# Create rendition record
|
383
|
+
rendition = self._create_rendition_object(
|
384
|
+
role=role,
|
385
|
+
filename=filename,
|
386
|
+
storage_path=storage_path,
|
387
|
+
content_type=mime_type,
|
388
|
+
category=category,
|
389
|
+
file_size=file_size
|
390
|
+
)
|
391
|
+
|
392
|
+
return rendition
|
393
|
+
|
394
|
+
finally:
|
395
|
+
# Clean up temporary files
|
396
|
+
if source_path and os.path.exists(source_path):
|
397
|
+
os.unlink(source_path)
|
398
|
+
if temp_output and os.path.exists(temp_output):
|
399
|
+
os.unlink(temp_output)
|
400
|
+
|
401
|
+
except Exception as e:
|
402
|
+
logger.error(f"Failed to create audio rendition '{role}': {str(e)}")
|
403
|
+
return None
|
@@ -0,0 +1,205 @@
|
|
1
|
+
import os
|
2
|
+
import logging
|
3
|
+
from abc import ABC, abstractmethod
|
4
|
+
from typing import Dict, List, Optional, Tuple, Any, Union
|
5
|
+
from django.conf import settings
|
6
|
+
from mojo.apps.fileman.models import File, FileRendition
|
7
|
+
|
8
|
+
logger = logging.getLogger(__name__)
|
9
|
+
|
10
|
+
class RenditionRole:
|
11
|
+
"""
|
12
|
+
Predefined roles for file renditions
|
13
|
+
"""
|
14
|
+
# Common roles
|
15
|
+
ORIGINAL = 'original'
|
16
|
+
THUMBNAIL = 'thumbnail'
|
17
|
+
PREVIEW = 'preview'
|
18
|
+
|
19
|
+
# Image-specific roles
|
20
|
+
THUMBNAIL_SM = 'thumbnail_sm'
|
21
|
+
THUMBNAIL_MD = 'thumbnail_md'
|
22
|
+
THUMBNAIL_LG = 'thumbnail_lg'
|
23
|
+
SQUARE_SM = 'square_sm'
|
24
|
+
SQUARE_MD = 'square_md'
|
25
|
+
SQUARE_LG = 'square_lg'
|
26
|
+
|
27
|
+
# Video-specific roles
|
28
|
+
VIDEO_THUMBNAIL = 'video_thumbnail'
|
29
|
+
VIDEO_PREVIEW = 'video_preview'
|
30
|
+
VIDEO_MP4 = 'video_mp4'
|
31
|
+
VIDEO_WEBM = 'video_webm'
|
32
|
+
|
33
|
+
# Document-specific roles
|
34
|
+
DOCUMENT_THUMBNAIL = 'document_thumbnail'
|
35
|
+
DOCUMENT_PREVIEW = 'document_preview'
|
36
|
+
DOCUMENT_PDF = 'document_pdf'
|
37
|
+
|
38
|
+
# Audio-specific roles
|
39
|
+
AUDIO_THUMBNAIL = 'audio_thumbnail'
|
40
|
+
AUDIO_PREVIEW = 'audio_preview'
|
41
|
+
AUDIO_MP3 = 'audio_mp3'
|
42
|
+
|
43
|
+
|
44
|
+
class BaseRenderer(ABC):
|
45
|
+
"""
|
46
|
+
Base class for file renderers
|
47
|
+
|
48
|
+
A renderer creates different versions (renditions) of a file based on
|
49
|
+
predefined roles. Each renderer supports specific file categories and
|
50
|
+
provides implementations for creating renditions.
|
51
|
+
"""
|
52
|
+
|
53
|
+
# The file categories this renderer supports
|
54
|
+
supported_categories = []
|
55
|
+
|
56
|
+
# Default rendition definitions:
|
57
|
+
# mapping of role -> (width, height, options)
|
58
|
+
default_renditions = {}
|
59
|
+
|
60
|
+
def __init__(self, file: File):
|
61
|
+
"""
|
62
|
+
Initialize renderer with a file
|
63
|
+
|
64
|
+
Args:
|
65
|
+
file: The original file to create renditions from
|
66
|
+
"""
|
67
|
+
self.file = file
|
68
|
+
self.renditions = {}
|
69
|
+
self._load_existing_renditions()
|
70
|
+
|
71
|
+
def _load_existing_renditions(self):
|
72
|
+
"""Load existing renditions for this file"""
|
73
|
+
for rendition in FileRendition.objects.filter(original_file=self.file):
|
74
|
+
self.renditions[rendition.role] = rendition
|
75
|
+
|
76
|
+
@classmethod
|
77
|
+
def supports_file(cls, file: File) -> bool:
|
78
|
+
"""
|
79
|
+
Check if this renderer supports the given file
|
80
|
+
|
81
|
+
Args:
|
82
|
+
file: The file to check
|
83
|
+
|
84
|
+
Returns:
|
85
|
+
bool: True if this renderer supports the file, False otherwise
|
86
|
+
"""
|
87
|
+
return file.category in cls.supported_categories
|
88
|
+
|
89
|
+
@abstractmethod
|
90
|
+
def create_rendition(self, role: str, options: Dict = None) -> Optional[FileRendition]:
|
91
|
+
"""
|
92
|
+
Create a rendition for the specified role
|
93
|
+
|
94
|
+
Args:
|
95
|
+
role: The role of the rendition (e.g., 'thumbnail', 'preview')
|
96
|
+
options: Additional options for creating the rendition
|
97
|
+
|
98
|
+
Returns:
|
99
|
+
FileRendition: The created rendition, or None if creation failed
|
100
|
+
"""
|
101
|
+
pass
|
102
|
+
|
103
|
+
def get_rendition(self, role: str, create_if_missing: bool = True) -> Optional[FileRendition]:
|
104
|
+
"""
|
105
|
+
Get a rendition for the specified role
|
106
|
+
|
107
|
+
Args:
|
108
|
+
role: The role of the rendition
|
109
|
+
create_if_missing: Whether to create the rendition if it doesn't exist
|
110
|
+
|
111
|
+
Returns:
|
112
|
+
FileRendition: The rendition, or None if not found and not created
|
113
|
+
"""
|
114
|
+
if role in self.renditions:
|
115
|
+
return self.renditions[role]
|
116
|
+
|
117
|
+
if create_if_missing:
|
118
|
+
options = self.default_renditions.get(role, {})
|
119
|
+
rendition = self.create_rendition(role, options)
|
120
|
+
if rendition:
|
121
|
+
self.renditions[role] = rendition
|
122
|
+
return rendition
|
123
|
+
|
124
|
+
return None
|
125
|
+
|
126
|
+
def create_all_renditions(self) -> List[FileRendition]:
|
127
|
+
"""
|
128
|
+
Create all default renditions for this file
|
129
|
+
|
130
|
+
Returns:
|
131
|
+
List[FileRendition]: List of created renditions
|
132
|
+
"""
|
133
|
+
results = []
|
134
|
+
for role, options in self.default_renditions.items():
|
135
|
+
rendition = self.get_rendition(role)
|
136
|
+
if rendition:
|
137
|
+
results.append(rendition)
|
138
|
+
return results
|
139
|
+
|
140
|
+
def cleanup_renditions(self):
|
141
|
+
"""
|
142
|
+
Remove all renditions for this file
|
143
|
+
"""
|
144
|
+
FileRendition.objects.filter(original_file=self.file).delete()
|
145
|
+
self.renditions = {}
|
146
|
+
|
147
|
+
def _create_rendition_object(self, role: str, filename: str, storage_path: str,
|
148
|
+
content_type: str, category: str, file_size: int = None) -> FileRendition:
|
149
|
+
"""
|
150
|
+
Create a FileRendition object in the database
|
151
|
+
|
152
|
+
Args:
|
153
|
+
role: The role of the rendition
|
154
|
+
filename: The filename of the rendition
|
155
|
+
storage_path: The storage path of the rendition
|
156
|
+
content_type: The MIME type of the rendition
|
157
|
+
category: The category of the rendition
|
158
|
+
file_size: The size of the rendition in bytes
|
159
|
+
|
160
|
+
Returns:
|
161
|
+
FileRendition: The created rendition object
|
162
|
+
"""
|
163
|
+
rendition = FileRendition(
|
164
|
+
original_file=self.file,
|
165
|
+
role=role,
|
166
|
+
filename=filename,
|
167
|
+
storage_path=storage_path,
|
168
|
+
content_type=content_type,
|
169
|
+
category=category,
|
170
|
+
file_size=file_size,
|
171
|
+
upload_status=FileRendition.COMPLETED
|
172
|
+
)
|
173
|
+
rendition.save()
|
174
|
+
return rendition
|
175
|
+
|
176
|
+
def get_temp_path(self, suffix: str = '') -> str:
|
177
|
+
"""
|
178
|
+
Get a temporary file path for processing
|
179
|
+
|
180
|
+
Args:
|
181
|
+
suffix: Optional suffix for the temp file (e.g., '.jpg')
|
182
|
+
|
183
|
+
Returns:
|
184
|
+
str: Path to a temporary file
|
185
|
+
"""
|
186
|
+
import tempfile
|
187
|
+
temp_dir = getattr(settings, 'MOJO_TEMP_DIR', None)
|
188
|
+
if temp_dir:
|
189
|
+
os.makedirs(temp_dir, exist_ok=True)
|
190
|
+
return os.path.join(temp_dir, f"{self.file.id}_{suffix}")
|
191
|
+
return tempfile.mktemp(suffix=suffix)
|
192
|
+
|
193
|
+
@staticmethod
|
194
|
+
def get_renderer_for_file(file: File) -> Optional['BaseRenderer']:
|
195
|
+
"""
|
196
|
+
Get the appropriate renderer for a file
|
197
|
+
|
198
|
+
Args:
|
199
|
+
file: The file to get a renderer for
|
200
|
+
|
201
|
+
Returns:
|
202
|
+
BaseRenderer: The renderer instance, or None if no renderer supports the file
|
203
|
+
"""
|
204
|
+
from mojo.apps.fileman.renderer import get_renderer_for_file
|
205
|
+
return get_renderer_for_file(file)
|