feat: implemented episode actions GET route
parent
bcfb8805eb
commit
029eb95382
|
@ -0,0 +1,19 @@
|
|||
meta {
|
||||
name: Get episode actions
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: http://localhost:8080/api/2/episodes/:username
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
params:query {
|
||||
:
|
||||
}
|
||||
|
||||
params:path {
|
||||
username: test.json
|
||||
}
|
|
@ -47,6 +47,9 @@ create table device_subscriptions (
|
|||
create table episode_actions (
|
||||
id integer primary key not null,
|
||||
|
||||
user_id bigint not null
|
||||
references users (id)
|
||||
on delete cascade,
|
||||
-- Can be null, as the device is not always provided
|
||||
device_id bigint
|
||||
references devices (id)
|
||||
|
|
|
@ -18,22 +18,24 @@ use crate::db::{schema::*, DbPool, DbResult};
|
|||
#[diesel(table_name = episode_actions)]
|
||||
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
||||
pub struct EpisodeAction {
|
||||
id: i64,
|
||||
device_id: Option<i64>,
|
||||
podcast_url: String,
|
||||
episode_url: String,
|
||||
time_changed: i64,
|
||||
timestamp: Option<i64>,
|
||||
action: ActionType,
|
||||
started: Option<i32>,
|
||||
position: Option<i32>,
|
||||
total: Option<i32>,
|
||||
pub id: i64,
|
||||
pub user_id: i64,
|
||||
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(Deserialize, Insertable)]
|
||||
#[diesel(table_name = episode_actions)]
|
||||
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
||||
pub struct NewEpisodeAction {
|
||||
pub user_id: i64,
|
||||
pub device_id: Option<i64>,
|
||||
pub podcast_url: String,
|
||||
pub episode_url: String,
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use super::SqliteRepository;
|
||||
|
@ -20,6 +21,7 @@ impl From<gpodder::EpisodeAction> for db::NewEpisodeAction {
|
|||
};
|
||||
|
||||
db::NewEpisodeAction {
|
||||
user_id: 0,
|
||||
device_id: None,
|
||||
podcast_url: value.podcast,
|
||||
episode_url: value.episode,
|
||||
|
@ -33,6 +35,36 @@ impl From<gpodder::EpisodeAction> for db::NewEpisodeAction {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<(Option<String>, db::EpisodeAction)> for gpodder::EpisodeAction {
|
||||
fn from((device_id, db_action): (Option<String>, db::EpisodeAction)) -> Self {
|
||||
let action = match db_action.action {
|
||||
db::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,
|
||||
},
|
||||
db::ActionType::New => gpodder::EpisodeActionType::New,
|
||||
db::ActionType::Delete => gpodder::EpisodeActionType::Delete,
|
||||
db::ActionType::Download => gpodder::EpisodeActionType::Download,
|
||||
};
|
||||
|
||||
Self {
|
||||
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().naive_utc()),
|
||||
device: device_id,
|
||||
action,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl gpodder::EpisodeActionRepository for SqliteRepository {
|
||||
fn add_episode_actions(
|
||||
&self,
|
||||
|
@ -55,6 +87,7 @@ impl gpodder::EpisodeActionRepository for SqliteRepository {
|
|||
};
|
||||
|
||||
let mut new_action: db::NewEpisodeAction = action.into();
|
||||
new_action.user_id = user.id;
|
||||
new_action.device_id = device_id;
|
||||
new_action.time_changed = time_changed;
|
||||
|
||||
|
@ -72,10 +105,68 @@ impl gpodder::EpisodeActionRepository for SqliteRepository {
|
|||
fn episode_actions_for_user(
|
||||
&self,
|
||||
user: &gpodder::User,
|
||||
since: Option<i64>,
|
||||
podcast: Option<String>,
|
||||
device: Option<String>,
|
||||
aggregated: bool,
|
||||
) -> (i64, Vec<gpodder::EpisodeAction>) {
|
||||
todo!()
|
||||
) -> Result<(i64, Vec<gpodder::EpisodeAction>), gpodder::AuthErr> {
|
||||
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.unwrap_or(0))),
|
||||
)
|
||||
.select((
|
||||
devices::device_id.nullable(),
|
||||
db::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<String>, db::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 max_timestamp = db_actions
|
||||
.iter()
|
||||
.map(|(_, a)| a.time_changed)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let actions = db_actions
|
||||
.into_iter()
|
||||
.map(gpodder::EpisodeAction::from)
|
||||
.collect();
|
||||
|
||||
Ok((max_timestamp + 1, actions))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ diesel::table! {
|
|||
diesel::table! {
|
||||
episode_actions (id) {
|
||||
id -> BigInt,
|
||||
user_id -> BigInt,
|
||||
device_id -> Nullable<BigInt>,
|
||||
podcast_url -> Text,
|
||||
episode_url -> Text,
|
||||
|
@ -54,6 +55,7 @@ diesel::table! {
|
|||
diesel::joinable!(device_subscriptions -> devices (device_id));
|
||||
diesel::joinable!(devices -> users (user_id));
|
||||
diesel::joinable!(episode_actions -> devices (device_id));
|
||||
diesel::joinable!(episode_actions -> users (user_id));
|
||||
diesel::joinable!(sessions -> users (user_id));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
|
|
|
@ -83,8 +83,9 @@ pub trait EpisodeActionRepository {
|
|||
fn episode_actions_for_user(
|
||||
&self,
|
||||
user: &User,
|
||||
since: Option<i64>,
|
||||
podcast: Option<String>,
|
||||
device: Option<String>,
|
||||
aggregated: bool,
|
||||
) -> (i64, Vec<EpisodeAction>);
|
||||
) -> Result<(i64, Vec<EpisodeAction>), AuthErr>;
|
||||
}
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
use axum::{
|
||||
extract::{Path, State},
|
||||
extract::{Path, Query, State},
|
||||
middleware,
|
||||
routing::post,
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
gpodder::{self, EpisodeActionRepository},
|
||||
|
@ -20,7 +21,10 @@ use crate::{
|
|||
|
||||
pub fn router(ctx: Context) -> Router<Context> {
|
||||
Router::new()
|
||||
.route("/{username}", post(post_episode_actions))
|
||||
.route(
|
||||
"/{username}",
|
||||
post(post_episode_actions).get(get_episode_actions),
|
||||
)
|
||||
.layer(middleware::from_fn_with_state(ctx.clone(), auth_middleware))
|
||||
}
|
||||
|
||||
|
@ -50,3 +54,46 @@ async fn post_episode_actions(
|
|||
})?,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct FilterQuery {
|
||||
podcast: Option<String>,
|
||||
device: Option<String>,
|
||||
since: Option<i64>,
|
||||
aggregated: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EpisodeActionsResponse {
|
||||
timestamp: i64,
|
||||
actions: Vec<gpodder::EpisodeAction>,
|
||||
}
|
||||
|
||||
async fn get_episode_actions(
|
||||
State(ctx): State<Context>,
|
||||
Path(username): Path<StringWithFormat>,
|
||||
Extension(user): Extension<gpodder::User>,
|
||||
Query(filter): Query<FilterQuery>,
|
||||
) -> AppResult<Json<EpisodeActionsResponse>> {
|
||||
if username.format != Format::Json {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
if *username != user.username {
|
||||
return Err(AppError::BadRequest);
|
||||
}
|
||||
|
||||
Ok(tokio::task::spawn_blocking(move || {
|
||||
ctx.repo.episode_actions_for_user(
|
||||
&user,
|
||||
filter.since,
|
||||
filter.podcast,
|
||||
filter.device,
|
||||
filter.aggregated,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.map(|(timestamp, actions)| Json(EpisodeActionsResponse { timestamp, actions }))?)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue