feat: implement episode actions add endpoint
parent
064365fb4f
commit
bcfb8805eb
|
@ -12,7 +12,7 @@ post {
|
||||||
|
|
||||||
params:path {
|
params:path {
|
||||||
user: test
|
user: test
|
||||||
device_id: test4.json
|
device_id: test_device.json
|
||||||
}
|
}
|
||||||
|
|
||||||
body:json {
|
body:json {
|
||||||
|
|
|
@ -6,10 +6,30 @@ meta {
|
||||||
|
|
||||||
post {
|
post {
|
||||||
url: http://localhost:8080/api/2/episodes/:username
|
url: http://localhost:8080/api/2/episodes/:username
|
||||||
body: none
|
body: json
|
||||||
auth: none
|
auth: inherit
|
||||||
}
|
}
|
||||||
|
|
||||||
params:path {
|
params:path {
|
||||||
username:
|
username: test.json
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"podcast": "http://example.com/feed.rss",
|
||||||
|
"episode": "http://example.com/files/s01e20.mp3",
|
||||||
|
"device": "test_device",
|
||||||
|
"action": "download",
|
||||||
|
"timestamp": "2009-12-12T09:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"podcast": "http://example.org/podcast.php",
|
||||||
|
"episode": "http://ftp.example.org/foo.ogg",
|
||||||
|
"action": "play",
|
||||||
|
"started": 15,
|
||||||
|
"position": 120,
|
||||||
|
"total": 500
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
drop table episode_actions;
|
drop table episode_actions;
|
||||||
|
|
||||||
drop table subscriptions;
|
drop table device_subscriptions;
|
||||||
|
|
||||||
drop table devices;
|
drop table devices;
|
||||||
|
|
||||||
|
|
|
@ -38,7 +38,7 @@ create table device_subscriptions (
|
||||||
|
|
||||||
podcast_url text not null,
|
podcast_url text not null,
|
||||||
|
|
||||||
time_changed bigint not null default 0,
|
time_changed bigint not null,
|
||||||
deleted boolean not null default false,
|
deleted boolean not null default false,
|
||||||
|
|
||||||
unique (device_id, podcast_url)
|
unique (device_id, podcast_url)
|
||||||
|
@ -55,6 +55,8 @@ create table episode_actions (
|
||||||
podcast_url text not null,
|
podcast_url text not null,
|
||||||
episode_url text not null,
|
episode_url text not null,
|
||||||
|
|
||||||
|
time_changed bigint not null,
|
||||||
|
|
||||||
timestamp bigint,
|
timestamp bigint,
|
||||||
action text not null,
|
action text not null,
|
||||||
started integer,
|
started integer,
|
||||||
|
@ -62,10 +64,10 @@ create table episode_actions (
|
||||||
total integer,
|
total integer,
|
||||||
|
|
||||||
-- Position should be set if the action type is "Play" and null otherwise
|
-- Position should be set if the action type is "Play" and null otherwise
|
||||||
check ((action = "Play") = (position is not null)),
|
check ((action = "play") = (position is not null)),
|
||||||
|
|
||||||
-- Started and position can only be set if the action type is "Play"
|
-- Started and position can only be set if the action type is "Play"
|
||||||
check (action = "Play" or (started is null and position is null)),
|
check (action = "play" or (started is null and position is null)),
|
||||||
|
|
||||||
-- Started and position should be provided together
|
-- Started and position should be provided together
|
||||||
check ((started is null) = (total is null))
|
check ((started is null) = (total is null))
|
||||||
|
|
|
@ -52,16 +52,34 @@ impl Device {
|
||||||
.get_results(&mut pool.get()?)?)
|
.get_results(&mut pool.get()?)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn by_device_id(pool: &DbPool, user_id: i64, device_id: &str) -> DbResult<Option<Self>> {
|
pub fn device_id_to_id(
|
||||||
Ok(devices::dsl::devices
|
conn: &mut SqliteConnection,
|
||||||
|
user_id: i64,
|
||||||
|
device_id: &str,
|
||||||
|
) -> diesel::QueryResult<i64> {
|
||||||
|
devices::table
|
||||||
|
.select(devices::id)
|
||||||
|
.filter(
|
||||||
|
devices::user_id
|
||||||
|
.eq(user_id)
|
||||||
|
.and(devices::device_id.eq(device_id)),
|
||||||
|
)
|
||||||
|
.get_result(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn by_device_id(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
user_id: i64,
|
||||||
|
device_id: &str,
|
||||||
|
) -> diesel::QueryResult<Self> {
|
||||||
|
devices::dsl::devices
|
||||||
.select(Self::as_select())
|
.select(Self::as_select())
|
||||||
.filter(
|
.filter(
|
||||||
devices::user_id
|
devices::user_id
|
||||||
.eq(user_id)
|
.eq(user_id)
|
||||||
.and(devices::device_id.eq(device_id)),
|
.and(devices::device_id.eq(device_id)),
|
||||||
)
|
)
|
||||||
.get_result(&mut pool.get()?)
|
.get_result(conn)
|
||||||
.optional()?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(&self, pool: &DbPool) -> DbResult<()> {
|
pub fn update(&self, pool: &DbPool) -> DbResult<()> {
|
||||||
|
|
|
@ -22,6 +22,7 @@ pub struct EpisodeAction {
|
||||||
device_id: Option<i64>,
|
device_id: Option<i64>,
|
||||||
podcast_url: String,
|
podcast_url: String,
|
||||||
episode_url: String,
|
episode_url: String,
|
||||||
|
time_changed: i64,
|
||||||
timestamp: Option<i64>,
|
timestamp: Option<i64>,
|
||||||
action: ActionType,
|
action: ActionType,
|
||||||
started: Option<i32>,
|
started: Option<i32>,
|
||||||
|
@ -33,14 +34,15 @@ pub struct EpisodeAction {
|
||||||
#[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 {
|
||||||
device_id: Option<i64>,
|
pub device_id: Option<i64>,
|
||||||
podcast_url: String,
|
pub podcast_url: String,
|
||||||
episode_url: String,
|
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(Serialize, Deserialize, FromSqlRow, Debug, AsExpression, Clone)]
|
#[derive(Serialize, Deserialize, FromSqlRow, Debug, AsExpression, Clone)]
|
||||||
|
|
|
@ -6,18 +6,76 @@ use crate::{
|
||||||
gpodder,
|
gpodder,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
impl From<gpodder::EpisodeAction> for db::NewEpisodeAction {
|
||||||
|
fn from(value: gpodder::EpisodeAction) -> Self {
|
||||||
|
let (action, started, position, total) = match value.action {
|
||||||
|
gpodder::EpisodeActionType::New => (db::ActionType::New, None, None, None),
|
||||||
|
gpodder::EpisodeActionType::Delete => (db::ActionType::Delete, None, None, None),
|
||||||
|
gpodder::EpisodeActionType::Download => (db::ActionType::Download, None, None, None),
|
||||||
|
gpodder::EpisodeActionType::Play {
|
||||||
|
started,
|
||||||
|
position,
|
||||||
|
total,
|
||||||
|
} => (db::ActionType::Play, started, Some(position), total),
|
||||||
|
};
|
||||||
|
|
||||||
|
db::NewEpisodeAction {
|
||||||
|
device_id: None,
|
||||||
|
podcast_url: value.podcast,
|
||||||
|
episode_url: value.episode,
|
||||||
|
time_changed: 0,
|
||||||
|
timestamp: value.timestamp.map(|t| t.and_utc().timestamp()),
|
||||||
|
action,
|
||||||
|
started,
|
||||||
|
position,
|
||||||
|
total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl gpodder::EpisodeActionRepository for SqliteRepository {
|
impl gpodder::EpisodeActionRepository for SqliteRepository {
|
||||||
fn add_episode_actions(
|
fn add_episode_actions(
|
||||||
&self,
|
&self,
|
||||||
user: &gpodder::User,
|
user: &gpodder::User,
|
||||||
actions: Vec<gpodder::EpisodeAction>,
|
actions: Vec<gpodder::EpisodeAction>,
|
||||||
) -> Result<i64, gpodder::AuthErr> {
|
) -> Result<i64, gpodder::AuthErr> {
|
||||||
|
let time_changed = chrono::Utc::now().timestamp_millis();
|
||||||
|
|
||||||
|
// TODO optimize this query
|
||||||
|
// 1. The lookup for a device could be replaced with a subquery, although Diesel seems to
|
||||||
|
// have a problem using an Option<String> to match equality with a String
|
||||||
|
// 2. Ideally the for loop would be replaced with a single query inserting multiple values,
|
||||||
|
// although each value would need its own subquery
|
||||||
self.pool.get()?.transaction(|conn| {
|
self.pool.get()?.transaction(|conn| {
|
||||||
for action in actions {}
|
for action in actions {
|
||||||
|
let device_id = if let Some(device) = &action.device {
|
||||||
|
Some(db::Device::device_id_to_id(conn, user.id, device)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut new_action: db::NewEpisodeAction = action.into();
|
||||||
|
new_action.device_id = device_id;
|
||||||
|
new_action.time_changed = time_changed;
|
||||||
|
|
||||||
|
diesel::insert_into(episode_actions::table)
|
||||||
|
.values(&new_action)
|
||||||
|
.execute(conn)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok::<_, diesel::result::Error>(())
|
Ok::<_, diesel::result::Error>(())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
Ok(time_changed + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn episode_actions_for_user(
|
||||||
|
&self,
|
||||||
|
user: &gpodder::User,
|
||||||
|
podcast: Option<String>,
|
||||||
|
device: Option<String>,
|
||||||
|
aggregated: bool,
|
||||||
|
) -> (i64, Vec<gpodder::EpisodeAction>) {
|
||||||
todo!()
|
todo!()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,6 +27,7 @@ diesel::table! {
|
||||||
device_id -> Nullable<BigInt>,
|
device_id -> Nullable<BigInt>,
|
||||||
podcast_url -> Text,
|
podcast_url -> Text,
|
||||||
episode_url -> Text,
|
episode_url -> Text,
|
||||||
|
time_changed -> BigInt,
|
||||||
timestamp -> Nullable<BigInt>,
|
timestamp -> Nullable<BigInt>,
|
||||||
action -> Text,
|
action -> Text,
|
||||||
started -> Nullable<Integer>,
|
started -> Nullable<Integer>,
|
||||||
|
|
|
@ -78,4 +78,13 @@ pub trait EpisodeActionRepository {
|
||||||
/// Insert the given episode actions into the datastore.
|
/// Insert the given episode actions into the datastore.
|
||||||
fn add_episode_actions(&self, user: &User, actions: Vec<EpisodeAction>)
|
fn add_episode_actions(&self, user: &User, actions: Vec<EpisodeAction>)
|
||||||
-> Result<i64, AuthErr>;
|
-> Result<i64, AuthErr>;
|
||||||
|
|
||||||
|
/// Retrieve the list of episode actions for the given user.
|
||||||
|
fn episode_actions_for_user(
|
||||||
|
&self,
|
||||||
|
user: &User,
|
||||||
|
podcast: Option<String>,
|
||||||
|
device: Option<String>,
|
||||||
|
aggregated: bool,
|
||||||
|
) -> (i64, Vec<EpisodeAction>);
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,11 +49,11 @@ pub enum EpisodeActionType {
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub struct EpisodeAction {
|
pub struct EpisodeAction {
|
||||||
podcast: String,
|
pub podcast: String,
|
||||||
episode: String,
|
pub episode: String,
|
||||||
timestamp: Option<NaiveDateTime>,
|
pub timestamp: Option<NaiveDateTime>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
device: Option<String>,
|
pub device: Option<String>,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
action: EpisodeActionType,
|
pub action: EpisodeActionType,
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,13 +6,13 @@ use axum::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
db,
|
gpodder::{self, EpisodeActionRepository},
|
||||||
server::{
|
server::{
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
gpodder::{
|
gpodder::{
|
||||||
auth_middleware,
|
auth_middleware,
|
||||||
format::{Format, StringWithFormat},
|
format::{Format, StringWithFormat},
|
||||||
models::{EpisodeAction, UpdatedUrlsResponse},
|
models::UpdatedUrlsResponse,
|
||||||
},
|
},
|
||||||
Context,
|
Context,
|
||||||
},
|
},
|
||||||
|
@ -27,8 +27,8 @@ pub fn router(ctx: Context) -> Router<Context> {
|
||||||
async fn post_episode_actions(
|
async fn post_episode_actions(
|
||||||
State(ctx): State<Context>,
|
State(ctx): State<Context>,
|
||||||
Path(username): Path<StringWithFormat>,
|
Path(username): Path<StringWithFormat>,
|
||||||
Extension(user): Extension<db::User>,
|
Extension(user): Extension<gpodder::User>,
|
||||||
Json(actions): Json<Vec<EpisodeAction>>,
|
Json(actions): Json<Vec<gpodder::EpisodeAction>>,
|
||||||
) -> AppResult<Json<UpdatedUrlsResponse>> {
|
) -> AppResult<Json<UpdatedUrlsResponse>> {
|
||||||
if username.format != Format::Json {
|
if username.format != Format::Json {
|
||||||
return Err(AppError::NotFound);
|
return Err(AppError::NotFound);
|
||||||
|
@ -38,7 +38,15 @@ async fn post_episode_actions(
|
||||||
return Err(AppError::BadRequest);
|
return Err(AppError::BadRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::debug!("{:?}", actions);
|
Ok(
|
||||||
|
tokio::task::spawn_blocking(move || ctx.repo.add_episode_actions(&user, actions))
|
||||||
todo!()
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.map(|timestamp| {
|
||||||
|
Json(UpdatedUrlsResponse {
|
||||||
|
timestamp,
|
||||||
|
update_urls: Vec::new(),
|
||||||
|
})
|
||||||
|
})?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,9 +13,7 @@ use crate::{
|
||||||
gpodder::{
|
gpodder::{
|
||||||
auth_middleware,
|
auth_middleware,
|
||||||
format::{Format, StringWithFormat},
|
format::{Format, StringWithFormat},
|
||||||
models::{
|
models::{SubscriptionDelta, SubscriptionDeltaResponse, UpdatedUrlsResponse},
|
||||||
DeviceType, SubscriptionDelta, SubscriptionDeltaResponse, UpdatedUrlsResponse,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Context,
|
Context,
|
||||||
},
|
},
|
||||||
|
|
|
@ -69,30 +69,3 @@ 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,
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in New Issue