Skip to main content

matrix_sdk/
media_metrics.rs

1// Copyright 2026 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::{BTreeMap, HashMap};
16
17use matrix_sdk_base::{
18    deserialized_responses::TimelineEvent, event_cache::store::EventCacheStoreLockState,
19};
20use ruma::{
21    OwnedMxcUri, OwnedRoomId,
22    events::{
23        AnySyncMessageLikeEvent, AnySyncTimelineEvent,
24        room::{MediaSource, message::MessageType},
25    },
26};
27
28use crate::{Client, Error, Room, media::MediaEventContent};
29
30/// The type of a media item.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
32#[non_exhaustive]
33pub enum MediaType {
34    /// An image.
35    Image,
36
37    /// A video.
38    Video,
39
40    /// An audio file.
41    Audio,
42
43    /// A generic file.
44    File,
45}
46
47/// The number of media events and the size of their cached representations.
48#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
49pub struct MediaUsage {
50    /// The number of media events.
51    pub count: u64,
52
53    /// The total size in bytes of the cached representations of the media,
54    /// including thumbnails.
55    pub size: u64,
56}
57
58/// Metrics about the media cached locally.
59///
60/// The metrics are computed from the events in the local event cache. Media
61/// that is cached without a corresponding cached event is not included. The
62/// size of a media item is only counted once, even if it is referenced by
63/// several events.
64///
65/// Location events are not media events and are ignored, even if they contain
66/// a thumbnail. Events that cannot be deserialized are also ignored.
67///
68/// If a media item is shared by events of different types, its size is counted
69/// once and attributed to one of those types.
70#[derive(Debug, Clone, Default, PartialEq, Eq)]
71pub struct MediaMetrics {
72    /// Metrics for all media events.
73    pub total: MediaUsage,
74
75    /// Metrics for each media type.
76    pub by_type: BTreeMap<MediaType, MediaUsage>,
77}
78
79impl Client {
80    /// Get metrics about the media cached locally for all rooms.
81    ///
82    /// The metrics are computed from the events in the local event cache. A
83    /// media event whose content isn't cached yet still counts towards the
84    /// number of media events, but not towards the size.
85    pub async fn media_metrics(&self) -> Result<MediaMetrics, Error> {
86        let room_ids: Vec<OwnedRoomId> =
87            self.rooms().into_iter().map(|room| room.room_id().to_owned()).collect();
88
89        collect_media_metrics(self, &room_ids).await
90    }
91}
92
93impl Room {
94    /// Get metrics about the media cached locally for this room.
95    ///
96    /// The metrics are computed from the events in the local event cache. A
97    /// media event whose content isn't cached yet still counts towards the
98    /// number of media events, but not towards the size.
99    pub async fn media_metrics(&self) -> Result<MediaMetrics, Error> {
100        let client = self.client();
101        let room_id = self.room_id().to_owned();
102
103        collect_media_metrics(&client, &[room_id]).await
104    }
105}
106
107/// Event types handled by `media_of`.
108const MEDIA_EVENT_TYPES: &[&str] = &["m.room.message", "m.sticker"];
109
110async fn collect_media_metrics(
111    client: &Client,
112    room_ids: &[OwnedRoomId],
113) -> Result<MediaMetrics, Error> {
114    let mut by_type: BTreeMap<MediaType, MediaUsage> = BTreeMap::new();
115    let mut uris: HashMap<OwnedMxcUri, MediaType> = HashMap::new();
116
117    {
118        let state = client.event_cache_store().lock().await?;
119        let store = match state {
120            EventCacheStoreLockState::Clean(store) | EventCacheStoreLockState::Dirty(store) => {
121                store
122            }
123        };
124
125        for room_id in room_ids {
126            for &event_type in MEDIA_EVENT_TYPES {
127                let events = store.get_room_events(room_id, Some(event_type), None).await?;
128
129                for event in events {
130                    let Some((media_type, media_uris)) = media_of(&event) else {
131                        continue;
132                    };
133
134                    by_type.entry(media_type).or_default().count += 1;
135
136                    for uri in media_uris {
137                        uris.entry(uri).or_insert(media_type);
138                    }
139                }
140            }
141        }
142    }
143
144    if !uris.is_empty() {
145        let uris_to_query = uris.keys().cloned().collect::<Vec<_>>();
146        let guard = client.media_store().lock().await?;
147
148        for (uri, size) in guard.media_sizes(&uris_to_query).await? {
149            if let Some(media_type) = uris.get(&uri) {
150                by_type.entry(*media_type).or_default().size += size;
151            }
152        }
153    }
154
155    let total = MediaUsage {
156        count: by_type.values().map(|usage| usage.count).sum(),
157        size: by_type.values().map(|usage| usage.size).sum(),
158    };
159
160    Ok(MediaMetrics { total, by_type })
161}
162
163fn media_of(event: &TimelineEvent) -> Option<(MediaType, Vec<OwnedMxcUri>)> {
164    let event = event.raw().deserialize().ok()?;
165
166    match event {
167        AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(message)) => {
168            let content = &message.as_original()?.content;
169
170            let (media_type, content): (MediaType, &dyn MediaEventContent) = match &content.msgtype
171            {
172                MessageType::Image(content) => (MediaType::Image, content),
173                MessageType::Video(content) => (MediaType::Video, content),
174                MessageType::Audio(content) => (MediaType::Audio, content),
175                MessageType::File(content) => (MediaType::File, content),
176                _ => return None,
177            };
178
179            let uris = media_uris(content);
180            (!uris.is_empty()).then_some((media_type, uris))
181        }
182
183        AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::Sticker(sticker)) => {
184            let content = &sticker.as_original()?.content;
185            let uris = media_uris(content);
186            (!uris.is_empty()).then_some((MediaType::Image, uris))
187        }
188
189        _ => None,
190    }
191}
192
193/// Collect the unique media sources for an event.
194///
195/// Image and video contents fall back to their full-size source when they have
196/// no thumbnail, so duplicate URIs must be removed here.
197fn media_uris(content: &dyn MediaEventContent) -> Vec<OwnedMxcUri> {
198    let mut uris = Vec::new();
199
200    for source in [content.source(), content.thumbnail_source()].into_iter().flatten() {
201        let uri = match source {
202            MediaSource::Plain(uri) => uri,
203            MediaSource::Encrypted(file) => file.url.clone(),
204        };
205        if !uris.contains(&uri) {
206            uris.push(uri);
207        }
208    }
209
210    uris
211}
212
213#[cfg(test)]
214mod tests {
215    use matrix_sdk_base::deserialized_responses::TimelineEvent;
216    use ruma::{
217        events::{AnySyncMessageLikeEvent, AnySyncTimelineEvent, room::message::MessageType},
218        mxc_uri,
219        serde::Raw,
220    };
221    use serde_json::json;
222
223    use super::{MediaType, media_of};
224
225    fn timeline_event(value: serde_json::Value) -> TimelineEvent {
226        let json = serde_json::to_string(&value).unwrap();
227        TimelineEvent::from_plaintext(Raw::from_json_string(json).unwrap())
228    }
229
230    #[test]
231    fn media_of_image_uses_file_and_thumbnail() {
232        let event = timeline_event(json!({
233            "type": "m.room.message",
234            "event_id": "$event",
235            "sender": "@alice:example.org",
236            "origin_server_ts": 0,
237            "content": {
238                "msgtype": "m.image",
239                "body": "image.png",
240                "url": "mxc://example.org/image",
241                "info": {
242                    "thumbnail_url": "mxc://example.org/thumbnail"
243                }
244            }
245        }));
246
247        let (media_type, uris) = media_of(&event).unwrap();
248
249        assert_eq!(media_type, MediaType::Image);
250        assert_eq!(
251            uris,
252            vec![
253                mxc_uri!("mxc://example.org/image").to_owned(),
254                mxc_uri!("mxc://example.org/thumbnail").to_owned(),
255            ]
256        );
257    }
258
259    #[test]
260    fn media_of_location_is_ignored() {
261        let event = timeline_event(json!({
262            "type": "m.room.message",
263            "event_id": "$event",
264            "sender": "@alice:example.org",
265            "origin_server_ts": 0,
266            "content": {
267                "msgtype": "m.location",
268                "body": "Location",
269                "geo_uri": "geo:51.5008,0.1247",
270                "info": {
271                    "thumbnail_url": "mxc://example.org/thumbnail"
272                }
273            }
274        }));
275
276        let deserialized = event.raw().deserialize().unwrap();
277        let AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(message)) =
278            deserialized
279        else {
280            panic!("expected a room message event");
281        };
282        assert!(matches!(
283            &message.as_original().unwrap().content.msgtype,
284            MessageType::Location(_)
285        ));
286
287        assert!(media_of(&event).is_none());
288    }
289
290    #[test]
291    fn media_of_image_without_thumbnail_uses_source_once() {
292        let event = timeline_event(json!({
293            "type": "m.room.message",
294            "event_id": "$event",
295            "sender": "@alice:example.org",
296            "origin_server_ts": 0,
297            "content": {
298                "msgtype": "m.image",
299                "body": "image.png",
300                "url": "mxc://example.org/image"
301            }
302        }));
303
304        let (media_type, uris) = media_of(&event).unwrap();
305
306        assert_eq!(media_type, MediaType::Image);
307        assert_eq!(uris, vec![mxc_uri!("mxc://example.org/image").to_owned()]);
308    }
309
310    #[test]
311    fn media_of_text_is_ignored() {
312        let event = timeline_event(json!({
313            "type": "m.room.message",
314            "event_id": "$event",
315            "sender": "@alice:example.org",
316            "origin_server_ts": 0,
317            "content": {
318                "msgtype": "m.text",
319                "body": "hello"
320            }
321        }));
322
323        assert!(media_of(&event).is_none());
324    }
325}