feat: implement episode actions add endpoint
							parent
							
								
									064365fb4f
								
							
						
					
					
						commit
						bcfb8805eb
					
				|  | @ -12,7 +12,7 @@ post { | |||
| 
 | ||||
| params:path { | ||||
|   user: test | ||||
|   device_id: test4.json | ||||
|   device_id: test_device.json | ||||
| } | ||||
| 
 | ||||
| body:json { | ||||
|  |  | |||
|  | @ -6,10 +6,30 @@ meta { | |||
| 
 | ||||
| post { | ||||
|   url: http://localhost:8080/api/2/episodes/:username | ||||
|   body: none | ||||
|   auth: none | ||||
|   body: json | ||||
|   auth: inherit | ||||
| } | ||||
| 
 | ||||
| params:path { | ||||
|   username:  | ||||
|   username: test.json | ||||
| } | ||||
| 
 | ||||
| body:json { | ||||
|   [ | ||||
|     { | ||||
|      "podcast": "http://example.com/feed.rss", | ||||
|      "episode": "http://example.com/files/s01e20.mp3", | ||||
|      "device": "test_device", | ||||
|      "action": "download", | ||||
|      "timestamp": "2009-12-12T09:00:00" | ||||
|     }, | ||||
|     { | ||||
|      "podcast": "http://example.org/podcast.php", | ||||
|      "episode": "http://ftp.example.org/foo.ogg", | ||||
|      "action": "play", | ||||
|      "started": 15, | ||||
|      "position": 120, | ||||
|      "total":  500 | ||||
|     } | ||||
|   ] | ||||
| } | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| drop table episode_actions; | ||||
| 
 | ||||
| drop table subscriptions; | ||||
| drop table device_subscriptions; | ||||
| 
 | ||||
| drop table devices; | ||||
| 
 | ||||
|  |  | |||
|  | @ -38,7 +38,7 @@ create table device_subscriptions ( | |||
| 
 | ||||
|     podcast_url text not null, | ||||
| 
 | ||||
|     time_changed bigint not null default 0, | ||||
|     time_changed bigint not null, | ||||
|     deleted boolean not null default false, | ||||
| 
 | ||||
|     unique (device_id, podcast_url) | ||||
|  | @ -55,6 +55,8 @@ create table episode_actions ( | |||
|     podcast_url text not null, | ||||
|     episode_url text not null, | ||||
| 
 | ||||
|     time_changed bigint not null, | ||||
| 
 | ||||
|     timestamp bigint, | ||||
|     action text not null, | ||||
|     started integer, | ||||
|  | @ -62,10 +64,10 @@ create table episode_actions ( | |||
|     total integer, | ||||
| 
 | ||||
|     -- Position should be set if the action type is "Play" and null otherwise | ||||
|     check ((action = "Play") = (position is not null)), | ||||
|     check ((action = "play") = (position is not null)), | ||||
| 
 | ||||
|     -- Started and position can only be set if the action type is "Play" | ||||
|     check (action = "Play" or (started is null and position is null)), | ||||
|     check (action = "play" or (started is null and position is null)), | ||||
| 
 | ||||
|     -- Started and position should be provided together | ||||
|     check ((started is null) = (total is null)) | ||||
|  |  | |||
|  | @ -52,16 +52,34 @@ impl Device { | |||
|             .get_results(&mut pool.get()?)?) | ||||
|     } | ||||
| 
 | ||||
|     pub fn by_device_id(pool: &DbPool, user_id: i64, device_id: &str) -> DbResult<Option<Self>> { | ||||
|         Ok(devices::dsl::devices | ||||
|     pub fn device_id_to_id( | ||||
|         conn: &mut SqliteConnection, | ||||
|         user_id: i64, | ||||
|         device_id: &str, | ||||
|     ) -> diesel::QueryResult<i64> { | ||||
|         devices::table | ||||
|             .select(devices::id) | ||||
|             .filter( | ||||
|                 devices::user_id | ||||
|                     .eq(user_id) | ||||
|                     .and(devices::device_id.eq(device_id)), | ||||
|             ) | ||||
|             .get_result(conn) | ||||
|     } | ||||
| 
 | ||||
|     pub fn by_device_id( | ||||
|         conn: &mut SqliteConnection, | ||||
|         user_id: i64, | ||||
|         device_id: &str, | ||||
|     ) -> diesel::QueryResult<Self> { | ||||
|         devices::dsl::devices | ||||
|             .select(Self::as_select()) | ||||
|             .filter( | ||||
|                 devices::user_id | ||||
|                     .eq(user_id) | ||||
|                     .and(devices::device_id.eq(device_id)), | ||||
|             ) | ||||
|             .get_result(&mut pool.get()?) | ||||
|             .optional()?) | ||||
|             .get_result(conn) | ||||
|     } | ||||
| 
 | ||||
|     pub fn update(&self, pool: &DbPool) -> DbResult<()> { | ||||
|  |  | |||
|  | @ -22,6 +22,7 @@ pub struct EpisodeAction { | |||
|     device_id: Option<i64>, | ||||
|     podcast_url: String, | ||||
|     episode_url: String, | ||||
|     time_changed: i64, | ||||
|     timestamp: Option<i64>, | ||||
|     action: ActionType, | ||||
|     started: Option<i32>, | ||||
|  | @ -33,14 +34,15 @@ pub struct EpisodeAction { | |||
| #[diesel(table_name = episode_actions)] | ||||
| #[diesel(check_for_backend(diesel::sqlite::Sqlite))] | ||||
| pub struct NewEpisodeAction { | ||||
|     device_id: Option<i64>, | ||||
|     podcast_url: String, | ||||
|     episode_url: String, | ||||
|     timestamp: Option<i64>, | ||||
|     action: ActionType, | ||||
|     started: Option<i32>, | ||||
|     position: Option<i32>, | ||||
|     total: Option<i32>, | ||||
|     pub device_id: Option<i64>, | ||||
|     pub podcast_url: String, | ||||
|     pub episode_url: String, | ||||
|     pub time_changed: i64, | ||||
|     pub timestamp: Option<i64>, | ||||
|     pub action: ActionType, | ||||
|     pub started: Option<i32>, | ||||
|     pub position: Option<i32>, | ||||
|     pub total: Option<i32>, | ||||
| } | ||||
| 
 | ||||
| #[derive(Serialize, Deserialize, FromSqlRow, Debug, AsExpression, Clone)] | ||||
|  |  | |||
|  | @ -6,18 +6,76 @@ use crate::{ | |||
|     gpodder, | ||||
| }; | ||||
| 
 | ||||
| impl From<gpodder::EpisodeAction> for db::NewEpisodeAction { | ||||
|     fn from(value: gpodder::EpisodeAction) -> Self { | ||||
|         let (action, started, position, total) = match value.action { | ||||
|             gpodder::EpisodeActionType::New => (db::ActionType::New, None, None, None), | ||||
|             gpodder::EpisodeActionType::Delete => (db::ActionType::Delete, None, None, None), | ||||
|             gpodder::EpisodeActionType::Download => (db::ActionType::Download, None, None, None), | ||||
|             gpodder::EpisodeActionType::Play { | ||||
|                 started, | ||||
|                 position, | ||||
|                 total, | ||||
|             } => (db::ActionType::Play, started, Some(position), total), | ||||
|         }; | ||||
| 
 | ||||
|         db::NewEpisodeAction { | ||||
|             device_id: None, | ||||
|             podcast_url: value.podcast, | ||||
|             episode_url: value.episode, | ||||
|             time_changed: 0, | ||||
|             timestamp: value.timestamp.map(|t| t.and_utc().timestamp()), | ||||
|             action, | ||||
|             started, | ||||
|             position, | ||||
|             total, | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl gpodder::EpisodeActionRepository for SqliteRepository { | ||||
|     fn add_episode_actions( | ||||
|         &self, | ||||
|         user: &gpodder::User, | ||||
|         actions: Vec<gpodder::EpisodeAction>, | ||||
|     ) -> Result<i64, gpodder::AuthErr> { | ||||
|         let time_changed = chrono::Utc::now().timestamp_millis(); | ||||
| 
 | ||||
|         // 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<String> 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
 | ||||
|         self.pool.get()?.transaction(|conn| { | ||||
|             for action in actions {} | ||||
|             for action in actions { | ||||
|                 let device_id = if let Some(device) = &action.device { | ||||
|                     Some(db::Device::device_id_to_id(conn, user.id, device)?) | ||||
|                 } else { | ||||
|                     None | ||||
|                 }; | ||||
| 
 | ||||
|                 let mut new_action: db::NewEpisodeAction = action.into(); | ||||
|                 new_action.device_id = device_id; | ||||
|                 new_action.time_changed = time_changed; | ||||
| 
 | ||||
|                 diesel::insert_into(episode_actions::table) | ||||
|                     .values(&new_action) | ||||
|                     .execute(conn)?; | ||||
|             } | ||||
| 
 | ||||
|             Ok::<_, diesel::result::Error>(()) | ||||
|         })?; | ||||
| 
 | ||||
|         Ok(time_changed + 1) | ||||
|     } | ||||
| 
 | ||||
|     fn episode_actions_for_user( | ||||
|         &self, | ||||
|         user: &gpodder::User, | ||||
|         podcast: Option<String>, | ||||
|         device: Option<String>, | ||||
|         aggregated: bool, | ||||
|     ) -> (i64, Vec<gpodder::EpisodeAction>) { | ||||
|         todo!() | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -27,6 +27,7 @@ diesel::table! { | |||
|         device_id -> Nullable<BigInt>, | ||||
|         podcast_url -> Text, | ||||
|         episode_url -> Text, | ||||
|         time_changed -> BigInt, | ||||
|         timestamp -> Nullable<BigInt>, | ||||
|         action -> Text, | ||||
|         started -> Nullable<Integer>, | ||||
|  |  | |||
|  | @ -78,4 +78,13 @@ pub trait EpisodeActionRepository { | |||
|     /// Insert the given episode actions into the datastore.
 | ||||
|     fn add_episode_actions(&self, user: &User, actions: Vec<EpisodeAction>) | ||||
|         -> Result<i64, AuthErr>; | ||||
| 
 | ||||
|     /// Retrieve the list of episode actions for the given user.
 | ||||
|     fn episode_actions_for_user( | ||||
|         &self, | ||||
|         user: &User, | ||||
|         podcast: Option<String>, | ||||
|         device: Option<String>, | ||||
|         aggregated: bool, | ||||
|     ) -> (i64, Vec<EpisodeAction>); | ||||
| } | ||||
|  |  | |||
|  | @ -49,11 +49,11 @@ pub enum EpisodeActionType { | |||
| 
 | ||||
| #[derive(Serialize, Deserialize, Debug)] | ||||
| pub struct EpisodeAction { | ||||
|     podcast: String, | ||||
|     episode: String, | ||||
|     timestamp: Option<NaiveDateTime>, | ||||
|     pub podcast: String, | ||||
|     pub episode: String, | ||||
|     pub timestamp: Option<NaiveDateTime>, | ||||
|     #[serde(default)] | ||||
|     device: Option<String>, | ||||
|     pub device: Option<String>, | ||||
|     #[serde(flatten)] | ||||
|     action: EpisodeActionType, | ||||
|     pub action: EpisodeActionType, | ||||
| } | ||||
|  |  | |||
|  | @ -6,13 +6,13 @@ use axum::{ | |||
| }; | ||||
| 
 | ||||
| use crate::{ | ||||
|     db, | ||||
|     gpodder::{self, EpisodeActionRepository}, | ||||
|     server::{ | ||||
|         error::{AppError, AppResult}, | ||||
|         gpodder::{ | ||||
|             auth_middleware, | ||||
|             format::{Format, StringWithFormat}, | ||||
|             models::{EpisodeAction, UpdatedUrlsResponse}, | ||||
|             models::UpdatedUrlsResponse, | ||||
|         }, | ||||
|         Context, | ||||
|     }, | ||||
|  | @ -27,8 +27,8 @@ pub fn router(ctx: Context) -> Router<Context> { | |||
| async fn post_episode_actions( | ||||
|     State(ctx): State<Context>, | ||||
|     Path(username): Path<StringWithFormat>, | ||||
|     Extension(user): Extension<db::User>, | ||||
|     Json(actions): Json<Vec<EpisodeAction>>, | ||||
|     Extension(user): Extension<gpodder::User>, | ||||
|     Json(actions): Json<Vec<gpodder::EpisodeAction>>, | ||||
| ) -> AppResult<Json<UpdatedUrlsResponse>> { | ||||
|     if username.format != Format::Json { | ||||
|         return Err(AppError::NotFound); | ||||
|  | @ -38,7 +38,15 @@ async fn post_episode_actions( | |||
|         return Err(AppError::BadRequest); | ||||
|     } | ||||
| 
 | ||||
|     tracing::debug!("{:?}", actions); | ||||
| 
 | ||||
|     todo!() | ||||
|     Ok( | ||||
|         tokio::task::spawn_blocking(move || ctx.repo.add_episode_actions(&user, actions)) | ||||
|             .await | ||||
|             .unwrap() | ||||
|             .map(|timestamp| { | ||||
|                 Json(UpdatedUrlsResponse { | ||||
|                     timestamp, | ||||
|                     update_urls: Vec::new(), | ||||
|                 }) | ||||
|             })?, | ||||
|     ) | ||||
| } | ||||
|  |  | |||
|  | @ -13,9 +13,7 @@ use crate::{ | |||
|         gpodder::{ | ||||
|             auth_middleware, | ||||
|             format::{Format, StringWithFormat}, | ||||
|             models::{ | ||||
|                 DeviceType, SubscriptionDelta, SubscriptionDeltaResponse, UpdatedUrlsResponse, | ||||
|             }, | ||||
|             models::{SubscriptionDelta, SubscriptionDeltaResponse, UpdatedUrlsResponse}, | ||||
|         }, | ||||
|         Context, | ||||
|     }, | ||||
|  |  | |||
|  | @ -69,30 +69,3 @@ pub struct UpdatedUrlsResponse { | |||
|     pub timestamp: i64, | ||||
|     pub update_urls: Vec<(String, String)>, | ||||
| } | ||||
| 
 | ||||
| #[derive(Serialize, Deserialize, Debug)] | ||||
| #[serde(rename_all = "lowercase")] | ||||
| #[serde(tag = "action")] | ||||
| pub enum EpisodeActionType { | ||||
|     Download, | ||||
|     Play { | ||||
|         #[serde(default)] | ||||
|         started: Option<i32>, | ||||
|         position: i32, | ||||
|         #[serde(default)] | ||||
|         total: Option<i32>, | ||||
|     }, | ||||
|     Delete, | ||||
|     New, | ||||
| } | ||||
| 
 | ||||
| #[derive(Serialize, Deserialize, Debug)] | ||||
| pub struct EpisodeAction { | ||||
|     podcast: String, | ||||
|     episode: String, | ||||
|     timestamp: Option<NaiveDateTime>, | ||||
|     #[serde(default)] | ||||
|     device: Option<String>, | ||||
|     #[serde(flatten)] | ||||
|     action: EpisodeActionType, | ||||
| } | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue