diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py
index 32ce1b7de6b8bd..24fe4f06545f57 100644
--- a/addons/mail/__manifest__.py
+++ b/addons/mail/__manifest__.py
@@ -132,6 +132,7 @@
'demo/discuss_channel_demo.xml',
'demo/discuss/public_channel_demo.xml',
"demo/discuss/readonly_channel_demo.xml",
+ 'demo/discuss/call_debrief_discuss_demo.xml',
"demo/mail_poll_demo.xml",
"demo/mail_canned_response_demo.xml",
],
diff --git a/addons/mail/demo/discuss/call_debrief_discuss_demo.xml b/addons/mail/demo/discuss/call_debrief_discuss_demo.xml
new file mode 100644
index 00000000000000..c209b789c9fd3d
--- /dev/null
+++ b/addons/mail/demo/discuss/call_debrief_discuss_demo.xml
@@ -0,0 +1,63 @@
+
+
+
+ Demo Call Channel
+ channel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+ 60000
+
+
+
+ demo_audio_call.webm
+ audio/webm
+
+ Demo Audio Recording
+ mail.call.artifact
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+ 60000
+
+
+
+ demo_video_call.webm
+ video/webm
+
+ Demo Video Recording
+ mail.call.artifact
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/mail/demo/discuss/fixtures/audio_opus_60s_long_3s_content.webm b/addons/mail/demo/discuss/fixtures/audio_opus_60s_long_3s_content.webm
new file mode 100644
index 00000000000000..5bbe47e6e4d5db
Binary files /dev/null and b/addons/mail/demo/discuss/fixtures/audio_opus_60s_long_3s_content.webm differ
diff --git a/addons/mail/demo/discuss/fixtures/video_60s_long_3s_content_av1_opus.webm b/addons/mail/demo/discuss/fixtures/video_60s_long_3s_content_av1_opus.webm
new file mode 100644
index 00000000000000..e4baef5980df0f
Binary files /dev/null and b/addons/mail/demo/discuss/fixtures/video_60s_long_3s_content_av1_opus.webm differ
diff --git a/addons/mail/models/__init__.py b/addons/mail/models/__init__.py
index 72a67c3605ee4f..5de1e0f2abd919 100644
--- a/addons/mail/models/__init__.py
+++ b/addons/mail/models/__init__.py
@@ -74,6 +74,7 @@
from . import update
# after mail specifically as discuss module depends on mail
+from . import mail_call_artifact
from . import discuss
# discuss_channel_member must be loaded first
diff --git a/addons/mail/models/discuss/discuss_call_history.py b/addons/mail/models/discuss/discuss_call_history.py
index 01e52a526814fa..39d41811a97ab4 100644
--- a/addons/mail/models/discuss/discuss_call_history.py
+++ b/addons/mail/models/discuss/discuss_call_history.py
@@ -10,6 +10,7 @@ class DiscussCallHistory(models.Model):
_explanation = "Stores the history of internal discuss calls (audio/video), tracking the start time, end time, duration, and the associated channel."
channel_id = fields.Many2one("discuss.channel", index=True, required=True, ondelete="cascade")
+ artifact_ids = fields.One2many("mail.call.artifact", "discuss_call_history_id", string="Artifacts")
duration_hour = fields.Float(compute="_compute_duration_hour")
start_dt = fields.Datetime(index=True, required=True)
end_dt = fields.Datetime()
diff --git a/addons/mail/models/ir_attachment.py b/addons/mail/models/ir_attachment.py
index 0178d76b721d0b..618c182b52808e 100644
--- a/addons/mail/models/ir_attachment.py
+++ b/addons/mail/models/ir_attachment.py
@@ -11,6 +11,11 @@
class IrAttachment(models.Model):
_inherit = 'ir.attachment'
+ _mail_call_artifact_uniq = models.UniqueIndex(
+ "(res_id) WHERE res_model = 'mail.call.artifact'",
+ message="Only one attachment per call artifact is allowed.",
+ )
+
thumbnail = fields.Image()
has_thumbnail = fields.Boolean(compute="_compute_has_thumbnail")
diff --git a/addons/mail/models/mail_call_artifact.py b/addons/mail/models/mail_call_artifact.py
new file mode 100644
index 00000000000000..b43065c18f5ccb
--- /dev/null
+++ b/addons/mail/models/mail_call_artifact.py
@@ -0,0 +1,90 @@
+from odoo import api, fields, models
+from odoo.exceptions import ValidationError
+
+
+class MailCallArtifact(models.Model):
+ """Represent a discrete product of a call (audio, transcript etc.)
+
+ For media artifacts, each record acts as a thin metadata wrapper (timing, source, etc.) for
+ exactly one media file, keeping the ir_attachment table lean"""
+
+ _name = "mail.call.artifact"
+ _description = "Call Artifact"
+
+ # required=False as artifact can also owned by other call models (ensured by constraints)
+ discuss_call_history_id = fields.Many2one(
+ "discuss.call.history", string="Discuss Call History",
+ ondelete="cascade", required=False, index=True,
+ )
+ media_id = fields.Many2one(
+ "ir.attachment", string="Media Attachment", compute="_compute_media_id",
+ )
+ start_ms = fields.Integer(
+ string="Start (ms)", default=0, required=True,
+ help="Offset from the start of the call in milliseconds",
+ )
+ end_ms = fields.Integer(
+ string="End (ms)", default=0, required=True,
+ help="Offset from the start of the call in milliseconds",
+ )
+
+ # ---------------------------------------------------------------------
+ # Constraints
+
+ _start_before_end = models.Constraint(
+ "CHECK(start_ms < end_ms)", "End time must be after the start time.",
+ )
+ _artifact_has_possessor = models.Constraint(
+ "CHECK(num_nonnulls(discuss_call_history_id) = 1)", "Artifact must be linked to a call source.",
+ )
+
+ @api.constrains("start_ms", "end_ms", "discuss_call_history_id")
+ def _constrains_artifacts_overlap(self):
+ """Check that artifacts within the same call do not overlap."""
+ grouped_artifacts = self._get_artifacts_grouped_by_call()
+ for key, artifacts in grouped_artifacts.items():
+ if not key:
+ continue
+ self._check_artifacts_overlap(artifacts)
+
+ def _get_artifacts_grouped_by_call(self):
+ """Return a dict mapping call records to their respective artifact recordsets.
+
+ This hook allows other modules to include artifacts linked to different
+ call models (e.g., voip.call) in the overlap validation"""
+ all_artifacts = self.discuss_call_history_id.artifact_ids
+ return all_artifacts.grouped('discuss_call_history_id')
+
+ def _is_overlap_candidate(self):
+ """Determine if self should be checked for overlap"""
+ return True
+
+ def _check_artifacts_overlap(self, artifacts):
+ """Check if the provided artifacts overlap in time"""
+ candidates = sorted(
+ (a for a in artifacts if a._is_overlap_candidate()),
+ key=lambda x: x.start_ms,
+ )
+ for i in range(len(candidates) - 1):
+ if candidates[i].end_ms > candidates[i + 1].start_ms:
+ raise ValidationError(self.env._("Media artifacts overlap."))
+
+ # ---------------------------------------------------------------------
+ # Computes
+
+ def _compute_media_id(self):
+ attachments = self.env["ir.attachment"].search_fetch([
+ ("res_model", "=", self._name),
+ ("res_id", "in", self.ids),
+ ], ['res_id'])
+ attachment_by_res_id = attachments.grouped('res_id')
+ for artifact in self:
+ artifact.media_id = attachment_by_res_id.get(artifact.id)
+
+ # ---------------------------------------------------------------------
+ # Methods
+
+ def _get_related_call(self):
+ """Return the parent call record (discuss.call.history, voip.call, etc.)"""
+ self.ensure_one()
+ return self.discuss_call_history_id
diff --git a/addons/mail/security/ir.model.access.csv b/addons/mail/security/ir.model.access.csv
index ba6d9540102965..ed4be7c066bc79 100644
--- a/addons/mail/security/ir.model.access.csv
+++ b/addons/mail/security/ir.model.access.csv
@@ -72,3 +72,5 @@ access_mail_scheduled_message,access.mail.scheduled.message,model_mail_scheduled
access_mail_poll_erp_manager,access.mail.poll.erp_manager,model_mail_poll,base.group_erp_manager,1,1,1,1
access_mail_poll_option_erp_manager,access.mail.poll.option.erp_manager,model_mail_poll_option,base.group_erp_manager,1,1,1,1
access_mail_poll_vote_erp_manager,access.mail.poll.vote.erp_manager,model_mail_poll_vote,base.group_erp_manager,1,1,1,1
+access_mail_call_artifact_user,call.artifact.user,model_mail_call_artifact,base.group_user,1,0,0,0
+access_mail_call_artifact_admin,call.artifact.admin,model_mail_call_artifact,base.group_erp_manager,1,1,1,1
diff --git a/addons/mail/security/mail_security.xml b/addons/mail/security/mail_security.xml
index 95399f1ddd9da4..19c2063f09060a 100644
--- a/addons/mail/security/mail_security.xml
+++ b/addons/mail/security/mail_security.xml
@@ -462,4 +462,22 @@
+
+
+ Call Artifact: User Access
+
+
+ [('discuss_call_history_id', 'access', 'read')]
+
+
+
+
+
+
+
+ Call Artifact: Admin Full Access
+
+
+ [(1, '=', 1)]
+
diff --git a/addons/mail/static/src/views/fields/call_debrief/call_debrief.js b/addons/mail/static/src/views/fields/call_debrief/call_debrief.js
new file mode 100644
index 00000000000000..16db299ca4bf03
--- /dev/null
+++ b/addons/mail/static/src/views/fields/call_debrief/call_debrief.js
@@ -0,0 +1,517 @@
+import {
+ Component,
+ onWillStart,
+ onWillUpdateProps,
+ onWillUnmount,
+ props,
+ proxy,
+ signal,
+ useEffect,
+ useListener,
+} from "@odoo/owl";
+import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
+import { CallDebriefTimeline } from "@mail/views/fields/call_debrief/call_debrief_timeline";
+import { CallDebriefMediaControls } from "@mail/views/fields/call_debrief/call_debrief_media_controls";
+import { registry } from "@web/core/registry";
+import { useService } from "@web/core/utils/hooks";
+import { standardFieldProps } from "@web/views/fields/standard_field_props";
+import { deserializeDateTime } from "@web/core/l10n/dates";
+import { _t } from "@web/core/l10n/translation";
+
+export class CallDebrief extends Component {
+ static template = "mail.CallDebrief";
+ static components = { CallDebriefTimeline, CallDebriefMediaControls };
+
+ props = props({
+ ...standardFieldProps,
+ // The name of the field on the record that stores the call's start datetime.
+ callStartDateField: { type: String },
+ // The name of the field on the record that stores the call's end datetime.
+ callEndDateField: { type: String },
+ });
+
+ setup() {
+ this.callDurationSeconds = 0;
+ this.playbackRates = [0.25, 0.5, 0.75, 0.9, 1, 1.25, 1.5, 1.75, 2, 3];
+ this.skipNextTimeUpdate = false;
+ this.isSwitchingSegment = false;
+
+ this.mediaPlayer = signal(null);
+
+ this.orm = useService("orm");
+ this.state = proxy({
+ currentTime: 0,
+ mediaSegments: [],
+ currentSegment: undefined,
+ error: "",
+ isPlaying: false,
+ isFullscreen: false,
+ playbackRate: 1,
+ volume: this.env.isSmall ? 1 : 0.5,
+ isMuted: false,
+ feedback: { icon: "", text: "", id: Date.now() },
+ });
+
+ this.onMediaLoadedCallback = null;
+
+ onWillStart(() => this._loadData(this.props));
+
+ onWillUpdateProps(async (nextProps) => {
+ const hasIdChanged = this.props.record.resId !== nextProps.record.resId;
+ const hasFieldChanged =
+ this.props.record.data[this.props.name] !== nextProps.record.data[nextProps.name];
+ if (hasIdChanged || hasFieldChanged) {
+ await this._loadData(nextProps);
+ }
+ });
+
+ useHotkey("k", () => this.togglePlay(), { global: true });
+ useHotkey("space", () => this.togglePlay(), { global: true });
+ useHotkey("j", () => this.seekRelative(-5), { global: true, allowRepeat: true });
+ useHotkey("l", () => this.seekRelative(5), { global: true, allowRepeat: true });
+ useHotkey("arrowleft", () => this.seekRelative(-5), { global: true, allowRepeat: true });
+ useHotkey("arrowright", () => this.seekRelative(5), { global: true, allowRepeat: true });
+ useHotkey("m", () => this.toggleMute(), { global: true });
+ // Supports AZERTY keyboard layouts
+ useHotkey("shift+.", () => this.adjustPlaybackRate(1), { global: true });
+ useHotkey("shift+?", () => this.adjustPlaybackRate(-1), { global: true });
+ // Supports QWERTY keyboard layouts
+ useHotkey("shift+>", () => this.adjustPlaybackRate(1), { global: true });
+ useHotkey("shift+<", () => this.adjustPlaybackRate(-1), { global: true });
+ useHotkey("f", () => this.toggleFullscreen(), { global: true });
+ useListener(document, "fullscreenchange", () => {
+ this.state.isFullscreen = !!document.fullscreenElement;
+ });
+
+ onWillUnmount(() => {
+ clearTimeout(this.feedbackTimeout);
+ });
+
+ // Effect for hardware media synchronization
+ useEffect(() => {
+ const media = this.mediaPlayer();
+ if (media) {
+ media.playbackRate = this.state.playbackRate;
+ media.volume = this.state.volume;
+ media.muted = this.state.isMuted;
+ }
+ });
+ }
+
+ get hasMedia() {
+ return this.state.mediaSegments.length > 0;
+ }
+
+ get hasTimeline() {
+ return this.hasMedia;
+ }
+
+ get hasVideo() {
+ return this.state.currentSegment?.type === "video";
+ }
+
+ onMediaError() {
+ this.showFeedback(_t("Media Error"));
+ console.warn("Media playback error. The format might not be supported by your browser.");
+ }
+
+ _initCallTiming(start, end) {
+ if (!start || !end) {
+ this.state.error = _t(
+ "CallDebrief widget needs start and end datetime from the parent record."
+ );
+ this._resetState();
+ return false;
+ }
+ const callStartDate = typeof start === "string" ? deserializeDateTime(start) : start;
+ const callEndDate = typeof end === "string" ? deserializeDateTime(end) : end;
+
+ const duration = callEndDate.diff(callStartDate, "seconds").seconds;
+ if (duration < 0) {
+ this.state.error = _t("Invalid call timing: end date is before start date.");
+ this._resetState();
+ return false;
+ }
+ this.callDurationSeconds = duration;
+ return true;
+ }
+
+ _resetState() {
+ this.state.mediaSegments = [];
+ this.state.currentSegment = undefined;
+ this.state.currentTime = 0;
+ }
+
+ async _loadData(props) {
+ this.state.error = "";
+ this.state.isPlaying = false;
+ this.state.currentSegment = undefined;
+ this.state.mediaSegments = [];
+
+ const start = props.record.data[props.callStartDateField];
+ const end = props.record.data[props.callEndDateField];
+
+ if (!this._initCallTiming(start, end)) {
+ return;
+ }
+
+ const artifactData = props.record.data[props.name];
+ let artifactIds = [];
+ if (artifactData && artifactData.currentIds) {
+ artifactIds = artifactData.currentIds;
+ } else if (Array.isArray(artifactData)) {
+ artifactIds = artifactData;
+ }
+
+ if (!artifactIds.length) {
+ return;
+ }
+
+ const fieldsToRead = this._getArtifactFields();
+
+ let artifacts;
+ try {
+ artifacts = await this.orm.read("mail.call.artifact", artifactIds, fieldsToRead);
+ } catch (e) {
+ this.state.error = _t("Could not load call recordings");
+ console.error(e);
+ return;
+ }
+
+ if (!artifacts.length) {
+ return;
+ }
+
+ const mediaIds = artifacts.flatMap((a) => a.media_id?.[0] ?? []);
+ const attachmentData = await this.orm.read("ir.attachment", mediaIds, ["mimetype"]);
+ const mimeMap = Object.fromEntries(attachmentData.map((a) => [a.id, a.mimetype]));
+
+ const segments = [];
+ for (const art of artifacts) {
+ const startSec = art.start_ms / 1000;
+ if (art.media_id) {
+ const mediaId = art.media_id[0];
+ const mime = mimeMap[mediaId] || "";
+ const isVideo = mime.startsWith("video/");
+ const isAudio = mime.startsWith("audio/");
+
+ if (isVideo || isAudio) {
+ const endSec = art.end_ms / 1000;
+ segments.push({
+ id: art.id,
+ mediaId: mediaId,
+ mediaUrl: `/web/content/${mediaId}`,
+ type: isVideo ? "video" : "audio",
+ startSec: startSec,
+ endSec: endSec,
+ duration: endSec - startSec,
+ });
+ }
+ }
+ }
+ segments.sort((a, b) => a.startSec - b.startSec);
+
+ this.state.mediaSegments = segments;
+ if (segments.length > 0) {
+ this.state.currentSegment = segments[0];
+ }
+ }
+
+ /**
+ * Hook to provide fields to read from mail.call.artifact.
+ * Overridden in AI module to add AI-specific fields.
+ */
+ _getArtifactFields() {
+ return ["media_id", "start_ms", "end_ms"];
+ }
+
+ /**
+ * Finds the appropriate media segment for the given timestamp or artifact ID.
+ */
+ _findTargetSegment(timestamp, artifactId) {
+ if (artifactId) {
+ return this.state.mediaSegments.find((s) => s.id === artifactId);
+ }
+
+ let nextSegment;
+ for (const segment of this.state.mediaSegments) {
+ if (timestamp >= segment.startSec && timestamp < segment.endSec) {
+ return segment; // Exact match found
+ }
+ // Track the closest upcoming segment if we fall in a gap
+ if (segment.startSec > timestamp) {
+ if (!nextSegment || segment.startSec < nextSegment.startSec) {
+ nextSegment = segment;
+ }
+ }
+ }
+ return nextSegment;
+ }
+
+ /**
+ * Applies the target segment, timeline position, and play state to the