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 (
|
create table episode_actions (
|
||||||
id integer primary key not null,
|
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
|
-- Can be null, as the device is not always provided
|
||||||
device_id bigint
|
device_id bigint
|
||||||
references devices (id)
|
references devices (id)
|
||||||
|
|
|
@ -18,22 +18,24 @@ use crate::db::{schema::*, DbPool, DbResult};
|
||||||
#[diesel(table_name = episode_actions)]
|
#[diesel(table_name = episode_actions)]
|
||||||
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
||||||
pub struct EpisodeAction {
|
pub struct EpisodeAction {
|
||||||
id: i64,
|
pub id: i64,
|
||||||
device_id: Option<i64>,
|
pub user_id: i64,
|
||||||
podcast_url: String,
|
pub device_id: Option<i64>,
|
||||||
episode_url: String,
|
pub podcast_url: String,
|
||||||
time_changed: i64,
|
pub episode_url: String,
|
||||||
timestamp: Option<i64>,
|
pub time_changed: i64,
|
||||||
action: ActionType,
|
pub timestamp: Option<i64>,
|
||||||
started: Option<i32>,
|
pub action: ActionType,
|
||||||
position: Option<i32>,
|
pub started: Option<i32>,
|
||||||
total: Option<i32>,
|
pub position: Option<i32>,
|
||||||
|
pub total: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Insertable)]
|
#[derive(Deserialize, Insertable)]
|
||||||
#[diesel(table_name = episode_actions)]
|
#[diesel(table_name = episode_actions)]
|
||||||
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
||||||
pub struct NewEpisodeAction {
|
pub struct NewEpisodeAction {
|
||||||
|
pub user_id: i64,
|
||||||
pub device_id: Option<i64>,
|
pub device_id: Option<i64>,
|
||||||
pub podcast_url: String,
|
pub podcast_url: String,
|
||||||
pub episode_url: String,
|
pub episode_url: String,
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use super::SqliteRepository;
|
use super::SqliteRepository;
|
||||||
|
@ -20,6 +21,7 @@ impl From<gpodder::EpisodeAction> for db::NewEpisodeAction {
|
||||||
};
|
};
|
||||||
|
|
||||||
db::NewEpisodeAction {
|
db::NewEpisodeAction {
|
||||||
|
user_id: 0,
|
||||||
device_id: None,
|
device_id: None,
|
||||||
podcast_url: value.podcast,
|
podcast_url: value.podcast,
|
||||||
episode_url: value.episode,
|
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 {
|
impl gpodder::EpisodeActionRepository for SqliteRepository {
|
||||||
fn add_episode_actions(
|
fn add_episode_actions(
|
||||||
&self,
|
&self,
|
||||||
|
@ -55,6 +87,7 @@ impl gpodder::EpisodeActionRepository for SqliteRepository {
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut new_action: db::NewEpisodeAction = action.into();
|
let mut new_action: db::NewEpisodeAction = action.into();
|
||||||
|
new_action.user_id = user.id;
|
||||||
new_action.device_id = device_id;
|
new_action.device_id = device_id;
|
||||||
new_action.time_changed = time_changed;
|
new_action.time_changed = time_changed;
|
||||||
|
|
||||||
|
@ -72,10 +105,68 @@ impl gpodder::EpisodeActionRepository for SqliteRepository {
|
||||||
fn episode_actions_for_user(
|
fn episode_actions_for_user(
|
||||||
&self,
|
&self,
|
||||||
user: &gpodder::User,
|
user: &gpodder::User,
|
||||||
|
since: Option<i64>,
|
||||||
podcast: Option<String>,
|
podcast: Option<String>,
|
||||||
device: Option<String>,
|
device: Option<String>,
|
||||||
aggregated: bool,
|
aggregated: bool,
|
||||||
) -> (i64, Vec<gpodder::EpisodeAction>) {
|
) -> Result<(i64, Vec<gpodder::EpisodeAction>), gpodder::AuthErr> {
|
||||||
todo!()
|
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! {
|
diesel::table! {
|
||||||
episode_actions (id) {
|
episode_actions (id) {
|
||||||
id -> BigInt,
|
id -> BigInt,
|
||||||
|
user_id -> BigInt,
|
||||||
device_id -> Nullable<BigInt>,
|
device_id -> Nullable<BigInt>,
|
||||||
podcast_url -> Text,
|
podcast_url -> Text,
|
||||||
episode_url -> Text,
|
episode_url -> Text,
|
||||||
|
@ -54,6 +55,7 @@ diesel::table! {
|
||||||
diesel::joinable!(device_subscriptions -> devices (device_id));
|
diesel::joinable!(device_subscriptions -> devices (device_id));
|
||||||
diesel::joinable!(devices -> users (user_id));
|
diesel::joinable!(devices -> users (user_id));
|
||||||
diesel::joinable!(episode_actions -> devices (device_id));
|
diesel::joinable!(episode_actions -> devices (device_id));
|
||||||
|
diesel::joinable!(episode_actions -> users (user_id));
|
||||||
diesel::joinable!(sessions -> users (user_id));
|
diesel::joinable!(sessions -> users (user_id));
|
||||||
|
|
||||||
diesel::allow_tables_to_appear_in_same_query!(
|
diesel::allow_tables_to_appear_in_same_query!(
|
||||||
|
|
|
@ -83,8 +83,9 @@ pub trait EpisodeActionRepository {
|
||||||
fn episode_actions_for_user(
|
fn episode_actions_for_user(
|
||||||
&self,
|
&self,
|
||||||
user: &User,
|
user: &User,
|
||||||
|
since: Option<i64>,
|
||||||
podcast: Option<String>,
|
podcast: Option<String>,
|
||||||
device: Option<String>,
|
device: Option<String>,
|
||||||
aggregated: bool,
|
aggregated: bool,
|
||||||
) -> (i64, Vec<EpisodeAction>);
|
) -> Result<(i64, Vec<EpisodeAction>), AuthErr>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
middleware,
|
middleware,
|
||||||
routing::post,
|
routing::post,
|
||||||
Extension, Json, Router,
|
Extension, Json, Router,
|
||||||
};
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
gpodder::{self, EpisodeActionRepository},
|
gpodder::{self, EpisodeActionRepository},
|
||||||
|
@ -20,7 +21,10 @@ use crate::{
|
||||||
|
|
||||||
pub fn router(ctx: Context) -> Router<Context> {
|
pub fn router(ctx: Context) -> Router<Context> {
|
||||||
Router::new()
|
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))
|
.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