From 3e79bec974c21d9ac16e2a2c9b614e1e1ffee89e Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Fri, 28 Feb 2025 13:48:44 +0100 Subject: [PATCH] wip episode actions --- Cargo.lock | 13 ++ Cargo.toml | 4 +- .../Upload action changes.bru | 15 +++ migrations/2025-02-23-095541_initial/up.sql | 20 +++- src/db/mod.rs | 1 + src/db/models/episode_action.rs | 113 ++++++++++++++++++ src/db/models/mod.rs | 1 + src/db/repository/episode_action.rs | 23 ++++ src/db/repository/mod.rs | 1 + src/db/schema.rs | 17 +++ src/gpodder/mod.rs | 12 +- src/gpodder/models.rs | 28 +++++ src/server/gpodder/advanced/episodes.rs | 44 +++++++ src/server/gpodder/advanced/mod.rs | 2 + src/server/gpodder/advanced/subscriptions.rs | 8 +- src/server/gpodder/models.rs | 30 ++++- 16 files changed, 319 insertions(+), 13 deletions(-) create mode 100644 bruno/Episode Actions API/Upload action changes.bru create mode 100644 src/db/models/episode_action.rs create mode 100644 src/db/repository/episode_action.rs create mode 100644 src/server/gpodder/advanced/episodes.rs diff --git a/Cargo.lock b/Cargo.lock index 2f0cebb..3c8d1d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,6 +107,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" dependencies = [ "axum-core", + "axum-macros", "bytes", "form_urlencoded", "futures-util", @@ -177,6 +178,17 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "backtrace" version = "0.3.74" @@ -271,6 +283,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-targets", ] diff --git a/Cargo.toml b/Cargo.toml index 821e5cf..fee72f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,9 +5,9 @@ edition = "2021" [dependencies] argon2 = "0.5.3" -axum = { version = "0.8.1" } +axum = { version = "0.8.1", features = ["macros"] } axum-extra = { version = "0.10", features = ["cookie", "typed-header"] } -chrono = "0.4.39" +chrono = { version = "0.4.39", features = ["serde"] } clap = { version = "4.5.30", features = ["derive", "env"] } diesel = { version = "2.2.7", features = ["r2d2", "sqlite", "returning_clauses_for_sqlite_3_35"] } diesel_migrations = { version = "2.2.0", features = ["sqlite"] } diff --git a/bruno/Episode Actions API/Upload action changes.bru b/bruno/Episode Actions API/Upload action changes.bru new file mode 100644 index 0000000..132e089 --- /dev/null +++ b/bruno/Episode Actions API/Upload action changes.bru @@ -0,0 +1,15 @@ +meta { + name: Upload action changes + type: http + seq: 1 +} + +post { + url: http://localhost:8080/api/2/episodes/:username + body: none + auth: none +} + +params:path { + username: +} diff --git a/migrations/2025-02-23-095541_initial/up.sql b/migrations/2025-02-23-095541_initial/up.sql index c97a60b..a1b1773 100644 --- a/migrations/2025-02-23-095541_initial/up.sql +++ b/migrations/2025-02-23-095541_initial/up.sql @@ -1,14 +1,17 @@ create table users ( id integer primary key not null, + username text unique not null, password_hash text not null ); create table sessions ( id bigint primary key not null, + user_id bigint not null references users (id) on delete cascade, + unique (id, user_id) ); @@ -28,15 +31,24 @@ create table devices ( create table subscriptions ( id integer primary key not null, + + url text unique not null +); + +create table device_subscriptions ( + id integer primary key not null, + device_id bigint not null references devices (id) on delete cascade, - url text not null, + subscription_id bigint not null + references subscriptions (id) + on delete cascade time_changed bigint not null default 0, deleted boolean not null default false, - unique (device_id, url) + unique (device_id, subscription_id) ); create table episode_actions ( @@ -44,14 +56,14 @@ create table episode_actions ( subscription_id bigint not null references subscriptions (id) - on delete cascade, + on delete set null, -- Can be null, as the device is not always provided device_id bigint references devices (id) on delete set null, - episode text not null, + episode_url text not null, timestamp bigint, action text not null, started integer, diff --git a/src/db/mod.rs b/src/db/mod.rs index 3f2ec04..e09f4be 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -3,6 +3,7 @@ mod repository; mod schema; pub use models::device::{Device, DeviceType, NewDevice}; +pub use models::episode_action::{ActionType, EpisodeAction, NewEpisodeAction}; pub use models::session::Session; pub use models::subscription::{NewSubscription, Subscription}; pub use models::user::{NewUser, User}; diff --git a/src/db/models/episode_action.rs b/src/db/models/episode_action.rs new file mode 100644 index 0000000..3305742 --- /dev/null +++ b/src/db/models/episode_action.rs @@ -0,0 +1,113 @@ +use std::{fmt, str::FromStr}; + +use chrono::NaiveDateTime; +use diesel::{ + deserialize::{FromSql, FromSqlRow}, + expression::AsExpression, + prelude::{Insertable, Queryable}, + serialize::ToSql, + sql_types::Text, + sqlite::{Sqlite, SqliteValue}, + Selectable, +}; +use serde::{Deserialize, Serialize}; + +use crate::db::{schema::*, DbPool, DbResult}; + +#[derive(Serialize, Deserialize, Clone, Queryable, Selectable)] +#[diesel(table_name = episode_actions)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub struct EpisodeAction { + id: i64, + subscription_id: i64, + device_id: Option, + episode: String, + timestamp: Option, + action: ActionType, + started: Option, + position: Option, + total: Option, +} + +#[derive(Deserialize, Insertable)] +#[diesel(table_name = episode_actions)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub struct NewEpisodeAction { + subscription_id: i64, + device_id: Option, + episode: String, + timestamp: Option, + action: ActionType, + started: Option, + position: Option, + total: Option, +} + +#[derive(Serialize, Deserialize, FromSqlRow, Debug, AsExpression, Clone)] +#[diesel(sql_type = Text)] +#[serde(rename_all = "lowercase")] +pub enum ActionType { + New, + Download, + Play, + Delete, +} + +impl fmt::Display for ActionType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::New => "new", + Self::Download => "download", + Self::Play => "play", + Self::Delete => "delete", + } + ) + } +} + +#[derive(Debug)] +pub struct ActionTypeParseErr(String); + +impl fmt::Display for ActionTypeParseErr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "invalid action type '{}'", self.0) + } +} + +impl std::error::Error for ActionTypeParseErr {} + +impl FromStr for ActionType { + type Err = ActionTypeParseErr; + + fn from_str(s: &str) -> Result { + match s { + "new" => Ok(Self::New), + "download" => Ok(Self::Download), + "delete" => Ok(Self::Delete), + "play" => Ok(Self::Play), + _ => Err(ActionTypeParseErr(s.to_string())), + } + } +} + +impl FromSql for ActionType { + fn from_sql(bytes: SqliteValue) -> diesel::deserialize::Result { + let s = >::from_sql(bytes)?; + + Ok(s.as_str().parse()?) + } +} + +impl ToSql for ActionType { + fn to_sql<'b>( + &'b self, + out: &mut diesel::serialize::Output<'b, '_, Sqlite>, + ) -> diesel::serialize::Result { + out.set_value(self.to_string()); + + Ok(diesel::serialize::IsNull::No) + } +} diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 5a36197..6e36b0f 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -1,4 +1,5 @@ pub mod device; +pub mod episode_action; pub mod session; pub mod subscription; pub mod user; diff --git a/src/db/repository/episode_action.rs b/src/db/repository/episode_action.rs new file mode 100644 index 0000000..5fbb834 --- /dev/null +++ b/src/db/repository/episode_action.rs @@ -0,0 +1,23 @@ +use diesel::prelude::*; + +use super::SqliteRepository; +use crate::{ + db::{self, schema::*}, + gpodder, +}; + +impl gpodder::EpisodeActionRepository for SqliteRepository { + fn add_episode_actions( + &self, + user: &gpodder::User, + actions: Vec, + ) -> Result { + self.pool.get()?.transaction(|conn| { + for action in actions {} + + Ok::<_, diesel::result::Error>(()) + })?; + + todo!() + } +} diff --git a/src/db/repository/mod.rs b/src/db/repository/mod.rs index f890c88..01a81d4 100644 --- a/src/db/repository/mod.rs +++ b/src/db/repository/mod.rs @@ -1,5 +1,6 @@ mod auth; mod device; +mod episode_action; mod subscription; use super::DbPool; diff --git a/src/db/schema.rs b/src/db/schema.rs index c9c4698..a7740af 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -11,6 +11,20 @@ diesel::table! { } } +diesel::table! { + episode_actions (id) { + id -> BigInt, + subscription_id -> BigInt, + device_id -> Nullable, + episode -> Text, + timestamp -> Nullable, + action -> Text, + started -> Nullable, + position -> Nullable, + total -> Nullable, + } +} + diesel::table! { sessions (id) { id -> BigInt, @@ -37,11 +51,14 @@ diesel::table! { } diesel::joinable!(devices -> users (user_id)); +diesel::joinable!(episode_actions -> devices (device_id)); +diesel::joinable!(episode_actions -> subscriptions (subscription_id)); diesel::joinable!(sessions -> users (user_id)); diesel::joinable!(subscriptions -> devices (device_id)); diesel::allow_tables_to_appear_in_same_query!( devices, + episode_actions, sessions, subscriptions, users, diff --git a/src/gpodder/mod.rs b/src/gpodder/mod.rs index 5c0a362..76e695a 100644 --- a/src/gpodder/mod.rs +++ b/src/gpodder/mod.rs @@ -9,7 +9,7 @@ pub enum AuthErr { Other(Box), } -pub trait AuthRepository: Send + Sync { +pub trait AuthRepository { /// Validate the given session ID and return its user. fn validate_session(&self, session_id: i64) -> Result; @@ -23,7 +23,7 @@ pub trait AuthRepository: Send + Sync { fn remove_session(&self, username: &str, session_id: i64) -> Result<(), AuthErr>; } -pub trait DeviceRepository: Send + Sync { +pub trait DeviceRepository { /// Return all devices associated with the user fn devices_for_user(&self, user: &User) -> Result, AuthErr>; @@ -37,7 +37,7 @@ pub trait DeviceRepository: Send + Sync { ) -> Result<(), AuthErr>; } -pub trait SubscriptionRepository: Send + Sync { +pub trait SubscriptionRepository { /// Return the subscriptions for the given device fn subscriptions_for_device( &self, @@ -73,3 +73,9 @@ pub trait SubscriptionRepository: Send + Sync { since: i64, ) -> Result<(i64, Vec, Vec), AuthErr>; } + +pub trait EpisodeActionRepository { + /// Insert the given episode actions into the datastore. + fn add_episode_actions(&self, user: &User, actions: Vec) + -> Result; +} diff --git a/src/gpodder/models.rs b/src/gpodder/models.rs index 296d612..157b1f6 100644 --- a/src/gpodder/models.rs +++ b/src/gpodder/models.rs @@ -1,3 +1,4 @@ +use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; #[derive(Clone)] @@ -29,3 +30,30 @@ pub struct DevicePatch { pub caption: Option, pub r#type: Option, } + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +#[serde(tag = "action")] +pub enum EpisodeActionType { + Download, + Play { + #[serde(default)] + started: Option, + position: i32, + #[serde(default)] + total: Option, + }, + Delete, + New, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct EpisodeAction { + podcast: String, + episode: String, + timestamp: Option, + #[serde(default)] + device: Option, + #[serde(flatten)] + action: EpisodeActionType, +} diff --git a/src/server/gpodder/advanced/episodes.rs b/src/server/gpodder/advanced/episodes.rs new file mode 100644 index 0000000..307c3f2 --- /dev/null +++ b/src/server/gpodder/advanced/episodes.rs @@ -0,0 +1,44 @@ +use axum::{ + extract::{Path, State}, + middleware, + routing::post, + Extension, Json, Router, +}; + +use crate::{ + db, + server::{ + error::{AppError, AppResult}, + gpodder::{ + auth_middleware, + format::{Format, StringWithFormat}, + models::{EpisodeAction, UpdatedUrlsResponse}, + }, + Context, + }, +}; + +pub fn router(ctx: Context) -> Router { + Router::new() + .route("/{username}", post(post_episode_actions)) + .layer(middleware::from_fn_with_state(ctx.clone(), auth_middleware)) +} + +async fn post_episode_actions( + State(ctx): State, + Path(username): Path, + Extension(user): Extension, + Json(actions): Json>, +) -> AppResult> { + if username.format != Format::Json { + return Err(AppError::NotFound); + } + + if *username != user.username { + return Err(AppError::BadRequest); + } + + tracing::debug!("{:?}", actions); + + todo!() +} diff --git a/src/server/gpodder/advanced/mod.rs b/src/server/gpodder/advanced/mod.rs index 1e92fc3..a1f596c 100644 --- a/src/server/gpodder/advanced/mod.rs +++ b/src/server/gpodder/advanced/mod.rs @@ -1,5 +1,6 @@ mod auth; mod devices; +mod episodes; mod subscriptions; use axum::Router; @@ -11,4 +12,5 @@ pub fn router(ctx: Context) -> Router { .nest("/auth", auth::router()) .nest("/devices", devices::router(ctx.clone())) .nest("/subscriptions", subscriptions::router(ctx.clone())) + .nest("/episodes", episodes::router(ctx.clone())) } diff --git a/src/server/gpodder/advanced/subscriptions.rs b/src/server/gpodder/advanced/subscriptions.rs index 6563ebe..f754a5f 100644 --- a/src/server/gpodder/advanced/subscriptions.rs +++ b/src/server/gpodder/advanced/subscriptions.rs @@ -13,7 +13,9 @@ use crate::{ gpodder::{ auth_middleware, format::{Format, StringWithFormat}, - models::{SubscriptionChangeResponse, SubscriptionDelta, SubscriptionDeltaResponse}, + models::{ + DeviceType, SubscriptionDelta, SubscriptionDeltaResponse, UpdatedUrlsResponse, + }, }, Context, }, @@ -33,7 +35,7 @@ pub async fn post_subscription_changes( Path((username, id)): Path<(String, StringWithFormat)>, Extension(user): Extension, Json(delta): Json, -) -> AppResult> { +) -> AppResult> { if id.format != Format::Json { return Err(AppError::NotFound); } @@ -49,7 +51,7 @@ pub async fn post_subscription_changes( .await .unwrap() .map(|timestamp| { - Json(SubscriptionChangeResponse { + Json(UpdatedUrlsResponse { timestamp, update_urls: Vec::new(), }) diff --git a/src/server/gpodder/models.rs b/src/server/gpodder/models.rs index d2b0bdf..6869a15 100644 --- a/src/server/gpodder/models.rs +++ b/src/server/gpodder/models.rs @@ -1,3 +1,4 @@ +use chrono::{NaiveDateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::db; @@ -64,7 +65,34 @@ pub struct SubscriptionDeltaResponse { } #[derive(Serialize)] -pub struct SubscriptionChangeResponse { +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, + position: i32, + #[serde(default)] + total: Option, + }, + Delete, + New, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct EpisodeAction { + podcast: String, + episode: String, + timestamp: Option, + #[serde(default)] + device: Option, + #[serde(flatten)] + action: EpisodeActionType, +}