pinakes/packages/pinakes-server/src/routes/analytics.rs
NotAShelf 00bab69598
meta: move public crates to packages/
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I928162008cb1ba02e1aa0e7aa971e8326a6a6964
2026-03-23 03:30:53 +03:00

194 lines
5.8 KiB
Rust

use axum::{
Json,
extract::{Extension, Path, Query, State},
};
use pinakes_core::{
analytics::{UsageEvent, UsageEventType},
model::MediaId,
};
use uuid::Uuid;
use crate::{
auth::resolve_user_id,
dto::{
MediaResponse,
MostViewedResponse,
PaginationParams,
RecordUsageEventRequest,
WatchProgressRequest,
WatchProgressResponse,
},
error::ApiError,
state::AppState,
};
const MAX_LIMIT: u64 = 100;
#[utoipa::path(
get,
path = "/api/v1/analytics/most-viewed",
tag = "analytics",
params(
("limit" = Option<u64>, Query, description = "Maximum number of results"),
("offset" = Option<u64>, Query, description = "Pagination offset"),
),
responses(
(status = 200, description = "Most viewed media", body = Vec<MostViewedResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_most_viewed(
State(state): State<AppState>,
Query(params): Query<PaginationParams>,
) -> Result<Json<Vec<MostViewedResponse>>, ApiError> {
let limit = params.limit.unwrap_or(20).min(MAX_LIMIT);
let results = state.storage.get_most_viewed(limit).await?;
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(
results
.into_iter()
.map(|(item, count)| {
MostViewedResponse {
media: MediaResponse::new(item, &roots),
view_count: count,
}
})
.collect(),
))
}
#[utoipa::path(
get,
path = "/api/v1/analytics/recently-viewed",
tag = "analytics",
params(
("limit" = Option<u64>, Query, description = "Maximum number of results"),
("offset" = Option<u64>, Query, description = "Pagination offset"),
),
responses(
(status = 200, description = "Recently viewed media", body = Vec<MediaResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_recently_viewed(
State(state): State<AppState>,
Extension(username): Extension<String>,
Query(params): Query<PaginationParams>,
) -> Result<Json<Vec<MediaResponse>>, ApiError> {
let user_id = resolve_user_id(&state.storage, &username).await?;
let limit = params.limit.unwrap_or(20).min(MAX_LIMIT);
let items = state.storage.get_recently_viewed(user_id, limit).await?;
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(
items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect(),
))
}
#[utoipa::path(
post,
path = "/api/v1/analytics/events",
tag = "analytics",
request_body = RecordUsageEventRequest,
responses(
(status = 200, description = "Event recorded"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn record_event(
State(state): State<AppState>,
Extension(username): Extension<String>,
Json(req): Json<RecordUsageEventRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let event_type: UsageEventType =
req.event_type.parse().map_err(|e: String| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(e))
})?;
let user_id = resolve_user_id(&state.storage, &username).await?;
let event = UsageEvent {
id: Uuid::now_v7(),
media_id: req.media_id.map(MediaId),
user_id: Some(user_id),
event_type,
timestamp: chrono::Utc::now(),
duration_secs: req.duration_secs,
context_json: req.context.map(|v| v.to_string()),
};
state.storage.record_usage_event(&event).await?;
Ok(Json(serde_json::json!({"recorded": true})))
}
#[utoipa::path(
get,
path = "/api/v1/media/{id}/progress",
tag = "analytics",
params(
("id" = Uuid, Path, description = "Media item ID"),
),
responses(
(status = 200, description = "Watch progress", body = WatchProgressResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_watch_progress(
State(state): State<AppState>,
Extension(username): Extension<String>,
Path(id): Path<Uuid>,
) -> Result<Json<WatchProgressResponse>, ApiError> {
let user_id = resolve_user_id(&state.storage, &username).await?;
let progress = state
.storage
.get_watch_progress(user_id, MediaId(id))
.await?
.unwrap_or(0.0);
Ok(Json(WatchProgressResponse {
progress_secs: progress,
}))
}
#[utoipa::path(
put,
path = "/api/v1/media/{id}/progress",
tag = "analytics",
params(
("id" = Uuid, Path, description = "Media item ID"),
),
request_body = WatchProgressRequest,
responses(
(status = 200, description = "Progress updated"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn update_watch_progress(
State(state): State<AppState>,
Extension(username): Extension<String>,
Path(id): Path<Uuid>,
Json(req): Json<WatchProgressRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
if !req.progress_secs.is_finite() || req.progress_secs < 0.0 {
return Err(ApiError::bad_request(
"progress_secs must be a non-negative finite number",
));
}
let user_id = resolve_user_id(&state.storage, &username).await?;
state
.storage
.update_watch_progress(user_id, MediaId(id), req.progress_secs)
.await?;
Ok(Json(serde_json::json!({"updated": true})))
}