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"
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",
]

View File

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

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

View File

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

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 episode_action;
pub mod session;
pub mod subscription;
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 device;
mod episode_action;
mod subscription;
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! {
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,

View File

@ -9,7 +9,7 @@ pub enum AuthErr {
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.
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>;
}
pub trait DeviceRepository: Send + Sync {
pub trait DeviceRepository {
/// Return all devices associated with the user
fn devices_for_user(&self, user: &User) -> Result<Vec<Device>, 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<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};
#[derive(Clone)]
@ -29,3 +30,30 @@ pub struct DevicePatch {
pub caption: Option<String>,
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 devices;
mod episodes;
mod subscriptions;
use axum::Router;
@ -11,4 +12,5 @@ pub fn router(ctx: Context) -> Router<Context> {
.nest("/auth", auth::router())
.nest("/devices", devices::router(ctx.clone()))
.nest("/subscriptions", subscriptions::router(ctx.clone()))
.nest("/episodes", episodes::router(ctx.clone()))
}

View File

@ -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<gpodder::User>,
Json(delta): Json<SubscriptionDelta>,
) -> AppResult<Json<SubscriptionChangeResponse>> {
) -> AppResult<Json<UpdatedUrlsResponse>> {
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(),
})

View File

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