Skip to main content

howler_core/
movebank.rs

1use crate::config::Config;
2use crate::db::Database;
3use crate::models::{Sighting, Source};
4use anyhow::{Context, Result};
5use chrono::{DateTime, Utc};
6use serde::Deserialize;
7
8const MOVEBANK_API_BASE: &str = "https://www.movebank.org/movebank-service/djson";
9
10#[derive(Debug, Deserialize)]
11#[allow(dead_code)]
12struct MovebankResponse {
13    #[serde(rename = "individuals")]
14    individuals: Option<Vec<MovebankIndividual>>,
15}
16
17#[derive(Debug, Deserialize)]
18#[allow(dead_code)]
19struct MovebankIndividual {
20    #[serde(rename = "individual_id")]
21    individual_id: Option<i64>,
22    #[serde(rename = "individual_local_identifier")]
23    individual_local_identifier: Option<String>,
24    #[serde(rename = "taxon_canonical_name")]
25    taxon_canonical_name: Option<String>,
26}
27
28#[derive(Debug, Deserialize)]
29struct MovebankLocationData {
30    #[serde(rename = "locations")]
31    locations: Option<Vec<MovebankLocation>>,
32}
33
34#[derive(Debug, Deserialize)]
35struct MovebankLocation {
36    #[serde(rename = "location_lat")]
37    location_lat: Option<f64>,
38    #[serde(rename = "location_long")]
39    location_long: Option<f64>,
40    #[serde(rename = "timestamp")]
41    timestamp: Option<String>,
42    #[serde(rename = "individual_id")]
43    individual_id: Option<i64>,
44    #[serde(rename = "individual_local_identifier")]
45    individual_local_identifier: Option<String>,
46}
47
48pub struct MovebankClient {
49    client: reqwest::Client,
50    username: String,
51    password: String,
52}
53
54impl MovebankClient {
55    pub fn new(config: &Config) -> Option<Self> {
56        if !config.has_movebank_credentials() {
57            return None;
58        }
59
60        Some(Self {
61            client: reqwest::Client::new(),
62            username: config.movebank_username.clone().unwrap(),
63            password: config.movebank_password.clone().unwrap(),
64        })
65    }
66
67    pub async fn fetch_wolf_tracks(&self, limit: u32) -> Result<Vec<Sighting>> {
68        // First, get a list of studies with wolf data
69        let studies_url = format!(
70            "{}?study_id=2928297&individual_local_identifier=*",
71            MOVEBANK_API_BASE
72        );
73
74        let response = self
75            .client
76            .get(&studies_url)
77            .basic_auth(&self.username, Some(&self.password))
78            .header("User-Agent", "Howler/0.1.0")
79            .send()
80            .await
81            .context("Failed to send Movebank request")?;
82
83        if !response.status().is_success() {
84            anyhow::bail!("Movebank API returned error: {}", response.status());
85        }
86
87        // For now, we'll use a simplified approach - fetch location data directly
88        // In a full implementation, you'd first list studies, then individuals, then locations
89        let locations_url = format!("{}?study_id=2928297&sensor_type_id=653", MOVEBANK_API_BASE);
90
91        let loc_response = self
92            .client
93            .get(&locations_url)
94            .basic_auth(&self.username, Some(&self.password))
95            .header("User-Agent", "Howler/0.1.0")
96            .send()
97            .await
98            .context("Failed to fetch Movebank locations")?;
99
100        if !loc_response.status().is_success() {
101            // If study-specific request fails, return empty but don't crash
102            return Ok(vec![]);
103        }
104
105        let location_data: MovebankLocationData = loc_response
106            .json()
107            .await
108            .context("Failed to parse Movebank location data")?;
109
110        let sightings = location_data
111            .locations
112            .unwrap_or_default()
113            .into_iter()
114            .filter_map(|loc| self.location_to_sighting(loc))
115            .take(limit as usize)
116            .collect();
117
118        Ok(sightings)
119    }
120
121    fn location_to_sighting(&self, loc: MovebankLocation) -> Option<Sighting> {
122        let latitude = loc.location_lat?;
123        let longitude = loc.location_long?;
124
125        let observed_on = if let Some(ts_str) = loc.timestamp {
126            DateTime::parse_from_rfc3339(&ts_str)
127                .or_else(|_| DateTime::parse_from_rfc2822(&ts_str))
128                .map(|dt| dt.with_timezone(&Utc))
129                .ok()
130        } else {
131            None
132        }
133        .unwrap_or_else(Utc::now);
134
135        let individual_id = loc.individual_local_identifier.unwrap_or_else(|| {
136            loc.individual_id
137                .map(|id| id.to_string())
138                .unwrap_or_default()
139        });
140
141        Some(Sighting {
142            id: None,
143            species: "Canis lupus".to_string(),
144            scientific_name: Some("Canis lupus".to_string()),
145            latitude,
146            longitude,
147            observed_on,
148            source: Source::Movebank,
149            source_id: format!("movebank_{}", individual_id),
150            details: Some(format!("Individual: {}", individual_id)),
151        })
152    }
153}
154
155pub async fn fetch_and_cache_movebank(db: &Database, config: &Config, limit: u32) -> Result<usize> {
156    let client = match MovebankClient::new(config) {
157        Some(c) => c,
158        None => {
159            eprintln!("Movebank credentials not found, skipping Movebank data");
160            return Ok(0);
161        }
162    };
163
164    let sightings = client.fetch_wolf_tracks(limit).await?;
165
166    let mut count = 0;
167    for sighting in sightings {
168        db.insert_sighting(&sighting)
169            .context("Failed to insert sighting into database")?;
170        count += 1;
171    }
172
173    Ok(count)
174}