Skip to content

Commit 1a068d6

Browse files
committed
[IMP] mail: introduce call artifacts, call_debrief player widget
This commit introduces a unified way to store and visualize post-call artifacts (call recordings). It also lays the groundwork for introducing transcripts, primarily for Discuss and VoIP. CALL ARTIFACT - What is that? --- A call.artifact represents a discrete "product" of a call. For example, it can be a call's recording or a call transcription. Each artifact acts as a thin metadata layer (start_ms, end_ms, source) for a maximum of one ir.attachment. This keeps the generic ir.attachment table lean while allowing us to store the complex, multi-segment timing metadata required for chunked playback and AI transcription pipelines. RECORDING STORAGE --- Odoo's infrastructure is not designed for the mass storage of media. Even though a single recording's size is negligible, VoIP systems are expected to generate a massive number of them. Therefore, the persistent call recording feature will ONLY be available when the cloud storage module is installed and configured, allowing media to be offloaded to providers like S3 or GCS. Note: Transient recordings used for transcriptions are not bound by this requirement. (Check out the enterprise commit for more details.) CALL DEBRIEF - New UI Media Player --- The presentation of the artifacts is realized with the Call Debrief Widget - a new field widget. It provides a modern synchronized player supporting audio/video recordings (with support for synchronized transcripts), drag-to-seek functionality, keyboard shortcuts with live feedback, and variable playback rates. task-5153790
1 parent 2d55b01 commit 1a068d6

30 files changed

Lines changed: 1593 additions & 0 deletions

