1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
32#[non_exhaustive]
33pub enum MediaType {
34 Image,
36
37 Video,
39
40 Audio,
42
43 File,
45}
46
47#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
49pub struct MediaUsage {
50 pub count: u64,
52
53 pub size: u64,
56}
57
58#[derive(Debug, Clone, Default, PartialEq, Eq)]
71pub struct MediaMetrics {
72 pub total: MediaUsage,
74
75 pub by_type: BTreeMap<MediaType, MediaUsage>,
77}
78
79impl Client {
80 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 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
107const 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
193fn 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}