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