addons/mail/__manifest__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@
132132
'demo/discuss_channel_demo.xml',
133133
'demo/discuss/public_channel_demo.xml',
134134
"demo/discuss/readonly_channel_demo.xml",
135+
'demo/discuss/call_debrief_discuss_demo.xml',
135136
"demo/mail_poll_demo.xml",
136137
"demo/mail_canned_response_demo.xml",
137138
],
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<odoo>
2+
<data noupdate="1">
3+
<record id="mail.demo_call_channel" model="discuss.channel">
4+
<field name="name">Demo Call Channel</field>
5+
<field name="channel_type">channel</field>
6+
</record>
7+
8+
<record id="mail.demo_call_channel_member" model="discuss.channel.member">
9+
<field name="partner_id" ref="base.partner_admin"/>
10+
<field name="channel_id" ref="mail.demo_call_channel"/>
11+
</record>
12+
13+
<!-- Audio Call Data -->
14+
<record id="mail.demo_call_history_audio" model="discuss.call.history">
15+
<field name="channel_id" ref="mail.demo_call_channel"/>
16+
<field name="start_dt" eval="(datetime.now() - relativedelta(hours=2)).strftime('%Y-%m-%d %H:%M:%S')"/>
17+
<field name="end_dt" eval="(datetime.now() - relativedelta(hours=2, minutes=-1)).strftime('%Y-%m-%d %H:%M:%S')"/>
18+
</record>
19+
20+
<record id="mail.demo_mail_call_artifact_audio" model="mail.call.artifact">
21+
<field name="discuss_call_history_id" ref="mail.demo_call_history_audio"/>
22+
<field name="start_ms">0</field>
23+
<field name="end_ms">60000</field>
24+
</record>
25+
26+
<record id="mail.demo_call_attachment_audio" model="ir.attachment">
27+
<field name="name">demo_audio_call.webm</field>
28+
<field name="mimetype">audio/webm</field>
29+
<field name="raw" type="bytes" file="mail/demo/discuss/fixtures/audio_opus_60s_long_3s_content.webm"/>
30+
<field name="description">Demo Audio Recording</field>
31+
<field name="res_model">mail.call.artifact</field>
32+
<field name="res_id" ref="mail.demo_mail_call_artifact_audio"/>
33+
</record>
34+
35+
<!-- Video Call Data -->
36+
<record id="mail.demo_call_history_video" model="discuss.call.history">
37+
<field name="channel_id" ref="mail.demo_call_channel"/>
38+
<field name="start_dt" eval="(datetime.now() - relativedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
39+
<field name="end_dt" eval="(datetime.now() - relativedelta(days=1, minutes=-1)).strftime('%Y-%m-%d %H:%M:%S')"/>
40+
</record>
41+
42+
<record id="mail.demo_mail_call_artifact_video" model="mail.call.artifact">
43+
<field name="discuss_call_history_id" ref="mail.demo_call_history_video"/>
44+
<field name="start_ms">0</field>
45+
<field name="end_ms">60000</field>
46+
</record>
47+
48+
<record id="mail.demo_call_attachment_video" model="ir.attachment">
49+
<field name="name">demo_video_call.webm</field>
50+
<field name="mimetype">video/webm</field>
51+
<field name="raw" type="bytes" file="mail/demo/discuss/fixtures/video_60s_long_3s_content_av1_opus.webm"/>
52+
<field name="description">Demo Video Recording</field>
53+
<field name="res_model">mail.call.artifact</field>
54+
<field name="res_id" ref="mail.demo_mail_call_artifact_video"/>
55+
</record>
56+
57+
<record id="mail.demo_call_history_no_media" model="discuss.call.history">
58+
<field name="channel_id" ref="mail.demo_call_channel"/>
59+
<field name="start_dt" eval="(datetime.now() - relativedelta(hours=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
60+
<field name="end_dt" eval="(datetime.now() - relativedelta(hours=5, minutes=-2)).strftime('%Y-%m-%d %H:%M:%S')"/>
61+
</record>
62+
<!-- Call with disjoined audios -->
63+
<record id="mail.call_starting_with_audio_then_silence_then_audio" model="discuss.call.history">
64+
<field name="channel_id" ref="mail.demo_call_channel"/>
65+
<field name="start_dt" eval="(datetime.now() - relativedelta(hours=3)).strftime('%Y-%m-%d %H:%M:%S')"/>
66+
<field name="end_dt" eval="(datetime.now() - relativedelta(hours=3, seconds=-123)).strftime('%Y-%m-%d %H:%M:%S')"/>
67+
</record>
68+
<record id="mail.artifact_disjoined_s1" model="mail.call.artifact">
69+
<field name="discuss_call_history_id" ref="mail.call_starting_with_audio_then_silence_then_audio"/>
70+
<field name="start_ms">0</field>
71+
<field name="end_ms">20000</field>
72+
</record>
73+
<record id="mail.attachment_disjoined_s1" model="ir.attachment">
74+
<field name="name">audio_physics_0s_20s_16k.mp3</field>
75+
<field name="mimetype">audio/mpeg</field>
76+
<field name="raw" type="bytes" file="mail/demo/discuss/fixtures/audio_physics_0s_20s_16k.mp3"/>
77+
<field name="description">Demo Audio S1 (0-20s)</field>
78+
<field name="res_model">mail.call.artifact</field>
79+
<field name="res_id" ref="mail.artifact_disjoined_s1"/>
80+
</record>
81+
<record id="mail.artifact_disjoined_s3" model="mail.call.artifact">
82+
<field name="discuss_call_history_id" ref="mail.call_starting_with_audio_then_silence_then_audio"/>
83+
<field name="start_ms">40000</field>
84+
<field name="end_ms">60000</field>
85+
</record>
86+
<record id="mail.attachment_disjoined_s3" model="ir.attachment">
87+
<field name="name">audio_physics_40s_60s_16k.mp3</field>
88+
<field name="mimetype">audio/mpeg</field>
89+
<field name="raw" type="bytes" file="mail/demo/discuss/fixtures/audio_physics_40s_60s_16k.mp3"/>
90+
<field name="description">Demo Audio S3 (40-60s)</field>
91+
<field name="res_model">mail.call.artifact</field>
92+
<field name="res_id" ref="mail.artifact_disjoined_s3"/>
93+
</record>
94+
</data>
95+
</odoo>
Binary file not shown.
39.4 KB
Binary file not shown.
39.4 KB
Binary file not shown.
Binary file not shown.

addons/mail/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
from . import update
7575

7676
# after mail specifically as discuss module depends on mail
77+
from . import mail_call_artifact
7778
from . import discuss
7879

7980
# discuss_channel_member must be loaded first

addons/mail/models/discuss/discuss_call_history.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class DiscussCallHistory(models.Model):
1010
_explanation = "Stores the history of internal discuss calls (audio/video), tracking the start time, end time, duration, and the associated channel."
1111

1212
channel_id = fields.Many2one("discuss.channel", index=True, required=True, ondelete="cascade")
13+
artifact_ids = fields.One2many("mail.call.artifact", "discuss_call_history_id", string="Artifacts")
1314
duration_hour = fields.Float(compute="_compute_duration_hour")
1415
start_dt = fields.Datetime(index=True, required=True)
1516
end_dt = fields.Datetime()
@@ -26,6 +27,10 @@ class DiscussCallHistory(models.Model):
2627
)
2728
_channel_id_end_dt_idx = models.Index("(channel_id, end_dt) WHERE end_dt IS NULL")
2829

30+
@api.ondelete(at_uninstall=False)
31+
def _unlink_cleanup_artifacts_attachments(self):
32+
self.artifact_ids.unlink()
33+
2934
@api.depends("start_dt", "end_dt")
3035
def _compute_duration_hour(self):
3136
for record in self:

