use chrono::{DateTime, Utc}; use diesel::prelude::*; use gpodder::AuthErr; use super::SqliteRepository; use crate::{ DbError, models::{ device::Device, episode_action::{ActionType, EpisodeAction, NewEpisodeAction}, }, schema::*, }; impl From for NewEpisodeAction { fn from(value: gpodder::EpisodeAction) -> Self { let (action, started, position, total) = match value.action { gpodder::EpisodeActionType::New => (ActionType::New, None, None, None), gpodder::EpisodeActionType::Delete => (ActionType::Delete, None, None, None), gpodder::EpisodeActionType::Download => (ActionType::Download, None, None, None), gpodder::EpisodeActionType::Play { started, position, total, } => (ActionType::Play, started, Some(position), total), }; NewEpisodeAction { user_id: 0, device_id: None, podcast_url: value.podcast, episode_url: value.episode, time_changed: 0, timestamp: value.timestamp.map(|t| t.timestamp()), action, started, position, total, } } } fn to_gpodder_action( (device_id, db_action): (Option, EpisodeAction), ) -> gpodder::EpisodeAction { let action = match db_action.action { ActionType::Play => gpodder::EpisodeActionType::Play { started: db_action.started, // SAFETY: the condition that this isn't null if the action type is "play" is // explicitely enforced by the database using a CHECK constraint. position: db_action.position.unwrap(), total: db_action.total, }, ActionType::New => gpodder::EpisodeActionType::New, ActionType::Delete => gpodder::EpisodeActionType::Delete, ActionType::Download => gpodder::EpisodeActionType::Download, }; gpodder::EpisodeAction { podcast: db_action.podcast_url, episode: db_action.episode_url, timestamp: db_action .timestamp // SAFETY the input to the from_timestamp function is always the result of a // previous timestamp() function call, which is guaranteed to be each other's // reverse .map(|ts| DateTime::from_timestamp(ts, 0).unwrap()), time_changed: DateTime::from_timestamp(db_action.time_changed, 0).unwrap(), device: device_id, action, } } impl gpodder::GpodderEpisodeActionStore for SqliteRepository { fn add_episode_actions( &self, user: &gpodder::User, actions: Vec, time_changed: DateTime, ) -> Result<(), gpodder::AuthErr> { (|| { let time_changed = time_changed.timestamp(); // TODO optimize this query // 1. The lookup for a device could be replaced with a subquery, although Diesel seems to // have a problem using an Option to match equality with a String // 2. Ideally the for loop would be replaced with a single query inserting multiple values, // although each value would need its own subquery // // NOTE this function usually gets called from the same device, so optimizing the // amount of device lookups required would be useful. self.pool.get()?.transaction(|conn| { for action in actions { let device_id = if let Some(device) = &action.device { Some(Device::device_id_to_id(conn, user.id, device)?) } else { None }; let mut new_action: NewEpisodeAction = action.into(); new_action.user_id = user.id; new_action.device_id = device_id; new_action.time_changed = time_changed; diesel::insert_into(episode_actions::table) .values(&new_action) .execute(conn)?; } Ok::<_, DbError>(()) }) })() .map_err(AuthErr::from) } fn episode_actions_for_user( &self, user: &gpodder::User, since: Option>, podcast: Option, device: Option, aggregated: bool, ) -> Result, gpodder::AuthErr> { (|| { let since = since.map(|ts| ts.timestamp()).unwrap_or(0); let conn = &mut self.pool.get()?; let mut query = episode_actions::table .left_join(devices::table) .filter( episode_actions::user_id .eq(user.id) .and(episode_actions::time_changed.ge(since)), ) .select((devices::device_id.nullable(), EpisodeAction::as_select())) .into_boxed(); if let Some(device_id) = device { query = query.filter(devices::device_id.eq(device_id)); } if let Some(podcast_url) = podcast { query = query.filter(episode_actions::podcast_url.eq(podcast_url)); } let db_actions: Vec<(Option, EpisodeAction)> = if aggregated { // https://stackoverflow.com/a/7745635 // For each episode URL, we want to return the row with the highest `time_changed` // value. We achieve this be left joining with self on the URL, as well as whether the // left row's time_changed value is less than the right one. Rows with the largest // time_changed value for a given URL will join with a NULL value (because of the left // join), so we filter those out to retrieve the correct rows. let a2 = diesel::alias!(episode_actions as a2); query .left_join( a2.on(episode_actions::episode_url .eq(a2.field(episode_actions::episode_url)) .and( episode_actions::time_changed .lt(a2.field(episode_actions::time_changed)), )), ) .filter(a2.field(episode_actions::episode_url).is_null()) .get_results(conn)? } else { query.get_results(conn)? }; let actions = db_actions.into_iter().map(to_gpodder_action).collect(); Ok::<_, DbError>(actions) })() .map_err(AuthErr::from) } }