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.
Files changed (276) hide show
  1. django_nativemojo-0.1.16.dist-info/METADATA +138 -0
  2. django_nativemojo-0.1.16.dist-info/RECORD +302 -0
  3. mojo/__init__.py +1 -1
  4. mojo/apps/account/management/__init__.py +5 -0
  5. mojo/apps/account/management/commands/__init__.py +6 -0
  6. mojo/apps/account/management/commands/serializer_admin.py +651 -0
  7. mojo/apps/account/migrations/0004_user_avatar.py +20 -0
  8. mojo/apps/account/migrations/0005_group_last_activity.py +18 -0
  9. mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
  10. mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
  11. mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
  12. mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
  13. mojo/apps/account/migrations/0010_group_avatar.py +20 -0
  14. mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
  15. mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
  16. mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
  17. mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
  18. mojo/apps/account/models/__init__.py +2 -0
  19. mojo/apps/account/models/device.py +281 -0
  20. mojo/apps/account/models/group.py +319 -15
  21. mojo/apps/account/models/member.py +29 -5
  22. mojo/apps/account/models/push/__init__.py +4 -0
  23. mojo/apps/account/models/push/config.py +112 -0
  24. mojo/apps/account/models/push/delivery.py +93 -0
  25. mojo/apps/account/models/push/device.py +66 -0
  26. mojo/apps/account/models/push/template.py +99 -0
  27. mojo/apps/account/models/user.py +369 -19
  28. mojo/apps/account/rest/__init__.py +2 -0
  29. mojo/apps/account/rest/device.py +39 -0
  30. mojo/apps/account/rest/group.py +9 -0
  31. mojo/apps/account/rest/push.py +187 -0
  32. mojo/apps/account/rest/user.py +100 -6
  33. mojo/apps/account/services/__init__.py +1 -0
  34. mojo/apps/account/services/push.py +363 -0
  35. mojo/apps/aws/migrations/0001_initial.py +206 -0
  36. mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
  37. mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
  38. mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
  39. mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
  40. mojo/apps/aws/models/__init__.py +19 -0
  41. mojo/apps/aws/models/email_attachment.py +99 -0
  42. mojo/apps/aws/models/email_domain.py +218 -0
  43. mojo/apps/aws/models/email_template.py +132 -0
  44. mojo/apps/aws/models/incoming_email.py +197 -0
  45. mojo/apps/aws/models/mailbox.py +288 -0
  46. mojo/apps/aws/models/sent_message.py +175 -0
  47. mojo/apps/aws/rest/__init__.py +7 -0
  48. mojo/apps/aws/rest/email.py +33 -0
  49. mojo/apps/aws/rest/email_ops.py +183 -0
  50. mojo/apps/aws/rest/messages.py +32 -0
  51. mojo/apps/aws/rest/s3.py +64 -0
  52. mojo/apps/aws/rest/send.py +101 -0
  53. mojo/apps/aws/rest/sns.py +403 -0
  54. mojo/apps/aws/rest/templates.py +19 -0
  55. mojo/apps/aws/services/__init__.py +32 -0
  56. mojo/apps/aws/services/email.py +390 -0
  57. mojo/apps/aws/services/email_ops.py +548 -0
  58. mojo/apps/docit/__init__.py +6 -0
  59. mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
  60. mojo/apps/docit/markdown_plugins/toc.py +12 -0
  61. mojo/apps/docit/migrations/0001_initial.py +113 -0
  62. mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
  63. mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
  64. mojo/apps/docit/models/__init__.py +17 -0
  65. mojo/apps/docit/models/asset.py +231 -0
  66. mojo/apps/docit/models/book.py +227 -0
  67. mojo/apps/docit/models/page.py +319 -0
  68. mojo/apps/docit/models/page_revision.py +203 -0
  69. mojo/apps/docit/rest/__init__.py +10 -0
  70. mojo/apps/docit/rest/asset.py +17 -0
  71. mojo/apps/docit/rest/book.py +22 -0
  72. mojo/apps/docit/rest/page.py +22 -0
  73. mojo/apps/docit/rest/page_revision.py +17 -0
  74. mojo/apps/docit/services/__init__.py +11 -0
  75. mojo/apps/docit/services/docit.py +315 -0
  76. mojo/apps/docit/services/markdown.py +44 -0
  77. mojo/apps/fileman/README.md +8 -8
  78. mojo/apps/fileman/backends/base.py +76 -70
  79. mojo/apps/fileman/backends/filesystem.py +86 -86
  80. mojo/apps/fileman/backends/s3.py +409 -108
  81. mojo/apps/fileman/migrations/0001_initial.py +106 -0
  82. mojo/apps/fileman/migrations/0002_filemanager_parent_alter_filemanager_max_file_size.py +24 -0
  83. mojo/apps/fileman/migrations/0003_remove_file_fileman_fil_upload__c4bc35_idx_and_more.py +25 -0
  84. mojo/apps/fileman/migrations/0004_remove_file_original_filename_and_more.py +39 -0
  85. mojo/apps/fileman/migrations/0005_alter_file_upload_token.py +18 -0
  86. mojo/apps/fileman/migrations/0006_file_download_url_filemanager_forever_urls.py +23 -0
  87. mojo/apps/fileman/migrations/0007_remove_filemanager_forever_urls_and_more.py +22 -0
  88. mojo/apps/fileman/migrations/0008_file_category.py +18 -0
  89. mojo/apps/fileman/migrations/0009_rename_file_path_file_storage_file_path.py +18 -0
  90. mojo/apps/fileman/migrations/0010_filerendition.py +33 -0
  91. mojo/apps/fileman/migrations/0011_alter_filerendition_original_file.py +19 -0
  92. mojo/apps/fileman/models/__init__.py +1 -5
  93. mojo/apps/fileman/models/file.py +240 -58
  94. mojo/apps/fileman/models/manager.py +427 -31
  95. mojo/apps/fileman/models/rendition.py +118 -0
  96. mojo/apps/fileman/renderer/__init__.py +111 -0
  97. mojo/apps/fileman/renderer/audio.py +403 -0
  98. mojo/apps/fileman/renderer/base.py +205 -0
  99. mojo/apps/fileman/renderer/document.py +404 -0
  100. mojo/apps/fileman/renderer/image.py +222 -0
  101. mojo/apps/fileman/renderer/utils.py +297 -0
  102. mojo/apps/fileman/renderer/video.py +304 -0
  103. mojo/apps/fileman/rest/__init__.py +1 -18
  104. mojo/apps/fileman/rest/upload.py +22 -32
  105. mojo/apps/fileman/signals.py +58 -0
  106. mojo/apps/fileman/tasks.py +254 -0
  107. mojo/apps/fileman/utils/__init__.py +40 -16
  108. mojo/apps/incident/migrations/0005_incidenthistory.py +39 -0
  109. mojo/apps/incident/migrations/0006_alter_incident_state.py +18 -0
  110. mojo/apps/incident/migrations/0007_event_uid.py +18 -0
  111. mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
  112. mojo/apps/incident/migrations/0009_incident_status.py +18 -0
  113. mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
  114. mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
  115. mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
  116. mojo/apps/incident/models/__init__.py +2 -0
  117. mojo/apps/incident/models/event.py +35 -0
  118. mojo/apps/incident/models/history.py +36 -0
  119. mojo/apps/incident/models/incident.py +3 -1
  120. mojo/apps/incident/models/ticket.py +62 -0
  121. mojo/apps/incident/reporter.py +21 -1
  122. mojo/apps/incident/rest/__init__.py +1 -0
  123. mojo/apps/incident/rest/event.py +7 -1
  124. mojo/apps/incident/rest/ticket.py +43 -0
  125. mojo/apps/jobs/__init__.py +489 -0
  126. mojo/apps/jobs/adapters.py +24 -0
  127. mojo/apps/jobs/cli.py +616 -0
  128. mojo/apps/jobs/daemon.py +370 -0
  129. mojo/apps/jobs/examples/sample_jobs.py +376 -0
  130. mojo/apps/jobs/examples/webhook_examples.py +203 -0
  131. mojo/apps/jobs/handlers/__init__.py +5 -0
  132. mojo/apps/jobs/handlers/webhook.py +317 -0
  133. mojo/apps/jobs/job_engine.py +734 -0
  134. mojo/apps/jobs/keys.py +203 -0
  135. mojo/apps/jobs/local_queue.py +363 -0
  136. mojo/apps/jobs/management/__init__.py +3 -0
  137. mojo/apps/jobs/management/commands/__init__.py +3 -0
  138. mojo/apps/jobs/manager.py +1327 -0
  139. mojo/apps/jobs/migrations/0001_initial.py +97 -0
  140. mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
  141. mojo/apps/jobs/models/__init__.py +6 -0
  142. mojo/apps/jobs/models/job.py +441 -0
  143. mojo/apps/jobs/rest/__init__.py +2 -0
  144. mojo/apps/jobs/rest/control.py +466 -0
  145. mojo/apps/jobs/rest/jobs.py +421 -0
  146. mojo/apps/jobs/scheduler.py +571 -0
  147. mojo/apps/jobs/services/__init__.py +6 -0
  148. mojo/apps/jobs/services/job_actions.py +465 -0
  149. mojo/apps/jobs/settings.py +209 -0
  150. mojo/apps/logit/migrations/0004_alter_log_level.py +18 -0
  151. mojo/apps/logit/models/log.py +7 -1
  152. mojo/apps/metrics/__init__.py +8 -1
  153. mojo/apps/metrics/redis_metrics.py +198 -0
  154. mojo/apps/metrics/rest/__init__.py +3 -0
  155. mojo/apps/metrics/rest/categories.py +266 -0
  156. mojo/apps/metrics/rest/helpers.py +48 -0
  157. mojo/apps/metrics/rest/permissions.py +99 -0
  158. mojo/apps/metrics/rest/values.py +277 -0
  159. mojo/apps/metrics/utils.py +19 -2
  160. mojo/decorators/auth.py +6 -1
  161. mojo/decorators/http.py +47 -3
  162. mojo/helpers/aws/__init__.py +45 -0
  163. mojo/helpers/aws/ec2.py +804 -0
  164. mojo/helpers/aws/iam.py +748 -0
  165. mojo/helpers/aws/inbound_email.py +309 -0
  166. mojo/helpers/aws/kms.py +413 -0
  167. mojo/helpers/aws/s3.py +451 -11
  168. mojo/helpers/aws/ses.py +483 -0
  169. mojo/helpers/aws/ses_domain.py +959 -0
  170. mojo/helpers/aws/sns.py +461 -0
  171. mojo/helpers/crypto/__init__.py +1 -1
  172. mojo/helpers/crypto/utils.py +15 -0
  173. mojo/helpers/dates.py +18 -0
  174. mojo/helpers/location/__init__.py +2 -0
  175. mojo/helpers/location/countries.py +262 -0
  176. mojo/helpers/location/geolocation.py +196 -0
  177. mojo/helpers/logit.py +37 -0
  178. mojo/helpers/redis/__init__.py +2 -0
  179. mojo/helpers/redis/adapter.py +606 -0
  180. mojo/helpers/redis/client.py +48 -0
  181. mojo/helpers/redis/pool.py +225 -0
  182. mojo/helpers/request.py +8 -0
  183. mojo/helpers/response.py +14 -2
  184. mojo/helpers/settings/__init__.py +2 -0
  185. mojo/helpers/{settings.py → settings/helper.py} +1 -37
  186. mojo/helpers/settings/parser.py +132 -0
  187. mojo/middleware/auth.py +1 -1
  188. mojo/middleware/cors.py +40 -0
  189. mojo/middleware/logging.py +131 -12
  190. mojo/middleware/mojo.py +10 -0
  191. mojo/models/rest.py +494 -65
  192. mojo/models/secrets.py +98 -3
  193. mojo/serializers/__init__.py +106 -0
  194. mojo/serializers/core/__init__.py +90 -0
  195. mojo/serializers/core/cache/__init__.py +121 -0
  196. mojo/serializers/core/cache/backends.py +518 -0
  197. mojo/serializers/core/cache/base.py +102 -0
  198. mojo/serializers/core/cache/disabled.py +181 -0
  199. mojo/serializers/core/cache/memory.py +287 -0
  200. mojo/serializers/core/cache/redis.py +533 -0
  201. mojo/serializers/core/cache/utils.py +454 -0
  202. mojo/serializers/core/manager.py +550 -0
  203. mojo/serializers/core/serializer.py +475 -0
  204. mojo/serializers/examples/settings.py +322 -0
  205. mojo/serializers/formats/csv.py +393 -0
  206. mojo/serializers/formats/localizers.py +509 -0
  207. mojo/serializers/{models.py → simple.py} +38 -15
  208. mojo/serializers/suggested_improvements.md +388 -0
  209. testit/client.py +1 -1
  210. testit/helpers.py +35 -4
  211. testit/runner.py +23 -6
  212. django_nativemojo-0.1.10.dist-info/METADATA +0 -96
  213. django_nativemojo-0.1.10.dist-info/RECORD +0 -194
  214. mojo/apps/metrics/rest/db.py +0 -0
  215. mojo/apps/notify/README.md +0 -91
  216. mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
  217. mojo/apps/notify/admin.py +0 -52
  218. mojo/apps/notify/handlers/example_handlers.py +0 -516
  219. mojo/apps/notify/handlers/ses/__init__.py +0 -25
  220. mojo/apps/notify/handlers/ses/bounce.py +0 -0
  221. mojo/apps/notify/handlers/ses/complaint.py +0 -25
  222. mojo/apps/notify/handlers/ses/message.py +0 -86
  223. mojo/apps/notify/management/commands/__init__.py +0 -1
  224. mojo/apps/notify/management/commands/process_notifications.py +0 -370
  225. mojo/apps/notify/mod +0 -0
  226. mojo/apps/notify/models/__init__.py +0 -12
  227. mojo/apps/notify/models/account.py +0 -128
  228. mojo/apps/notify/models/attachment.py +0 -24
  229. mojo/apps/notify/models/bounce.py +0 -68
  230. mojo/apps/notify/models/complaint.py +0 -40
  231. mojo/apps/notify/models/inbox.py +0 -113
  232. mojo/apps/notify/models/inbox_message.py +0 -173
  233. mojo/apps/notify/models/outbox.py +0 -129
  234. mojo/apps/notify/models/outbox_message.py +0 -288
  235. mojo/apps/notify/models/template.py +0 -30
  236. mojo/apps/notify/providers/aws.py +0 -73
  237. mojo/apps/notify/rest/ses.py +0 -0
  238. mojo/apps/notify/utils/__init__.py +0 -2
  239. mojo/apps/notify/utils/notifications.py +0 -404
  240. mojo/apps/notify/utils/parsing.py +0 -202
  241. mojo/apps/notify/utils/render.py +0 -144
  242. mojo/apps/tasks/README.md +0 -118
  243. mojo/apps/tasks/__init__.py +0 -11
  244. mojo/apps/tasks/manager.py +0 -489
  245. mojo/apps/tasks/rest/__init__.py +0 -2
  246. mojo/apps/tasks/rest/hooks.py +0 -0
  247. mojo/apps/tasks/rest/tasks.py +0 -62
  248. mojo/apps/tasks/runner.py +0 -174
  249. mojo/apps/tasks/tq_handlers.py +0 -14
  250. mojo/helpers/aws/setup_email.py +0 -0
  251. mojo/helpers/redis.py +0 -10
  252. mojo/models/meta.py +0 -262
  253. mojo/ws4redis/README.md +0 -174
  254. mojo/ws4redis/__init__.py +0 -2
  255. mojo/ws4redis/client.py +0 -283
  256. mojo/ws4redis/connection.py +0 -327
  257. mojo/ws4redis/exceptions.py +0 -32
  258. mojo/ws4redis/redis.py +0 -183
  259. mojo/ws4redis/servers/base.py +0 -86
  260. mojo/ws4redis/servers/django.py +0 -171
  261. mojo/ws4redis/servers/uwsgi.py +0 -63
  262. mojo/ws4redis/settings.py +0 -45
  263. mojo/ws4redis/utf8validator.py +0 -128
  264. mojo/ws4redis/websocket.py +0 -403
  265. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/LICENSE +0 -0
  266. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/NOTICE +0 -0
  267. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/WHEEL +0 -0
  268. /mojo/apps/{notify → aws}/__init__.py +0 -0
  269. /mojo/apps/{notify/handlers → aws/migrations}/__init__.py +0 -0
  270. /mojo/apps/{notify/management → docit/markdown_plugins}/__init__.py +0 -0
  271. /mojo/apps/{notify/providers → docit/migrations}/__init__.py +0 -0
  272. /mojo/apps/{notify/rest → fileman/migrations}/__init__.py +0 -0
  273. /mojo/{ws4redis/servers → apps/jobs/examples}/__init__.py +0 -0
  274. /mojo/apps/{fileman/models/render.py → jobs/migrations/__init__.py} +0 -0
  275. /mojo/{serializers → rest}/openapi.py +0 -0
  276. /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