use std::collections::{HashMap, HashSet};
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use crate::client::ScoutClient;
use crate::models::{MediaType, ResponseScout, ResponseScoutStatus};
const DEFAULT_PAGE_LIMIT: usize = 20;
const MAX_PAGE_LIMIT: usize = 500;
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "webm", "mov", "mkv", "avi"];
const IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "gif", "webp", "bmp"];
const AUDIO_EXTENSIONS: &[&str] = &["wav", "mp3", "m4a", "aac", "flac", "ogg"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum GalleryFeedType {
Event,
Artifact,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GalleryCursor {
pub timestamp: String,
pub id: i64,
pub feed_type: GalleryFeedType,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GalleryPageOptions {
pub limit: Option<usize>,
pub cursor: Option<GalleryCursor>,
pub range_start: Option<String>,
pub range_end: Option<String>,
}
impl GalleryPageOptions {
fn page_limit(&self) -> Result<usize> {
let limit = self.limit.unwrap_or(DEFAULT_PAGE_LIMIT);
if !(1..=MAX_PAGE_LIMIT).contains(&limit) {
return Err(anyhow!("limit must be between 1 and {MAX_PAGE_LIMIT}"));
}
if let Some(cursor) = &self.cursor {
validate_id("cursor id", cursor.id)?;
}
Ok(limit)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GalleryItem {
pub feed_type: GalleryFeedType,
pub id: i64,
pub sort_ts: String,
pub device_id: i64,
pub herd_id: i64,
pub session_id: Option<i64>,
pub timestamp_observation: Option<String>,
pub message: Option<String>,
pub media_type: Option<MediaType>,
pub modality: Option<String>,
pub file_path: Option<String>,
pub media_url: Option<String>,
pub thumbnail_file_path: Option<String>,
pub thumbnail_generated_at: Option<String>,
pub proxy_file_path: Option<String>,
pub proxy_generated_at: Option<String>,
pub processing_blocked_at: Option<String>,
pub processing_blocked_reason: Option<String>,
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub duration_seconds: Option<f64>,
}
impl GalleryItem {
pub fn media_kind(&self) -> MediaKind {
classify_media(
self.file_path.as_deref(),
self.media_url.as_deref(),
self.media_type.as_ref(),
self.modality.as_deref(),
)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GalleryPage {
pub items: Vec<GalleryItem>,
pub next_cursor: Option<GalleryCursor>,
pub has_more: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GalleryItemKey {
pub feed_type: GalleryFeedType,
pub id: i64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GalleryItemsByIds {
pub event_ids: Vec<i64>,
pub artifact_ids: Vec<i64>,
pub ranked_ids: Vec<GalleryItemKey>,
}
impl GalleryItemsByIds {
fn normalized(&self) -> Result<Self> {
let ranked_ids = dedupe_keys(&self.ranked_ids)?;
let mut event_ids = self.event_ids.clone();
let mut artifact_ids = self.artifact_ids.clone();
for key in &ranked_ids {
match key.feed_type {
GalleryFeedType::Event => event_ids.push(key.id),
GalleryFeedType::Artifact => artifact_ids.push(key.id),
}
}
Ok(Self {
event_ids: dedupe_ids("event", event_ids)?,
artifact_ids: dedupe_ids("artifact", artifact_ids)?,
ranked_ids,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MediaKind {
Image,
Video,
Audio,
Text,
Unknown,
}
impl ScoutClient {
pub async fn get_gallery_by_device(
&mut self,
device_id: i64,
options: &GalleryPageOptions,
) -> Result<ResponseScout<GalleryPage>> {
validate_id("device_id", device_id)?;
self.get_gallery_page(
"get_gallery_infinite_by_device",
"device_id_caller",
device_id,
options,
)
.await
}
pub async fn get_gallery_by_herd(
&mut self,
herd_id: i64,
options: &GalleryPageOptions,
) -> Result<ResponseScout<GalleryPage>> {
validate_id("herd_id", herd_id)?;
self.get_gallery_page(
"get_gallery_infinite_by_herd",
"herd_id_caller",
herd_id,
options,
)
.await
}
pub async fn get_gallery_items_by_ids(
&mut self,
request: &GalleryItemsByIds,
) -> Result<ResponseScout<Vec<GalleryItem>>> {
let request = request.normalized()?;
if request.event_ids.is_empty() && request.artifact_ids.is_empty() {
return Ok(success(Vec::new()));
}
let params = serde_json::json!({
"event_ids": request.event_ids,
"artifact_ids": request.artifact_ids,
})
.to_string();
let mut items: Vec<GalleryItem> = self
.get_db_client()?
.query(|client| client.rpc("get_gallery_items_by_ids", params))
.await?;
rank_items(&mut items, &request);
Ok(success(items))
}
async fn get_gallery_page(
&mut self,
function: &str,
id_parameter: &str,
id: i64,
options: &GalleryPageOptions,
) -> Result<ResponseScout<GalleryPage>> {
let limit = options.page_limit()?;
let mut params = serde_json::json!({
"limit_caller": limit + 1,
"cursor_timestamp": options.cursor.as_ref().map(|cursor| &cursor.timestamp),
"cursor_id": options.cursor.as_ref().map(|cursor| cursor.id),
"cursor_feed_type": options.cursor.as_ref().map(|cursor| cursor.feed_type),
"range_start": options.range_start.as_deref(),
"range_end": options.range_end.as_deref(),
});
params[id_parameter] = id.into();
let params = params.to_string();
let rows = self
.get_db_client()?
.query(|client| client.rpc(function, params))
.await?;
Ok(success(page_from_rows(rows, limit)))
}
}
fn success<T>(data: T) -> ResponseScout<T> {
ResponseScout::new(ResponseScoutStatus::Success, Some(data))
}
fn page_from_rows(mut items: Vec<GalleryItem>, limit: usize) -> GalleryPage {
let has_more = items.len() > limit;
if has_more {
items.truncate(limit);
}
let next_cursor = has_more.then(|| {
let last = items.last().expect("positive validated page limit");
GalleryCursor {
timestamp: last.sort_ts.clone(),
id: last.id,
feed_type: last.feed_type,
}
});
GalleryPage {
items,
next_cursor,
has_more,
}
}
fn validate_id(name: &str, id: i64) -> Result<()> {
if id <= 0 {
return Err(anyhow!("{name} must be positive"));
}
Ok(())
}
fn dedupe_ids(name: &str, ids: Vec<i64>) -> Result<Vec<i64>> {
let mut seen = HashSet::new();
let mut unique = Vec::with_capacity(ids.len());
for id in ids {
if id <= 0 {
return Err(anyhow!("{name} ids must be positive"));
}
if seen.insert(id) {
unique.push(id);
}
}
Ok(unique)
}
fn dedupe_keys(keys: &[GalleryItemKey]) -> Result<Vec<GalleryItemKey>> {
let mut seen = HashSet::new();
let mut unique = Vec::with_capacity(keys.len());
for &key in keys {
validate_id("ranked ids", key.id)?;
if seen.insert(key) {
unique.push(key);
}
}
Ok(unique)
}
fn rank_items(items: &mut [GalleryItem], request: &GalleryItemsByIds) {
let ranks: HashMap<_, _> = if request.ranked_ids.is_empty() {
request
.event_ids
.iter()
.enumerate()
.map(|(rank, &id)| ((GalleryFeedType::Event, id), rank))
.chain(
request
.artifact_ids
.iter()
.enumerate()
.map(|(rank, &id)| ((GalleryFeedType::Artifact, id), rank)),
)
.collect()
} else {
request
.ranked_ids
.iter()
.enumerate()
.map(|(rank, key)| ((key.feed_type, key.id), rank))
.collect()
};
let rank = |item: &GalleryItem| {
ranks
.get(&(item.feed_type, item.id))
.copied()
.unwrap_or(usize::MAX)
};
items.sort_by(|left, right| {
rank(left)
.cmp(&rank(right))
.then_with(|| right.sort_ts.cmp(&left.sort_ts))
.then_with(|| right.id.cmp(&left.id))
.then_with(|| right.feed_type.cmp(&left.feed_type))
});
}
pub fn classify_media(
file_path: Option<&str>,
media_url: Option<&str>,
media_type: Option<&MediaType>,
modality: Option<&str>,
) -> MediaKind {
let extension = media_extension(file_path).or_else(|| media_extension(media_url));
if let Some(extension) = extension.as_deref() {
if VIDEO_EXTENSIONS.contains(&extension) {
return MediaKind::Video;
}
if IMAGE_EXTENSIONS.contains(&extension) {
return MediaKind::Image;
}
if AUDIO_EXTENSIONS.contains(&extension) {
return MediaKind::Audio;
}
}
match media_type {
Some(MediaType::Image) => MediaKind::Image,
Some(MediaType::Video) => MediaKind::Video,
Some(MediaType::Audio) => MediaKind::Audio,
Some(MediaType::Text) => MediaKind::Text,
None if modality == Some("video") => MediaKind::Video,
None => MediaKind::Unknown,
}
}
fn media_extension(path: Option<&str>) -> Option<String> {
path.and_then(|path| path.split(['?', '#']).next())
.and_then(|path| path.rsplit('/').next())
.and_then(|name| name.rsplit_once('.'))
.filter(|(name, extension)| !name.is_empty() && !extension.is_empty())
.map(|(_, extension)| extension)
.map(str::to_ascii_lowercase)
}
#[cfg(test)]
mod tests {
use super::*;
fn item(feed_type: GalleryFeedType, id: i64, sort_ts: &str) -> GalleryItem {
GalleryItem {
feed_type,
id,
sort_ts: sort_ts.into(),
device_id: 1,
herd_id: 1,
session_id: None,
timestamp_observation: None,
message: None,
media_type: None,
modality: None,
file_path: None,
media_url: None,
thumbnail_file_path: None,
thumbnail_generated_at: None,
proxy_file_path: None,
proxy_generated_at: None,
processing_blocked_at: None,
processing_blocked_reason: None,
latitude: None,
longitude: None,
duration_seconds: None,
}
}
#[test]
fn overfetched_page_is_the_only_proof_of_more_rows() {
let exact = page_from_rows(
vec![
item(GalleryFeedType::Event, 1, "2026-01-02T00:00:00Z"),
item(GalleryFeedType::Artifact, 2, "2026-01-01T00:00:00Z"),
],
2,
);
assert!(!exact.has_more);
assert!(exact.next_cursor.is_none());
let extra = page_from_rows(
vec![
item(GalleryFeedType::Event, 1, "2026-01-03T00:00:00Z"),
item(GalleryFeedType::Artifact, 2, "2026-01-02T00:00:00Z"),
item(GalleryFeedType::Event, 3, "2026-01-01T00:00:00Z"),
],
2,
);
assert!(extra.has_more);
assert_eq!(extra.items.len(), 2);
assert_eq!(extra.next_cursor.unwrap().id, 2);
}
#[test]
fn batch_request_dedupes_and_derives_ids_from_mixed_ranking() {
let request = GalleryItemsByIds {
event_ids: vec![10, 10],
ranked_ids: vec![
GalleryItemKey {
feed_type: GalleryFeedType::Artifact,
id: 30,
},
GalleryItemKey {
feed_type: GalleryFeedType::Event,
id: 11,
},
],
..Default::default()
}
.normalized()
.unwrap();
assert_eq!(request.event_ids, vec![10, 11]);
assert_eq!(request.artifact_ids, vec![30]);
}
#[test]
fn batch_request_rejects_non_positive_ids() {
assert!(GalleryItemsByIds {
artifact_ids: vec![0],
..Default::default()
}
.normalized()
.is_err());
}
#[test]
fn mixed_ranking_is_preserved() {
let request = GalleryItemsByIds {
ranked_ids: vec![
GalleryItemKey {
feed_type: GalleryFeedType::Artifact,
id: 30,
},
GalleryItemKey {
feed_type: GalleryFeedType::Event,
id: 10,
},
],
..Default::default()
};
let mut items = vec![
item(GalleryFeedType::Event, 10, "2026-01-02T00:00:00Z"),
item(GalleryFeedType::Artifact, 30, "2026-01-01T00:00:00Z"),
];
rank_items(&mut items, &request);
assert_eq!(items[0].id, 30);
}
#[test]
fn classifier_handles_m4v_and_extensionless_legacy_video() {
assert_eq!(
classify_media(Some("events/1/clip.M4V?token=x"), None, None, None),
MediaKind::Video
);
assert_eq!(
classify_media(Some("events/1/legacy"), None, Some(&MediaType::Video), None),
MediaKind::Video
);
assert_eq!(
classify_media(
Some("events/1/legacy"),
Some("https://cdn.example/clip.mov"),
None,
None
),
MediaKind::Video
);
assert_eq!(
classify_media(Some("events/1/.mp4"), None, None, None),
MediaKind::Unknown
);
}
#[test]
fn page_options_reject_invalid_limit() {
assert!(GalleryPageOptions {
limit: Some(0),
..Default::default()
}
.page_limit()
.is_err());
}
}