wip episode actions

episode-actions
Jef Roosens 2025-02-28 13:48:44 +01:00
parent 7ce41cd034
commit 3e79bec974
No known key found for this signature in database
GPG Key ID: 21FD3D77D56BAF49
16 changed files with 319 additions and 13 deletions

13
Cargo.lock generated
View File

@ -107,6 +107,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8"
dependencies = [ dependencies = [
"axum-core", "axum-core",
"axum-macros",
"bytes", "bytes",
"form_urlencoded", "form_urlencoded",
"futures-util", "futures-util",
@ -177,6 +178,17 @@ dependencies = [
"tower-service", "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]] [[package]]
name = "backtrace" name = "backtrace"
version = "0.3.74" version = "0.3.74"
@ -271,6 +283,7 @@ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
"num-traits", "num-traits",
"serde",
"wasm-bindgen", "wasm-bindgen",
"windows-targets", "windows-targets",
] ]

View File

@ -5,9 +5,9 @@ edition = "2021"
[dependencies] [dependencies]
argon2 = "0.5.3" 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"] } 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"] } clap = { version = "4.5.30", features = ["derive", "env"] }
diesel = { version = "2.2.7", features = ["r2d2", "sqlite", "returning_clauses_for_sqlite_3_35"] } diesel = { version = "2.2.7", features = ["r2d2", "sqlite", "returning_clauses_for_sqlite_3_35"] }
diesel_migrations = { version = "2.2.0", features = ["sqlite"] } diesel_migrations = { version = "2.2.0", features = ["sqlite"] }

View File

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

View File

@ -1,14 +1,17 @@
create table users ( create table users (
id integer primary key not null, id integer primary key not null,
username text unique not null, username text unique not null,
password_hash text not null password_hash text not null
); );
create table sessions ( create table sessions (
id bigint primary key not null, id bigint primary key not null,
user_id bigint not null user_id bigint not null
references users (id) references users (id)
on delete cascade, on delete cascade,
unique (id, user_id) unique (id, user_id)
); );
@ -28,15 +31,24 @@ create table devices (
create table subscriptions ( create table subscriptions (
id integer primary key not null, 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 device_id bigint not null
references devices (id) references devices (id)
on delete cascade, 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, time_changed bigint not null default 0,
deleted boolean not null default false, deleted boolean not null default false,
unique (device_id, url) unique (device_id, subscription_id)
); );
create table episode_actions ( create table episode_actions (
@ -44,14 +56,14 @@ create table episode_actions (
subscription_id bigint not null subscription_id bigint not null
references subscriptions (id) references subscriptions (id)
on delete cascade, on delete set null,
-- 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)
on delete set null, on delete set null,
episode text not null, episode_url text not null,
timestamp bigint, timestamp bigint,
action text not null, action text not null,
started integer, started integer,

View File

@ -3,6 +3,7 @@ mod repository;
mod schema; mod schema;
pub use models::device::{Device, DeviceType, NewDevice}; pub use models::device::{Device, DeviceType, NewDevice};
pub use models::episode_action::{ActionType, EpisodeAction, NewEpisodeAction};
pub use models::session::Session; pub use models::session::Session;
pub use models::subscription::{NewSubscription, Subscription}; pub use models::subscription::{NewSubscription, Subscription};
pub use models::user::{NewUser, User}; pub use models::user::{NewUser, User};

View File

@ -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<i64>,
episode: String,
timestamp: Option<i64>,
action: ActionType,
started: Option<i32>,
position: Option<i32>,
total: Option<i32>,
}
#[derive(Deserialize, Insertable)]
#[diesel(table_name = episode_actions)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct NewEpisodeAction {
subscription_id: i64,
device_id: Option<i64>,
episode: String,
timestamp: Option<i64>,
action: ActionType,
started: Option<i32>,
position: Option<i32>,
total: Option<i32>,
}
#[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<Self, Self::Err> {
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<Text, Sqlite> for ActionType {
fn from_sql(bytes: SqliteValue) -> diesel::deserialize::Result<Self> {
let s = <String as FromSql<Text, Sqlite>>::from_sql(bytes)?;
Ok(s.as_str().parse()?)
}
}
impl ToSql<Text, Sqlite> 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)
}
}

View File

@ -1,4 +1,5 @@
pub mod device; pub mod device;
pub mod episode_action;
pub mod session; pub mod session;
pub mod subscription; pub mod subscription;
pub mod user; pub mod user;

View File

@ -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<gpodder::EpisodeAction>,
) -> Result<i64, gpodder::AuthErr> {
self.pool.get()?.transaction(|conn| {
for action in actions {}
Ok::<_, diesel::result::Error>(())
})?;
todo!()
}
}

View File

@ -1,5 +1,6 @@
mod auth; mod auth;
mod device; mod device;
mod episode_action;
mod subscription; mod subscription;
use super::DbPool; use super::DbPool;

View File