addons/mail/models/ir_attachment.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
class IrAttachment(models.Model):
1212
_inherit = 'ir.attachment'
1313

14+
_mail_call_artifact_uniq = models.UniqueIndex(
15+
"(res_id) WHERE res_model = 'mail.call.artifact'",
16+
message="Only one attachment per call artifact is allowed.",
17+
)
18+
1419
thumbnail = fields.Image()
1520
has_thumbnail = fields.Boolean(compute="_compute_has_thumbnail")
1621

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
from odoo import api, fields, models
2+
from odoo.exceptions import ValidationError
3+
4+
5+
class MailCallArtifact(models.Model):
6+
"""Represent a discrete product of a call (audio, transcript etc.)
7+
8+
For media artifacts, each record acts as a thin metadata wrapper (timing, source, etc.) for
9+
exactly one media file, keeping the ir_attachment table lean"""
10+
11+
_name = "mail.call.artifact"
12+
_description = "Call Artifact"
13+
_order = "start_ms, id"
14+
15+
# required=False as artifact can also owned by other call models (ensured by constraints)
16+
discuss_call_history_id = fields.Many2one(
17+
"discuss.call.history", string="Discuss Call History",
18+
ondelete="cascade", required=False, index=True,
19+
)
20+
media_id = fields.Many2one(
21+
"ir.attachment", string="Media Attachment", compute="_compute_media_id",
22+
)
23+
start_ms = fields.Integer(
24+
string="Start (ms)", default=0, required=True,
25+
help="Offset from the start of the call in milliseconds",
26+
)
27+
end_ms = fields.Integer(
28+
string="End (ms)", default=0, required=True,
29+
help="Offset from the start of the call in milliseconds",
30+
)
31+
32+
# ---------------------------------------------------------------------
33+
# Constraints
34+
35+
_start_before_end = models.Constraint(
36+
"CHECK(start_ms < end_ms)", "End time must be after the start time.",
37+
)
38+
_artifact_has_possessor = models.Constraint(
39+
"CHECK(num_nonnulls(discuss_call_history_id) = 1)", "Artifact must be linked to a call source.",
40+
)
41+
42+
@api.constrains("start_ms", "end_ms", "discuss_call_history_id")
43+
def _constrains_artifacts_overlap(self):
44+
"""Check that artifacts within the same call do not overlap."""
45+
grouped_artifacts = self._get_artifacts_grouped_by_call()
46+
for key, artifacts in grouped_artifacts.items():
47+
if not key:
48+
continue
49+
self._check_artifacts_overlap(artifacts)
50+
51+
def _get_artifacts_grouped_by_call(self):
52+
"""Return a dict mapping call records to their respective artifact recordsets.
53+
54+
This hook allows other modules to include artifacts linked to different
55+
call models (e.g., voip.call) in the overlap validation"""
56+
all_artifacts = self.discuss_call_history_id.artifact_ids
57+
return all_artifacts.grouped('discuss_call_history_id')
58+
59+
def _is_overlap_candidate(self):
60+
"""Determine if self should be checked for overlap"""
61+
return True
62+
63+
def _check_artifacts_overlap(self, artifacts):
64+
"""Check if the provided artifacts overlap in time"""
65+
candidates = sorted(
66+
(a for a in artifacts if a._is_overlap_candidate()),
67+
key=lambda x: x.start_ms,
68+
)
69+
for i in range(len(candidates) - 1):
70+
if candidates[i].end_ms > candidates[i + 1].start_ms:
71+
raise ValidationError(self.env._("Media artifacts overlap."))
72+
73+
# ---------------------------------------------------------------------
74+
# Computes
75+
76+
def _compute_media_id(self):
77+
attachments = self.env["ir.attachment"].search_fetch([
78+
("res_model", "=", self._name),
79+
("res_id", "in", self.ids),
80+
], ['res_id'])
81+
attachment_by_res_id = attachments.grouped('res_id')
82+
for artifact in self:
83+
artifact.media_id = attachment_by_res_id.get(artifact.id)
84+
85+
# ---------------------------------------------------------------------
86+
# Methods
87+
88+
def _get_related_call(self):
89+
"""Return the parent call record (discuss.call.history, voip.call, etc.)"""
90+
self.ensure_one()
91+
return self.discuss_call_history_id
92+
93+
@api.ondelete(at_uninstall=False)
94+
def _unlink_cleanup_media_attachment(self):
95+
self.media_id.sudo().unlink()

0 commit comments

Comments
 (0)