feat: implemented episode actions GET route

episode-actions
Jef Roosens 2025-03-04 16:44:30 +01:00
parent bcfb8805eb
commit 029eb95382
No known key found for this signature in database
GPG Key ID: 21FD3D77D56BAF49
7 changed files with 180 additions and 15 deletions

View File

@ -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
}

View File

@ -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)

View File

@ -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,

View File

@ -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))
}
}

View File

@ -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!(

View File

@ -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>;
}

View File

@ -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 }))?)
}