@ -11,6 +11,20 @@ diesel::table! {
} }
} }
diesel::table! {
episode_actions (id) {
id -> BigInt,
subscription_id -> BigInt,
device_id -> Nullable<BigInt>,
episode -> Text,
timestamp -> Nullable<BigInt>,
action -> Text,
started -> Nullable<Integer>,
position -> Nullable<Integer>,
total -> Nullable<Integer>,
}
}
diesel::table! { diesel::table! {
sessions (id) { sessions (id) {
id -> BigInt, id -> BigInt,
@ -37,11 +51,14 @@ diesel::table! {
} }
diesel::joinable!(devices -> users (user_id)); 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!(sessions -> users (user_id));
diesel::joinable!(subscriptions -> devices (device_id)); diesel::joinable!(subscriptions -> devices (device_id));
diesel::allow_tables_to_appear_in_same_query!( diesel::allow_tables_to_appear_in_same_query!(
devices, devices,
episode_actions,
sessions, sessions,
subscriptions, subscriptions,
users, users,

View File

@ -9,7 +9,7 @@ pub enum AuthErr {
Other(Box<dyn std::error::Error + Sync + Send>), Other(Box<dyn std::error::Error + Sync + Send>),
} }
pub trait AuthRepository: Send + Sync { pub trait AuthRepository {
/// Validate the given session ID and return its user. /// Validate the given session ID and return its user.
fn validate_session(&self, session_id: i64) -> Result<models::User, AuthErr>; fn validate_session(&self, session_id: i64) -> Result<models::User, AuthErr>;
@ -23,7 +23,7 @@ pub trait AuthRepository: Send + Sync {
fn remove_session(&self, username: &str, session_id: i64) -> Result<(), AuthErr>; 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 /// Return all devices associated with the user
fn devices_for_user(&self, user: &User) -> Result<Vec<Device>, AuthErr>; fn devices_for_user(&self, user: &User) -> Result<Vec<Device>, AuthErr>;
@ -37,7 +37,7 @@ pub trait DeviceRepository: Send + Sync {
) -> Result<(), AuthErr>; ) -> Result<(), AuthErr>;
} }
pub trait SubscriptionRepository: Send + Sync { pub trait SubscriptionRepository {
/// Return the subscriptions for the given device /// Return the subscriptions for the given device
fn subscriptions_for_device( fn subscriptions_for_device(
&self, &self,
@ -73,3 +73,9 @@ pub trait SubscriptionRepository: Send + Sync {
since: i64, since: i64,
) -> Result<(i64, Vec<String>, Vec<String>), AuthErr>; ) -> Result<(i64, Vec<String>, Vec<String>), AuthErr>;
} }
pub trait EpisodeActionRepository {
/// Insert the given episode actions into the datastore.
fn add_episode_actions(&self, user: &User, actions: Vec<EpisodeAction>)
-> Result<i64, AuthErr>;
}

View File

@ -1,3 +1,4 @@
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Clone)] #[derive(Clone)]
@ -29,3 +30,30 @@ pub struct DevicePatch {
pub caption: Option<String>, pub caption: Option<String>,
pub r#type: Option<DeviceType>, pub r#type: Option<DeviceType>,
} }
#[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,
}

View File

@ -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<Context> {
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<Context>,
Path(username): Path<StringWithFormat>,
Extension(user): Extension<db::User>,
Json(actions): Json<Vec<EpisodeAction>>,
) -> AppResult<Json<UpdatedUrlsResponse>> {
if username.format != Format::Json {
return Err(AppError::NotFound);
}
if *username != user.username {
return Err(AppError::BadRequest);
}
tracing::debug!("{:?}", actions);
todo!()
}

View File

@ -1,5 +1,6 @@
mod auth; mod auth;
mod devices; mod devices;
mod episodes;
mod subscriptions; mod subscriptions;
use axum::Router; use axum::Router;
@ -11,4 +12,5 @@ pub fn router(ctx: Context) -> Router<Context> {
.nest("/auth", auth::router()) .nest("/auth", auth::router())
.nest("/devices", devices::router(ctx.clone())) .nest("/devices", devices::router(ctx.clone()))
.nest("/subscriptions", subscriptions::router(ctx.clone())) .nest("/subscriptions", subscriptions::router(ctx.clone()))
.nest("/episodes", episodes::router(ctx.clone()))
} }

View File

@ -13,7 +13,9 @@ use crate::{
gpodder::{ gpodder::{
auth_middleware, auth_middleware,
format::{Format, StringWithFormat}, format::{Format, StringWithFormat},
models::{SubscriptionChangeResponse, SubscriptionDelta, SubscriptionDeltaResponse}, models::{
DeviceType, SubscriptionDelta, SubscriptionDeltaResponse, UpdatedUrlsResponse,
},
}, },
Context, Context,
}, },
@ -33,7 +35,7 @@ pub async fn post_subscription_changes(
Path((username, id)): Path<(String, StringWithFormat)>, Path((username, id)): Path<(String, StringWithFormat)>,
Extension(user): Extension<gpodder::User>, Extension(user): Extension<gpodder::User>,
Json(delta): Json<SubscriptionDelta>, Json(delta): Json<SubscriptionDelta>,
) -> AppResult<Json<SubscriptionChangeResponse>> { ) -> AppResult<Json<UpdatedUrlsResponse>> {
if id.format != Format::Json { if id.format != Format::Json {
return Err(AppError::NotFound); return Err(AppError::NotFound);
} }
@ -49,7 +51,7 @@ pub async fn post_subscription_changes(
.await .await
.unwrap() .unwrap()
.map(|timestamp| { .map(|timestamp| {
Json(SubscriptionChangeResponse { Json(UpdatedUrlsResponse {
timestamp, timestamp,
update_urls: Vec::new(), update_urls: Vec::new(),
}) })

View File

@ -1,3 +1,4 @@
use chrono::{NaiveDateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::db; use crate::db;
@ -64,7 +65,34 @@ pub struct SubscriptionDeltaResponse {
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct SubscriptionChangeResponse { pub struct UpdatedUrlsResponse {
pub timestamp: i64, pub timestamp: i64,
pub update_urls: Vec<(String, String)>, 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,
}