django-nativemojo 0.1.10__py3-none-any.whl → 0.1.16__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.16.dist-info/METADATA +138 -0
- django_nativemojo-0.1.16.dist-info/RECORD +302 -0
- 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 +651 -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/migrations/0006_add_device_tracking_models.py +72 -0
- mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
- mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
- mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
- mojo/apps/account/migrations/0010_group_avatar.py +20 -0
- mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
- mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
- mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
- mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
- mojo/apps/account/models/__init__.py +2 -0
- mojo/apps/account/models/device.py +281 -0
- mojo/apps/account/models/group.py +319 -15
- mojo/apps/account/models/member.py +29 -5
- mojo/apps/account/models/push/__init__.py +4 -0
- mojo/apps/account/models/push/config.py +112 -0
- mojo/apps/account/models/push/delivery.py +93 -0
- mojo/apps/account/models/push/device.py +66 -0
- mojo/apps/account/models/push/template.py +99 -0
- mojo/apps/account/models/user.py +369 -19
- mojo/apps/account/rest/__init__.py +2 -0
- mojo/apps/account/rest/device.py +39 -0
- mojo/apps/account/rest/group.py +9 -0
- mojo/apps/account/rest/push.py +187 -0
- mojo/apps/account/rest/user.py +100 -6
- mojo/apps/account/services/__init__.py +1 -0
- mojo/apps/account/services/push.py +363 -0
- mojo/apps/aws/migrations/0001_initial.py +206 -0
- mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
- mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
- mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
- mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
- mojo/apps/aws/models/__init__.py +19 -0
- mojo/apps/aws/models/email_attachment.py +99 -0
- mojo/apps/aws/models/email_domain.py +218 -0
- mojo/apps/aws/models/email_template.py +132 -0
- mojo/apps/aws/models/incoming_email.py +197 -0
- mojo/apps/aws/models/mailbox.py +288 -0
- mojo/apps/aws/models/sent_message.py +175 -0
- mojo/apps/aws/rest/__init__.py +7 -0
- mojo/apps/aws/rest/email.py +33 -0
- mojo/apps/aws/rest/email_ops.py +183 -0
- mojo/apps/aws/rest/messages.py +32 -0
- mojo/apps/aws/rest/s3.py +64 -0
- mojo/apps/aws/rest/send.py +101 -0
- mojo/apps/aws/rest/sns.py +403 -0
- mojo/apps/aws/rest/templates.py +19 -0
- mojo/apps/aws/services/__init__.py +32 -0
- mojo/apps/aws/services/email.py +390 -0
- mojo/apps/aws/services/email_ops.py +548 -0
- mojo/apps/docit/__init__.py +6 -0
- mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
- mojo/apps/docit/markdown_plugins/toc.py +12 -0
- mojo/apps/docit/migrations/0001_initial.py +113 -0
- mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
- mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
- mojo/apps/docit/models/__init__.py +17 -0
- mojo/apps/docit/models/asset.py +231 -0
- mojo/apps/docit/models/book.py +227 -0
- mojo/apps/docit/models/page.py +319 -0
- mojo/apps/docit/models/page_revision.py +203 -0
- mojo/apps/docit/rest/__init__.py +10 -0
- mojo/apps/docit/rest/asset.py +17 -0
- mojo/apps/docit/rest/book.py +22 -0
- mojo/apps/docit/rest/page.py +22 -0
- mojo/apps/docit/rest/page_revision.py +17 -0
- mojo/apps/docit/services/__init__.py +11 -0
- mojo/apps/docit/services/docit.py +315 -0
- mojo/apps/docit/services/markdown.py +44 -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 +409 -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 +240 -58
- mojo/apps/fileman/models/manager.py +427 -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/migrations/0007_event_uid.py +18 -0
- mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
- mojo/apps/incident/migrations/0009_incident_status.py +18 -0
- mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
- mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
- mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
- mojo/apps/incident/models/__init__.py +2 -0
- mojo/apps/incident/models/event.py +35 -0
- mojo/apps/incident/models/history.py +36 -0
- mojo/apps/incident/models/incident.py +3 -1
- mojo/apps/incident/models/ticket.py +62 -0
- mojo/apps/incident/reporter.py +21 -1
- mojo/apps/incident/rest/__init__.py +1 -0
- mojo/apps/incident/rest/event.py +7 -1
- mojo/apps/incident/rest/ticket.py +43 -0
- mojo/apps/jobs/__init__.py +489 -0
- mojo/apps/jobs/adapters.py +24 -0
- mojo/apps/jobs/cli.py +616 -0
- mojo/apps/jobs/daemon.py +370 -0
- mojo/apps/jobs/examples/sample_jobs.py +376 -0
- mojo/apps/jobs/examples/webhook_examples.py +203 -0
- mojo/apps/jobs/handlers/__init__.py +5 -0
- mojo/apps/jobs/handlers/webhook.py +317 -0
- mojo/apps/jobs/job_engine.py +734 -0
- mojo/apps/jobs/keys.py +203 -0
- mojo/apps/jobs/local_queue.py +363 -0
- mojo/apps/jobs/management/__init__.py +3 -0
- mojo/apps/jobs/management/commands/__init__.py +3 -0
- mojo/apps/jobs/manager.py +1327 -0
- mojo/apps/jobs/migrations/0001_initial.py +97 -0
- mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
- mojo/apps/jobs/models/__init__.py +6 -0
- mojo/apps/jobs/models/job.py +441 -0
- mojo/apps/jobs/rest/__init__.py +2 -0
- mojo/apps/jobs/rest/control.py +466 -0
- mojo/apps/jobs/rest/jobs.py +421 -0
- mojo/apps/jobs/scheduler.py +571 -0
- mojo/apps/jobs/services/__init__.py +6 -0
- mojo/apps/jobs/services/job_actions.py +465 -0
- mojo/apps/jobs/settings.py +209 -0
- mojo/apps/logit/migrations/0004_alter_log_level.py +18 -0
- mojo/apps/logit/models/log.py +7 -1
- mojo/apps/metrics/__init__.py +8 -1
- mojo/apps/metrics/redis_metrics.py +198 -0
- mojo/apps/metrics/rest/__init__.py +3 -0
- mojo/apps/metrics/rest/categories.py +266 -0
- mojo/apps/metrics/rest/helpers.py +48 -0
- mojo/apps/metrics/rest/permissions.py +99 -0
- mojo/apps/metrics/rest/values.py +277 -0
- mojo/apps/metrics/utils.py +19 -2
- mojo/decorators/auth.py +6 -1
- mojo/decorators/http.py +47 -3
- mojo/helpers/aws/__init__.py +45 -0
- mojo/helpers/aws/ec2.py +804 -0
- mojo/helpers/aws/iam.py +748 -0
- mojo/helpers/aws/inbound_email.py +309 -0
- mojo/helpers/aws/kms.py +413 -0
- mojo/helpers/aws/s3.py +451 -11
- mojo/helpers/aws/ses.py +483 -0
- mojo/helpers/aws/ses_domain.py +959 -0
- mojo/helpers/aws/sns.py +461 -0
- mojo/helpers/crypto/__init__.py +1 -1
- mojo/helpers/crypto/utils.py +15 -0
- mojo/helpers/dates.py +18 -0
- mojo/helpers/location/__init__.py +2 -0
- mojo/helpers/location/countries.py +262 -0
- mojo/helpers/location/geolocation.py +196 -0
- mojo/helpers/logit.py +37 -0
- mojo/helpers/redis/__init__.py +2 -0
- mojo/helpers/redis/adapter.py +606 -0
- mojo/helpers/redis/client.py +48 -0
- mojo/helpers/redis/pool.py +225 -0
- mojo/helpers/request.py +8 -0
- mojo/helpers/response.py +14 -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/auth.py +1 -1
- mojo/middleware/cors.py +40 -0
- mojo/middleware/logging.py +131 -12
- mojo/middleware/mojo.py +10 -0
- mojo/models/rest.py +494 -65
- mojo/models/secrets.py +98 -3
- mojo/serializers/__init__.py +106 -0
- mojo/serializers/core/__init__.py +90 -0
- mojo/serializers/core/cache/__init__.py +121 -0
- mojo/serializers/core/cache/backends.py +518 -0
- mojo/serializers/core/cache/base.py +102 -0
- mojo/serializers/core/cache/disabled.py +181 -0
- mojo/serializers/core/cache/memory.py +287 -0
- mojo/serializers/core/cache/redis.py +533 -0
- mojo/serializers/core/cache/utils.py +454 -0
- mojo/serializers/core/manager.py +550 -0
- mojo/serializers/core/serializer.py +475 -0
- mojo/serializers/examples/settings.py +322 -0
- mojo/serializers/formats/csv.py +393 -0
- mojo/serializers/formats/localizers.py +509 -0
- mojo/serializers/{models.py → simple.py} +38 -15
- mojo/serializers/suggested_improvements.md +388 -0
- testit/client.py +1 -1
- testit/helpers.py +35 -4
- testit/runner.py +23 -6
- django_nativemojo-0.1.10.dist-info/METADATA +0 -96
- django_nativemojo-0.1.10.dist-info/RECORD +0 -194
- mojo/apps/metrics/rest/db.py +0 -0
- mojo/apps/notify/README.md +0 -91
- mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
- mojo/apps/notify/admin.py +0 -52
- mojo/apps/notify/handlers/example_handlers.py +0 -516
- mojo/apps/notify/handlers/ses/__init__.py +0 -25
- mojo/apps/notify/handlers/ses/bounce.py +0 -0
- mojo/apps/notify/handlers/ses/complaint.py +0 -25
- mojo/apps/notify/handlers/ses/message.py +0 -86
- mojo/apps/notify/management/commands/__init__.py +0 -1
- mojo/apps/notify/management/commands/process_notifications.py +0 -370
- mojo/apps/notify/mod +0 -0
- mojo/apps/notify/models/__init__.py +0 -12
- mojo/apps/notify/models/account.py +0 -128
- mojo/apps/notify/models/attachment.py +0 -24
- mojo/apps/notify/models/bounce.py +0 -68
- mojo/apps/notify/models/complaint.py +0 -40
- mojo/apps/notify/models/inbox.py +0 -113
- mojo/apps/notify/models/inbox_message.py +0 -173
- mojo/apps/notify/models/outbox.py +0 -129
- mojo/apps/notify/models/outbox_message.py +0 -288
- mojo/apps/notify/models/template.py +0 -30
- mojo/apps/notify/providers/aws.py +0 -73
- mojo/apps/notify/rest/ses.py +0 -0
- mojo/apps/notify/utils/__init__.py +0 -2
- mojo/apps/notify/utils/notifications.py +0 -404
- mojo/apps/notify/utils/parsing.py +0 -202
- mojo/apps/notify/utils/render.py +0 -144
- mojo/apps/tasks/README.md +0 -118
- mojo/apps/tasks/__init__.py +0 -11
- mojo/apps/tasks/manager.py +0 -489
- mojo/apps/tasks/rest/__init__.py +0 -2
- mojo/apps/tasks/rest/hooks.py +0 -0
- mojo/apps/tasks/rest/tasks.py +0 -62
- mojo/apps/tasks/runner.py +0 -174
- mojo/apps/tasks/tq_handlers.py +0 -14
- mojo/helpers/aws/setup_email.py +0 -0
- mojo/helpers/redis.py +0 -10
- mojo/models/meta.py +0 -262
- 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.16.dist-info}/LICENSE +0 -0
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/NOTICE +0 -0
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/WHEEL +0 -0
- /mojo/apps/{notify → aws}/__init__.py +0 -0
- /mojo/apps/{notify/handlers → aws/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/management → docit/markdown_plugins}/__init__.py +0 -0
- /mojo/apps/{notify/providers → docit/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/rest → fileman/migrations}/__init__.py +0 -0
- /mojo/{ws4redis/servers → apps/jobs/examples}/__init__.py +0 -0
- /mojo/apps/{fileman/models/render.py → jobs/migrations/__init__.py} +0 -0
- /mojo/{serializers → rest}/openapi.py +0 -0
- /mojo/{apps/fileman/rest/__init__ → serializers/formats/__init__.py} +0 -0
@@ -0,0 +1,111 @@
|
|
1
|
+
import logging
|
2
|
+
from typing import Dict, List, Optional, Tuple, Any, Union
|
3
|
+
|
4
|
+
# Import renderer classes
|
5
|
+
from mojo.apps.fileman.models import File, FileRendition
|
6
|
+
from mojo.apps.fileman.renderer.base import BaseRenderer, RenditionRole
|
7
|
+
from mojo.apps.fileman.renderer.image import ImageRenderer
|
8
|
+
from mojo.apps.fileman.renderer.video import VideoRenderer
|
9
|
+
from mojo.apps.fileman.renderer.document import DocumentRenderer
|
10
|
+
from mojo.apps.fileman.renderer.audio import AudioRenderer
|
11
|
+
|
12
|
+
logger = logging.getLogger(__name__)
|
13
|
+
|
14
|
+
# Register renderers in order of preference
|
15
|
+
RENDERERS = [
|
16
|
+
ImageRenderer,
|
17
|
+
VideoRenderer,
|
18
|
+
DocumentRenderer,
|
19
|
+
AudioRenderer,
|
20
|
+
]
|
21
|
+
|
22
|
+
__all__ = [
|
23
|
+
'BaseRenderer', 'RenditionRole', 'ImageRenderer', 'VideoRenderer', 'DocumentRenderer', 'AudioRenderer',
|
24
|
+
'get_renderer_for_file', 'create_rendition', 'create_all_renditions',
|
25
|
+
'get_rendition', 'get_or_create_rendition'
|
26
|
+
]
|
27
|
+
|
28
|
+
def get_renderer_for_file(file: File) -> Optional[BaseRenderer]:
|
29
|
+
"""
|
30
|
+
Get the appropriate renderer for a file based on its category
|
31
|
+
|
32
|
+
Args:
|
33
|
+
file: The file to get a renderer for
|
34
|
+
|
35
|
+
Returns:
|
36
|
+
BaseRenderer: The renderer instance, or None if no renderer supports the file
|
37
|
+
"""
|
38
|
+
for renderer_class in RENDERERS:
|
39
|
+
if renderer_class.supports_file(file):
|
40
|
+
return renderer_class(file)
|
41
|
+
return None
|
42
|
+
|
43
|
+
def create_rendition(file: File, role: str, options: Dict = None) -> Optional[FileRendition]:
|
44
|
+
"""
|
45
|
+
Create a rendition for a file
|
46
|
+
|
47
|
+
Args:
|
48
|
+
file: The file to create a rendition for
|
49
|
+
role: The role of the rendition (e.g., 'thumbnail', 'preview')
|
50
|
+
options: Additional options for creating the rendition
|
51
|
+
|
52
|
+
Returns:
|
53
|
+
FileRendition: The created rendition, or None if creation failed
|
54
|
+
"""
|
55
|
+
renderer = get_renderer_for_file(file)
|
56
|
+
if not renderer:
|
57
|
+
logger.warning(f"No renderer available for file {file.id} ({file.filename}, {file.category})")
|
58
|
+
return None
|
59
|
+
|
60
|
+
return renderer.create_rendition(role, options)
|
61
|
+
|
62
|
+
def create_all_renditions(file: File) -> List[FileRendition]:
|
63
|
+
"""
|
64
|
+
Create all default renditions for a file
|
65
|
+
|
66
|
+
Args:
|
67
|
+
file: The file to create renditions for
|
68
|
+
|
69
|
+
Returns:
|
70
|
+
List[FileRendition]: List of created renditions
|
71
|
+
"""
|
72
|
+
renderer = get_renderer_for_file(file)
|
73
|
+
if not renderer:
|
74
|
+
logger.warning(f"No renderer available for file {file.id} ({file.filename}, {file.category})")
|
75
|
+
return []
|
76
|
+
|
77
|
+
return renderer.create_all_renditions()
|
78
|
+
|
79
|
+
def get_rendition(file: File, role: str) -> Optional[FileRendition]:
|
80
|
+
"""
|
81
|
+
Get an existing rendition for a file
|
82
|
+
|
83
|
+
Args:
|
84
|
+
file: The file to get a rendition for
|
85
|
+
role: The role of the rendition
|
86
|
+
|
87
|
+
Returns:
|
88
|
+
FileRendition: The rendition, or None if not found
|
89
|
+
"""
|
90
|
+
try:
|
91
|
+
return FileRendition.objects.get(original_file=file, role=role)
|
92
|
+
except FileRendition.DoesNotExist:
|
93
|
+
return None
|
94
|
+
|
95
|
+
def get_or_create_rendition(file: File, role: str, options: Dict = None) -> Optional[FileRendition]:
|
96
|
+
"""
|
97
|
+
Get an existing rendition or create a new one if it doesn't exist
|
98
|
+
|
99
|
+
Args:
|
100
|
+
file: The file to get or create a rendition for
|
101
|
+
role: The role of the rendition
|
102
|
+
options: Additional options for creating the rendition
|
103
|
+
|
104
|
+
Returns:
|
105
|
+
FileRendition: The rendition, or None if not found and creation failed
|
106
|
+
"""
|
107
|
+
rendition = get_rendition(file, role)
|
108
|
+
if rendition:
|
109
|
+
return rendition
|
110
|
+
|
111
|
+
return create_rendition(file, role, options)
|
@@ -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
|