diff --git a/.sqlx/query-10240b18dcfdd6baf6fc5ecf3f1f11148915c58c6facecfd83aa37f372569fa6.json b/.sqlx/query-10240b18dcfdd6baf6fc5ecf3f1f11148915c58c6facecfd83aa37f372569fa6.json new file mode 100644 index 00000000..46f866d4 --- /dev/null +++ b/.sqlx/query-10240b18dcfdd6baf6fc5ecf3f1f11148915c58c6facecfd83aa37f372569fa6.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM room_playback_history\n WHERE room_id = $1 AND id = $2\n RETURNING id AS \"id!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Int8", + "origin": { + "Table": { + "table": "room_playback_history", + "name": "id" + } + } + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "10240b18dcfdd6baf6fc5ecf3f1f11148915c58c6facecfd83aa37f372569fa6" +} diff --git a/.sqlx/query-2e33108da4743a3eece50f4600c750dd96cff512b3f5c3057b590c1215581c3b.json b/.sqlx/query-2e33108da4743a3eece50f4600c750dd96cff512b3f5c3057b590c1215581c3b.json new file mode 100644 index 00000000..95a20228 --- /dev/null +++ b/.sqlx/query-2e33108da4743a3eece50f4600c750dd96cff512b3f5c3057b590c1215581c3b.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM room_playback_history WHERE room_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "2e33108da4743a3eece50f4600c750dd96cff512b3f5c3057b590c1215581c3b" +} diff --git a/.sqlx/query-ccc1f5981c5d87e00838ca57d044198e8d71a76769648d4a502065a09e83cc49.json b/.sqlx/query-da2ed8e26ca6d01fd11f920ddcdce11e943be3a58287ee3748c9537f8bad2022.json similarity index 96% rename from .sqlx/query-ccc1f5981c5d87e00838ca57d044198e8d71a76769648d4a502065a09e83cc49.json rename to .sqlx/query-da2ed8e26ca6d01fd11f920ddcdce11e943be3a58287ee3748c9537f8bad2022.json index 8e7cf589..37fc8368 100644 --- a/.sqlx/query-ccc1f5981c5d87e00838ca57d044198e8d71a76769648d4a502065a09e83cc49.json +++ b/.sqlx/query-da2ed8e26ca6d01fd11f920ddcdce11e943be3a58287ee3748c9537f8bad2022.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT history.id AS \"id!\", history.room_id AS \"room_id!: RoomId\", history.sequence AS \"sequence!\",\n history.media_id AS \"media_id?: MediaId\", history.playlist_id AS \"playlist_id?: PlaylistId\",\n history.target AS \"target?: crate::models::ProviderTarget\",\n history.position_seconds, history.selected_by_user_id AS \"selected_by_user_id?: UserId\",\n history.media_name,\n history.playlist_name,\n COALESCE(media.source_provider, playlist.source_provider) AS \"source_provider?: crate::models::SourceProvider\",\n COALESCE(media.provider_instance_name, playlist.provider_instance_name)\n AS \"provider_instance_name?\",\n history.created_at, history.updated_at\n FROM room_playback_history history\n LEFT JOIN media\n ON media.id = history.media_id AND media.room_id = history.room_id\n LEFT JOIN playlists playlist\n ON playlist.id = history.playlist_id AND playlist.room_id = history.room_id\n WHERE history.room_id = $1\n AND ($2::bigint IS NULL OR history.id < $2)\n ORDER BY history.sequence DESC\n LIMIT $3", + "query": "SELECT history.id AS \"id!\", history.room_id AS \"room_id!: RoomId\", history.sequence AS \"sequence!\",\n history.media_id AS \"media_id?: MediaId\", history.playlist_id AS \"playlist_id?: PlaylistId\",\n history.target AS \"target?: crate::models::ProviderTarget\",\n history.position_seconds, history.selected_by_user_id AS \"selected_by_user_id?: UserId\",\n history.media_name,\n history.playlist_name,\n COALESCE(media.source_provider, playlist.source_provider) AS \"source_provider?: crate::models::SourceProvider\",\n COALESCE(media.provider_instance_name, playlist.provider_instance_name)\n AS \"provider_instance_name?\",\n history.created_at, history.updated_at\n FROM room_playback_history history\n LEFT JOIN media\n ON media.id = history.media_id AND media.room_id = history.room_id\n LEFT JOIN playlists playlist\n ON playlist.id = history.playlist_id AND playlist.room_id = history.room_id\n WHERE history.room_id = $1\n AND ($2::bigint IS NULL OR history.id > $2)\n ORDER BY history.sequence ASC, history.id ASC\n LIMIT $3", "describe": { "columns": [ { @@ -172,5 +172,5 @@ false ] }, - "hash": "ccc1f5981c5d87e00838ca57d044198e8d71a76769648d4a502065a09e83cc49" + "hash": "da2ed8e26ca6d01fd11f920ddcdce11e943be3a58287ee3748c9537f8bad2022" } diff --git a/.sqlx/query-ddd338c0963cc88111adcb605d8d83d539bfc5a7f3cda449e57437251ad3500e.json b/.sqlx/query-ddd338c0963cc88111adcb605d8d83d539bfc5a7f3cda449e57437251ad3500e.json new file mode 100644 index 00000000..dc6378ee --- /dev/null +++ b/.sqlx/query-ddd338c0963cc88111adcb605d8d83d539bfc5a7f3cda449e57437251ad3500e.json @@ -0,0 +1,155 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT state.room_id AS \"room_id!: RoomId\",\n state.playing_media_id AS \"playing_media_id?: MediaId\",\n state.playing_playlist_id AS \"playing_playlist_id?: PlaylistId\",\n state.target AS \"target?: crate::models::ProviderTarget\",\n state.current_progress_id,\n state.history_cursor_id,\n COALESCE(progress.\"position\", 0.0) AS \"position!\",\n state.speed AS \"speed!\",\n state.is_playing AS \"is_playing!\",\n state.playback_generation AS \"playback_generation!\",\n state.updated_at AS \"updated_at!\",\n state.version AS \"version!\"\n FROM room_playback_state state\n LEFT JOIN room_playback_progress progress\n ON progress.id = state.current_progress_id\n WHERE state.room_id = $1\n FOR UPDATE OF state", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "room_id!: RoomId", + "type_info": "Int8", + "origin": { + "Table": { + "table": "room_playback_state", + "name": "room_id" + } + } + }, + { + "ordinal": 1, + "name": "playing_media_id?: MediaId", + "type_info": "Int8", + "origin": { + "Table": { + "table": "room_playback_state", + "name": "playing_media_id" + } + } + }, + { + "ordinal": 2, + "name": "playing_playlist_id?: PlaylistId", + "type_info": "Int8", + "origin": { + "Table": { + "table": "room_playback_state", + "name": "playing_playlist_id" + } + } + }, + { + "ordinal": 3, + "name": "target?: crate::models::ProviderTarget", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "room_playback_state", + "name": "target" + } + } + }, + { + "ordinal": 4, + "name": "current_progress_id", + "type_info": "Int8", + "origin": { + "Table": { + "table": "room_playback_state", + "name": "current_progress_id" + } + } + }, + { + "ordinal": 5, + "name": "history_cursor_id", + "type_info": "Int8", + "origin": { + "Table": { + "table": "room_playback_state", + "name": "history_cursor_id" + } + } + }, + { + "ordinal": 6, + "name": "position!", + "type_info": "Float8", + "origin": "Expression" + }, + { + "ordinal": 7, + "name": "speed!", + "type_info": "Float8", + "origin": { + "Table": { + "table": "room_playback_state", + "name": "speed" + } + } + }, + { + "ordinal": 8, + "name": "is_playing!", + "type_info": "Bool", + "origin": { + "Table": { + "table": "room_playback_state", + "name": "is_playing" + } + } + }, + { + "ordinal": 9, + "name": "playback_generation!", + "type_info": "Int8", + "origin": { + "Table": { + "table": "room_playback_state", + "name": "playback_generation" + } + } + }, + { + "ordinal": 10, + "name": "updated_at!", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "room_playback_state", + "name": "updated_at" + } + } + }, + { + "ordinal": 11, + "name": "version!", + "type_info": "Int8", + "origin": { + "Table": { + "table": "room_playback_state", + "name": "version" + } + } + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + true, + true, + true, + true, + true, + null, + false, + false, + false, + false, + false + ] + }, + "hash": "ddd338c0963cc88111adcb605d8d83d539bfc5a7f3cda449e57437251ad3500e" +} diff --git a/.sqlx/query-f58607d0fa58276a07b675c779e94ae84b06bb6c586dff2084c299c0c233446a.json b/.sqlx/query-f58607d0fa58276a07b675c779e94ae84b06bb6c586dff2084c299c0c233446a.json new file mode 100644 index 00000000..40f19a46 --- /dev/null +++ b/.sqlx/query-f58607d0fa58276a07b675c779e94ae84b06bb6c586dff2084c299c0c233446a.json @@ -0,0 +1,176 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT history.id AS \"id!\", history.room_id AS \"room_id!: RoomId\", history.sequence AS \"sequence!\",\n history.media_id AS \"media_id?: MediaId\", history.playlist_id AS \"playlist_id?: PlaylistId\",\n history.target AS \"target?: crate::models::ProviderTarget\",\n history.position_seconds, history.selected_by_user_id AS \"selected_by_user_id?: UserId\",\n history.media_name,\n history.playlist_name,\n COALESCE(media.source_provider, playlist.source_provider) AS \"source_provider?: crate::models::SourceProvider\",\n COALESCE(media.provider_instance_name, playlist.provider_instance_name)\n AS \"provider_instance_name?\",\n history.created_at, history.updated_at\n FROM room_playback_history history\n LEFT JOIN media\n ON media.id = history.media_id AND media.room_id = history.room_id\n LEFT JOIN playlists playlist\n ON playlist.id = history.playlist_id AND playlist.room_id = history.room_id\n WHERE history.room_id = $1\n AND ($2::bigint IS NULL OR history.id < $2)\n ORDER BY history.sequence DESC, history.id DESC\n LIMIT $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Int8", + "origin": { + "Table": { + "table": "room_playback_history", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "room_id!: RoomId", + "type_info": "Int8", + "origin": { + "Table": { + "table": "room_playback_history", + "name": "room_id" + } + } + }, + { + "ordinal": 2, + "name": "sequence!", + "type_info": "Int8", + "origin": { + "Table": { + "table": "room_playback_history", + "name": "sequence" + } + } + }, + { + "ordinal": 3, + "name": "media_id?: MediaId", + "type_info": "Int8", + "origin": { + "Table": { + "table": "room_playback_history", + "name": "media_id" + } + } + }, + { + "ordinal": 4, + "name": "playlist_id?: PlaylistId", + "type_info": "Int8", + "origin": { + "Table": { + "table": "room_playback_history", + "name": "playlist_id" + } + } + }, + { + "ordinal": 5, + "name": "target?: crate::models::ProviderTarget", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "room_playback_history", + "name": "target" + } + } + }, + { + "ordinal": 6, + "name": "position_seconds", + "type_info": "Float8", + "origin": { + "Table": { + "table": "room_playback_history", + "name": "position_seconds" + } + } + }, + { + "ordinal": 7, + "name": "selected_by_user_id?: UserId", + "type_info": "Int8", + "origin": { + "Table": { + "table": "room_playback_history", + "name": "selected_by_user_id" + } + } + }, + { + "ordinal": 8, + "name": "media_name", + "type_info": "Text", + "origin": { + "Table": { + "table": "room_playback_history", + "name": "media_name" + } + } + }, + { + "ordinal": 9, + "name": "playlist_name", + "type_info": "Text", + "origin": { + "Table": { + "table": "room_playback_history", + "name": "playlist_name" + } + } + }, + { + "ordinal": 10, + "name": "source_provider?: crate::models::SourceProvider", + "type_info": "Int2", + "origin": "Expression" + }, + { + "ordinal": 11, + "name": "provider_instance_name?", + "type_info": "Varchar", + "origin": "Expression" + }, + { + "ordinal": 12, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "room_playback_history", + "name": "created_at" + } + } + }, + { + "ordinal": 13, + "name": "updated_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "room_playback_history", + "name": "updated_at" + } + } + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + true, + true, + true, + null, + null, + false, + false + ] + }, + "hash": "f58607d0fa58276a07b675c779e94ae84b06bb6c586dff2084c299c0c233446a" +} diff --git a/synctv-api-common/src/impls/client/convert.rs b/synctv-api-common/src/impls/client/convert.rs index 82e5ace9..81ea2080 100644 --- a/synctv-api-common/src/impls/client/convert.rs +++ b/synctv-api-common/src/impls/client/convert.rs @@ -3034,7 +3034,13 @@ pub fn playback_history_page_to_proto( .map_err(|error| proto_encode_error("playback history entry", &error))? .unwrap_or_default(), next_before_entry_id: page - .next_before_entry_id + .next_cursor_entry_id + .map(|id| public_id_codec.encode_playback_history_entry_id(id)) + .transpose() + .map_err(|error| proto_encode_error("playback history entry", &error))? + .unwrap_or_default(), + next_cursor_entry_id: page + .next_cursor_entry_id .map(|id| public_id_codec.encode_playback_history_entry_id(id)) .transpose() .map_err(|error| proto_encode_error("playback history entry", &error))? diff --git a/synctv-api-common/src/impls/client/playback.rs b/synctv-api-common/src/impls/client/playback.rs index 84004143..c13f5c9e 100644 --- a/synctv-api-common/src/impls/client/playback.rs +++ b/synctv-api-common/src/impls/client/playback.rs @@ -993,21 +993,30 @@ impl ClientApiImpl { ) .await?; let rid = actor.room_id(); - let before_entry_id = req - .before_entry_id + let cursor_entry_id = req + .cursor_entry_id .as_deref() + .or(req.before_entry_id.as_deref()) .map(|id| self.public_id_codec.decode_playback_history_entry_id(id)) .transpose() .map_err(|_| { - ApiError::InvalidInput("Invalid playback history before_entry_id".into()) + ApiError::InvalidInput("Invalid playback history cursor_entry_id".into()) })?; + let sort_direction = match synctv_proto::client::SortDirection::try_from(req.sort_direction) + .unwrap_or_default() + { + synctv_proto::client::SortDirection::Asc => synctv_core::models::SortDirection::Asc, + synctv_proto::client::SortDirection::Unspecified + | synctv_proto::client::SortDirection::Desc => synctv_core::models::SortDirection::Desc, + }; let page = self .room_service .playback_service() .list_playback_history( &rid, - before_entry_id, + cursor_entry_id, if req.limit == 0 { 50 } else { req.limit }, + sort_direction, ) .await .map_err(ApiError::from)?; @@ -1049,6 +1058,59 @@ impl ClientApiImpl { self.playback_state_with_operation(&state, client_operation_id.as_deref()) } + pub async fn delete_playback_history_entry( + &self, + user_id: &UserId, + room_id: &str, + req: synctv_proto::client::DeletePlaybackHistoryEntryRequest, + ) -> Result { + crate::impls::validate_proto_request(&req)?; + let rid = self.parse_room_id(room_id)?; + let entry_id = self + .public_id_codec + .decode_playback_history_entry_id(&req.entry_id) + .map_err(|_| ApiError::InvalidInput("Invalid playback history entry_id".into()))?; + let prepared_fanout = self.prepare_playback_state_changed(*user_id, None).await?; + let deleted = self + .room_service + .playback_service() + .delete_playback_history_entry_for_user( + &rid, + *user_id, + entry_id, + Some(prepared_fanout.outbox_factory()), + ) + .await + .map_err(ApiError::from)?; + prepared_fanout.publish_after_outbox_commit(); + if deleted { + self.room_service.touch_room_activity(rid).await; + } + Ok(synctv_proto::client::DeletePlaybackHistoryEntryResponse { deleted }) + } + + pub async fn clear_playback_history( + &self, + user_id: &UserId, + room_id: &str, + req: synctv_proto::client::ClearPlaybackHistoryRequest, + ) -> Result { + crate::impls::validate_proto_request(&req)?; + let rid = self.parse_room_id(room_id)?; + let prepared_fanout = self.prepare_playback_state_changed(*user_id, None).await?; + let deleted_count = self + .room_service + .playback_service() + .clear_playback_history_for_user(&rid, *user_id, Some(prepared_fanout.outbox_factory())) + .await + .map_err(ApiError::from)?; + prepared_fanout.publish_after_outbox_commit(); + if deleted_count != 0 { + self.room_service.touch_room_activity(rid).await; + } + Ok(synctv_proto::client::ClearPlaybackHistoryResponse { deleted_count }) + } + /// Get current playback state and complete playback information /// HTTP API: GET /`api/rooms/{room_id}/playback` pub async fn get_playback( diff --git a/synctv-api-common/src/impls/messaging/resource_observer.rs b/synctv-api-common/src/impls/messaging/resource_observer.rs index 83e532d2..ced72f12 100644 --- a/synctv-api-common/src/impls/messaging/resource_observer.rs +++ b/synctv-api-common/src/impls/messaging/resource_observer.rs @@ -3414,23 +3414,37 @@ impl ResourceObserver { (snapshot.version.clone(), None, payload) } ObservedResource::PlaybackHistory { request } => { - let before_entry_id = request - .before_entry_id + let cursor_entry_id = request + .cursor_entry_id .as_deref() + .or(request.before_entry_id.as_deref()) .map(|id| self.public_id_codec.decode_playback_history_entry_id(id)) .transpose() - .map_err(|_| "Invalid playback history before_entry_id".to_string())?; + .map_err(|_| "Invalid playback history cursor_entry_id".to_string())?; + let sort_direction = + match synctv_proto::client::SortDirection::try_from(request.sort_direction) + .unwrap_or_default() + { + synctv_proto::client::SortDirection::Asc => { + synctv_core::models::SortDirection::Asc + } + synctv_proto::client::SortDirection::Unspecified + | synctv_proto::client::SortDirection::Desc => { + synctv_core::models::SortDirection::Desc + } + }; let page = self .room_service .playback_service() .list_playback_history( &self.room_id, - before_entry_id, + cursor_entry_id, if request.limit == 0 { 50 } else { request.limit }, + sort_direction, ) .await .map_err(|error| error.to_string())?; diff --git a/synctv-api-grpc/src/grpc/client_service/room.rs b/synctv-api-grpc/src/grpc/client_service/room.rs index fedfe76a..2dce5646 100644 --- a/synctv-api-grpc/src/grpc/client_service/room.rs +++ b/synctv-api-grpc/src/grpc/client_service/room.rs @@ -712,6 +712,20 @@ impl RoomService for ClientServiceImpl { playback::play_history_entry(self, request).await } + async fn delete_playback_history_entry( + &self, + request: Request, + ) -> Result, Status> { + playback::delete_playback_history_entry(self, request).await + } + + async fn clear_playback_history( + &self, + request: Request, + ) -> Result, Status> { + playback::clear_playback_history(self, request).await + } + async fn get_playback( &self, request: Request, diff --git a/synctv-api-grpc/src/grpc/client_service/room/playback.rs b/synctv-api-grpc/src/grpc/client_service/room/playback.rs index ec0204fc..6a42b446 100644 --- a/synctv-api-grpc/src/grpc/client_service/room/playback.rs +++ b/synctv-api-grpc/src/grpc/client_service/room/playback.rs @@ -146,6 +146,54 @@ pub(super) async fn play_history_entry( Ok(Response::new(response)) } +pub(super) async fn delete_playback_history_entry( + service: &ClientServiceImpl, + request: Request, +) -> Result, Status> { + let (metadata, room_id) = service.room_request_context(&request)?; + let req = request.into_inner(); + let client_api = service.client_api.clone(); + let response = service + .client_api + .clone() + .execute_user_endpoint( + &metadata, + EndpointRateLimitCategory::Media, + move |authenticated| async move { + client_api + .delete_playback_history_entry(&authenticated.user_id(), &room_id, req) + .await + }, + ) + .await + .map_err(map_api_error)?; + Ok(Response::new(response)) +} + +pub(super) async fn clear_playback_history( + service: &ClientServiceImpl, + request: Request, +) -> Result, Status> { + let (metadata, room_id) = service.room_request_context(&request)?; + let req = request.into_inner(); + let client_api = service.client_api.clone(); + let response = service + .client_api + .clone() + .execute_user_endpoint( + &metadata, + EndpointRateLimitCategory::Media, + move |authenticated| async move { + client_api + .clear_playback_history(&authenticated.user_id(), &room_id, req) + .await + }, + ) + .await + .map_err(map_api_error)?; + Ok(Response::new(response)) +} + pub(super) async fn get_playback( service: &ClientServiceImpl, request: Request, diff --git a/synctv-api-http/src/http/mod.rs b/synctv-api-http/src/http/mod.rs index 5dea48ea..a8c61125 100644 --- a/synctv-api-http/src/http/mod.rs +++ b/synctv-api-http/src/http/mod.rs @@ -538,6 +538,10 @@ fn register_write_routes() -> Router { "/api/rooms/{roomId}/playback/history/{entryId}/play", post(room::play_history_entry), ) + .route( + "/api/rooms/{roomId}/playback/history/{entryId}", + axum::routing::delete(room::delete_playback_history_entry), + ) .route( "/api/rooms/{roomId}/playback", axum::routing::patch(room::update_playback_state), @@ -670,7 +674,7 @@ fn register_read_routes() -> Router { .route("/api/rooms/{roomId}/playback", get(room::get_playback)) .route( "/api/rooms/{roomId}/playback/history", - get(room::list_playback_history), + get(room::list_playback_history).delete(room::clear_playback_history), ) .route( "/api/rooms/{roomId}/watch/playback-state", diff --git a/synctv-api-http/src/http/room.rs b/synctv-api-http/src/http/room.rs index 8adffed3..13aec06c 100644 --- a/synctv-api-http/src/http/room.rs +++ b/synctv-api-http/src/http/room.rs @@ -71,13 +71,15 @@ pub(crate) use objects::{ }; #[cfg(feature = "openapi")] pub(crate) use playback::{ - __path_get_playback, __path_list_playback_history, __path_play_history_entry, __path_play_next, + __path_clear_playback_history, __path_delete_playback_history_entry, __path_get_playback, + __path_list_playback_history, __path_play_history_entry, __path_play_next, __path_play_previous, __path_start_playback, __path_stop_playback, __path_update_playback_state, }; pub(crate) use playback::{ - get_playback, list_playback_history, play_history_entry, play_next, play_previous, - start_playback, stop_playback, update_playback_state, watch_playback, watch_playback_state, + clear_playback_history, delete_playback_history_entry, get_playback, list_playback_history, + play_history_entry, play_next, play_previous, start_playback, stop_playback, + update_playback_state, watch_playback, watch_playback_state, }; #[cfg(feature = "openapi")] pub(crate) use playlists::{ diff --git a/synctv-api-http/src/http/room/playback.rs b/synctv-api-http/src/http/room/playback.rs index 96a3fa2c..f07807bf 100644 --- a/synctv-api-http/src/http/room/playback.rs +++ b/synctv-api-http/src/http/room/playback.rs @@ -16,10 +16,11 @@ use crate::http::websocket::RealtimeTransportFormat; use crate::http::{middleware::RequestMetadata, AppResult, AppState}; use synctv_api_common::impls::{EndpointRateLimitCategory, EndpointRateLimitScope}; use synctv_proto::client::{ - GetPlaybackResponse, ListPlaybackHistoryRequest, ListPlaybackHistoryResponse, - PlayHistoryEntryRequest, PlayNextRequest, PlayPreviousRequest, PlaybackState, - StartPlaybackRequest, StopPlaybackRequest, UpdatePlaybackStateRequest, WatchPlaybackRequest, - WatchPlaybackStateRequest, + ClearPlaybackHistoryRequest, ClearPlaybackHistoryResponse, DeletePlaybackHistoryEntryRequest, + DeletePlaybackHistoryEntryResponse, GetPlaybackResponse, ListPlaybackHistoryRequest, + ListPlaybackHistoryResponse, PlayHistoryEntryRequest, PlayNextRequest, PlayPreviousRequest, + PlaybackState, StartPlaybackRequest, StopPlaybackRequest, UpdatePlaybackStateRequest, + WatchPlaybackRequest, WatchPlaybackStateRequest, }; #[cfg_attr( @@ -184,8 +185,10 @@ pub async fn play_previous( tag = "Room", params( ("roomId" = String, Path, description = "Room ID"), - ("beforeEntryId" = Option, Query, description = "History pagination cursor"), - ("limit" = Option, Query, description = "Page size, up to 100") + ("beforeEntryId" = Option, Query, description = "Legacy newest-first pagination cursor"), + ("cursorEntryId" = Option, Query, description = "Pagination cursor for the selected sort direction"), + ("limit" = Option, Query, description = "Page size, up to 100"), + ("sortDirection" = Option, Query, description = "Sort direction enum value; defaults to descending") ), responses((status = 200, description = "Playback history", body = ListPlaybackHistoryResponse)), security(("bearer_auth" = [])) @@ -254,6 +257,80 @@ pub async fn play_history_entry( Ok(Json(response)) } +#[cfg_attr( + feature = "openapi", + utoipa::path( + delete, + path = "/api/rooms/{roomId}/playback/history/{entryId}", + tag = "Room", + params( + ("roomId" = String, Path, description = "Room ID"), + ("entryId" = String, Path, description = "Playback history entry ID") + ), + responses((status = 200, description = "Playback history deletion result", body = DeletePlaybackHistoryEntryResponse)), + security(("bearer_auth" = [])) + ) +)] +pub async fn delete_playback_history_entry( + request_meta: RequestMetadata, + State(state): State, + Path((room_id, entry_id)): Path<(String, String)>, +) -> AppResult> { + let response = execute_user_endpoint( + &state, + request_meta, + EndpointRateLimitCategory::Media, + EndpointRateLimitScope::RoomPlayback, + move |client_api, authenticated| async move { + client_api + .delete_playback_history_entry( + &authenticated.user_id(), + &room_id, + DeletePlaybackHistoryEntryRequest { entry_id }, + ) + .await + }, + ) + .await?; + Ok(Json(response)) +} + +#[cfg_attr( + feature = "openapi", + utoipa::path( + delete, + path = "/api/rooms/{roomId}/playback/history", + tag = "Room", + params(("roomId" = String, Path, description = "Room ID")), + responses((status = 200, description = "Playback history clear result", body = ClearPlaybackHistoryResponse)), + security(("bearer_auth" = [])) + ) +)] +pub async fn clear_playback_history( + request_meta: RequestMetadata, + State(state): State, + Path(path): Path, +) -> AppResult> { + let room_id = path.room_id; + let response = execute_user_endpoint( + &state, + request_meta, + EndpointRateLimitCategory::Media, + EndpointRateLimitScope::RoomPlayback, + move |client_api, authenticated| async move { + client_api + .clear_playback_history( + &authenticated.user_id(), + &room_id, + ClearPlaybackHistoryRequest {}, + ) + .await + }, + ) + .await?; + Ok(Json(response)) +} + #[cfg_attr( feature = "openapi", utoipa::path( diff --git a/synctv-api-http/src/http/tests.rs b/synctv-api-http/src/http/tests.rs index ccf86261..440d8ef2 100644 --- a/synctv-api-http/src/http/tests.rs +++ b/synctv-api-http/src/http/tests.rs @@ -1800,6 +1800,8 @@ async fn test_playback_navigation_routes_are_reachable_via_project_router() -> T ("POST", "/api/rooms/room_123/playback/previous", "{}"), ("GET", "/api/rooms/room_123/playback/history", ""), ("POST", "/api/rooms/room_123/playback/history/ph_1/play", ""), + ("DELETE", "/api/rooms/room_123/playback/history/ph_1", ""), + ("DELETE", "/api/rooms/room_123/playback/history", ""), ] { let app = register_all_routes().with_state(test_app_state()); let request = test_request( diff --git a/synctv-api-http/src/openapi.rs b/synctv-api-http/src/openapi.rs index 3f1df6c4..1ecace86 100644 --- a/synctv-api-http/src/openapi.rs +++ b/synctv-api-http/src/openapi.rs @@ -348,6 +348,8 @@ pub struct GoogleRpcStatusSchema { room::play_previous, room::list_playback_history, room::play_history_entry, + room::delete_playback_history_entry, + room::clear_playback_history, room::stop_playback, room::update_playback_state, room::start_room_password_login, @@ -2257,6 +2259,15 @@ mod tests { "streamPreference", "query", )?; + for name in ["beforeEntryId", "cursorEntryId", "limit", "sortDirection"] { + assert_parameter_location( + &doc, + "/api/rooms/{roomId}/playback/history", + "get", + name, + "query", + )?; + } Ok(()) } diff --git a/synctv-core/src/models/playback.rs b/synctv-core/src/models/playback.rs index d955c883..53370ddc 100644 --- a/synctv-core/src/models/playback.rs +++ b/synctv-core/src/models/playback.rs @@ -106,7 +106,7 @@ pub struct PlaybackHistoryEntry { pub struct PlaybackHistoryPage { pub entries: Vec, pub history_cursor_id: Option, - pub next_before_entry_id: Option, + pub next_cursor_entry_id: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] diff --git a/synctv-core/src/repository/playback.rs b/synctv-core/src/repository/playback.rs index fa8a33dc..df8a7494 100644 --- a/synctv-core/src/repository/playback.rs +++ b/synctv-core/src/repository/playback.rs @@ -480,6 +480,37 @@ impl RoomPlaybackStateRepository { Ok(result) } + pub async fn get_for_update_with_executor( + &self, + room_id: &RoomId, + conn: &mut PgConnection, + ) -> Result> { + let state = sqlx::query_as!( + RoomPlaybackState, + r#"SELECT state.room_id AS "room_id!: RoomId", + state.playing_media_id AS "playing_media_id?: MediaId", + state.playing_playlist_id AS "playing_playlist_id?: PlaylistId", + state.target AS "target?: crate::models::ProviderTarget", + state.current_progress_id, + state.history_cursor_id, + COALESCE(progress."position", 0.0) AS "position!", + state.speed AS "speed!", + state.is_playing AS "is_playing!", + state.playback_generation AS "playback_generation!", + state.updated_at AS "updated_at!", + state.version AS "version!" + FROM room_playback_state state + LEFT JOIN room_playback_progress progress + ON progress.id = state.current_progress_id + WHERE state.room_id = $1 + FOR UPDATE OF state"#, + room_id as &RoomId, + ) + .fetch_optional(&mut *conn) + .await?; + Ok(state) + } + /// Run `update_with_exact_version_on_conn` inside a dedicated transaction, /// committing on success and rolling back on error. async fn update_in_tx( diff --git a/synctv-core/src/repository/playback_history.rs b/synctv-core/src/repository/playback_history.rs index ca70d357..78b6924c 100644 --- a/synctv-core/src/repository/playback_history.rs +++ b/synctv-core/src/repository/playback_history.rs @@ -4,7 +4,7 @@ use sqlx::{PgConnection, PgPool}; use crate::{ models::{ try_hash_playback_target, MediaId, PlaybackHistoryEntry, PlaybackHistoryPage, PlaylistId, - ProviderTarget, RoomId, UserId, + ProviderTarget, RoomId, SortDirection, UserId, }, Error, Result, }; @@ -82,13 +82,45 @@ impl PlaybackHistoryRepository { pub async fn list( &self, room_id: &RoomId, - before_entry_id: Option, + cursor_entry_id: Option, limit: i32, + sort_direction: SortDirection, ) -> Result { let limit = limit.clamp(1, 100); - let rows = sqlx::query_as!( - PlaybackHistoryRow, - r#"SELECT history.id AS "id!", history.room_id AS "room_id!: RoomId", history.sequence AS "sequence!", + let rows = match sort_direction { + SortDirection::Asc => { + sqlx::query_as!( + PlaybackHistoryRow, + r#"SELECT history.id AS "id!", history.room_id AS "room_id!: RoomId", history.sequence AS "sequence!", + history.media_id AS "media_id?: MediaId", history.playlist_id AS "playlist_id?: PlaylistId", + history.target AS "target?: crate::models::ProviderTarget", + history.position_seconds, history.selected_by_user_id AS "selected_by_user_id?: UserId", + history.media_name, + history.playlist_name, + COALESCE(media.source_provider, playlist.source_provider) AS "source_provider?: crate::models::SourceProvider", + COALESCE(media.provider_instance_name, playlist.provider_instance_name) + AS "provider_instance_name?", + history.created_at, history.updated_at + FROM room_playback_history history + LEFT JOIN media + ON media.id = history.media_id AND media.room_id = history.room_id + LEFT JOIN playlists playlist + ON playlist.id = history.playlist_id AND playlist.room_id = history.room_id + WHERE history.room_id = $1 + AND ($2::bigint IS NULL OR history.id > $2) + ORDER BY history.sequence ASC, history.id ASC + LIMIT $3"#, + room_id.as_i64(), + cursor_entry_id, + i64::from(limit) + 1, + ) + .fetch_all(&self.pool) + .await? + } + SortDirection::Desc => { + sqlx::query_as!( + PlaybackHistoryRow, + r#"SELECT history.id AS "id!", history.room_id AS "room_id!: RoomId", history.sequence AS "sequence!", history.media_id AS "media_id?: MediaId", history.playlist_id AS "playlist_id?: PlaylistId", history.target AS "target?: crate::models::ProviderTarget", history.position_seconds, history.selected_by_user_id AS "selected_by_user_id?: UserId", @@ -105,14 +137,16 @@ impl PlaybackHistoryRepository { ON playlist.id = history.playlist_id AND playlist.room_id = history.room_id WHERE history.room_id = $1 AND ($2::bigint IS NULL OR history.id < $2) - ORDER BY history.sequence DESC + ORDER BY history.sequence DESC, history.id DESC LIMIT $3"#, - room_id.as_i64(), - before_entry_id, - i64::from(limit) + 1, - ) - .fetch_all(&self.pool) - .await?; + room_id.as_i64(), + cursor_entry_id, + i64::from(limit) + 1, + ) + .fetch_all(&self.pool) + .await? + } + }; let limit = usize::try_from(limit).map_err(|error| { Error::Internal(format!( "validated playback history limit is invalid: {error}" @@ -132,11 +166,11 @@ impl PlaybackHistoryRepository { .fetch_optional(&self.pool) .await? .flatten(); - let next_before_entry_id = has_more.then(|| entries.last().expect("non-empty page").id); + let next_cursor_entry_id = has_more.then(|| entries.last().expect("non-empty page").id); Ok(PlaybackHistoryPage { entries, history_cursor_id, - next_before_entry_id, + next_cursor_entry_id, }) } @@ -376,6 +410,34 @@ impl PlaybackHistoryRepository { Ok(()) } + pub async fn delete_entry_on_conn( + &self, + room_id: &RoomId, + entry_id: i64, + conn: &mut PgConnection, + ) -> Result { + let deleted_id = sqlx::query_scalar!( + r#"DELETE FROM room_playback_history + WHERE room_id = $1 AND id = $2 + RETURNING id AS "id!""#, + room_id.as_i64(), + entry_id, + ) + .fetch_optional(&mut *conn) + .await?; + Ok(deleted_id.is_some()) + } + + pub async fn clear_on_conn(&self, room_id: &RoomId, conn: &mut PgConnection) -> Result { + let result = sqlx::query!( + "DELETE FROM room_playback_history WHERE room_id = $1", + room_id.as_i64(), + ) + .execute(&mut *conn) + .await?; + Ok(result.rows_affected()) + } + pub async fn cleanup(&self, retention_days: u32, max_entries_per_room: i64) -> Result { if retention_days == 0 && max_entries_per_room <= 0 { return Ok(0); diff --git a/synctv-core/src/service/playback.rs b/synctv-core/src/service/playback.rs index de7131c4..4f03e42e 100644 --- a/synctv-core/src/service/playback.rs +++ b/synctv-core/src/service/playback.rs @@ -16,7 +16,7 @@ use crate::{ ChatPlaybackChangedMetadata, ChatPlaybackMetadata, MediaId, PlayMode, PlaybackChangeReason, PlaybackHistoryEntry, PlaybackHistoryPage, PlaybackKind, PlaybackSourceIdentity, PlaybackSourceMetadata, PlaylistId, ProviderTarget, RealtimeEvent, RoomId, - RoomPlaybackState, RoomSettings, SourceProvider, TwitchTargetKind, UserId, + RoomPlaybackState, RoomSettings, SortDirection, SourceProvider, TwitchTargetKind, UserId, }, repository::{ chat::InsertChatMessageEvent, @@ -1138,14 +1138,197 @@ impl PlaybackService { pub async fn list_playback_history( &self, room_id: &RoomId, - before_entry_id: Option, + cursor_entry_id: Option, limit: i32, + sort_direction: SortDirection, ) -> Result { self.history_repo - .list(room_id, before_entry_id, limit) + .list(room_id, cursor_entry_id, limit, sort_direction) .await } + async fn finish_playback_history_mutation( + &self, + room_id: &RoomId, + mut tx: sqlx::Transaction<'_, sqlx::Postgres>, + playback_state: Option, + deleted_count: u64, + outbox_event_factory: Option<&RealtimeOutboxPlaybackStateEventFactory>, + context: &'static str, + ) -> Result { + if deleted_count == 0 { + tx.commit().await?; + return Ok(0); + } + + let mut state = playback_state.ok_or_else(|| { + Error::Internal("Playback history exists without playback state".to_string()) + })?; + let reservation = match self + .begin_playback_write_from_db_version(room_id, state.version) + .await + { + Ok(reservation) => reservation, + Err(error) => { + if let Err(rollback_error) = tx.rollback().await { + tracing::warn!( + room_id = %room_id, + error = %rollback_error, + "Failed to roll back playback history mutation" + ); + } + return Err(error); + } + }; + let new_version = reservation + .as_ref() + .map_or(state.version + 1, |reservation| reservation.version); + state.position = state.computed_position_at(self.clock.now()); + + let result = async { + let state = self + .playback_repo + .update_with_exact_version_executor_and_previous_progress( + &state, + new_version, + None, + &mut tx, + ) + .await?; + self.insert_playback_outbox_tx(&mut tx, &state, outbox_event_factory) + .await?; + Ok(state) + } + .await; + let state = match result { + Ok(state) => state, + Err(error) => { + if let Err(rollback_error) = tx.rollback().await { + tracing::warn!( + room_id = %room_id, + error = %rollback_error, + "Failed to roll back playback history mutation" + ); + } + self.abort_playback_write(room_id, reservation.as_ref()) + .await; + return Err(error); + } + }; + if let Err(error) = tx.commit().await { + self.abort_playback_write(room_id, reservation.as_ref()) + .await; + return Err(error.into()); + } + + self.finalize_committed_playback_write_best_effort( + room_id, + reservation.as_ref(), + state.version, + context, + ) + .await; + + self.write_playback_cache(&state).await; + self.broadcast_invalidation(room_id, &state, context).await; + Ok(deleted_count) + } + + pub async fn delete_playback_history_entry_for_user( + &self, + room_id: &RoomId, + user_id: UserId, + entry_id: i64, + outbox_event_factory: Option, + ) -> Result { + if entry_id <= 0 { + return Err(Error::InvalidInput( + "entry_id must be a positive integer".to_string(), + )); + } + self.permission_service + .check_permission( + room_id, + &user_id, + crate::models::RoomPermission::MANAGE_ROOM_SETTINGS, + ) + .await?; + + crate::service::optimistic_retry::retry_with_optimistic_lock( + Self::MAX_RETRIES, + Self::BACKOFF_BASE_MS, + "delete playback history entry failed after maximum retry attempts", + || { + let outbox_event_factory = outbox_event_factory.clone(); + async move { + let mut tx = self.playback_repo.pool().begin().await?; + let playback_state = self + .playback_repo + .get_for_update_with_executor(room_id, &mut tx) + .await?; + let deleted = self + .history_repo + .delete_entry_on_conn(room_id, entry_id, &mut tx) + .await?; + let deleted_count = self + .finish_playback_history_mutation( + room_id, + tx, + playback_state, + u64::from(deleted), + outbox_event_factory.as_ref(), + "delete_playback_history_entry", + ) + .await?; + Ok(deleted_count != 0) + } + }, + ) + .await + } + + pub async fn clear_playback_history_for_user( + &self, + room_id: &RoomId, + user_id: UserId, + outbox_event_factory: Option, + ) -> Result { + self.permission_service + .check_permission( + room_id, + &user_id, + crate::models::RoomPermission::MANAGE_ROOM_SETTINGS, + ) + .await?; + + crate::service::optimistic_retry::retry_with_optimistic_lock( + Self::MAX_RETRIES, + Self::BACKOFF_BASE_MS, + "clear playback history failed after maximum retry attempts", + || { + let outbox_event_factory = outbox_event_factory.clone(); + async move { + let mut tx = self.playback_repo.pool().begin().await?; + let playback_state = self + .playback_repo + .get_for_update_with_executor(room_id, &mut tx) + .await?; + let deleted_count = self.history_repo.clear_on_conn(room_id, &mut tx).await?; + self.finish_playback_history_mutation( + room_id, + tx, + playback_state, + deleted_count, + outbox_event_factory.as_ref(), + "clear_playback_history", + ) + .await + } + }, + ) + .await + } + /// Play/pause playback pub async fn set_playing( &self, diff --git a/synctv-core/tests/playback_play_next_tests.rs b/synctv-core/tests/playback_play_next_tests.rs index 9ca836f5..4df6a30e 100644 --- a/synctv-core/tests/playback_play_next_tests.rs +++ b/synctv-core/tests/playback_play_next_tests.rs @@ -18,8 +18,8 @@ use synctv_core::{ models::{ room::AutoPlaySettings, room_settings::AutoPlay, Media, MediaId, PlayMode, PlaybackAlistMedia, PlaybackMedia, PlaybackMediaProvider, Playlist, PlaylistId, - ProviderInstance, ProviderTarget, RoomAdminPermissionBits, RoomId, RoomRole, RoomSettings, - SourceProvider, User, UserId, UserRole, UserStatus, + ProviderInstance, ProviderTarget, RoomAdminPermissionBits, RoomId, RoomMember, RoomRole, + RoomSettings, SortDirection, SourceProvider, User, UserId, UserRole, UserStatus, }, provider::{ DynamicListQuery, DynamicListResult, DynamicPagination, DynamicPlaylistItem, @@ -28,7 +28,7 @@ use synctv_core::{ }, repository::{ MediaRepository, PlaybackHistoryRepository, ProviderInstanceRepository, - UserProviderCredentialRepository, UserRepository, + RoomMemberRepository, UserProviderCredentialRepository, UserRepository, }, service::{ BruteForceProtection, InMemoryTokenBlacklistStore, JwtService, RoomService, @@ -774,7 +774,7 @@ async fn test_sequential_advance_preserves_static_playlist_context() { assert!(advanced.target.is_none()); let history = playback - .list_playback_history(&room.id, None, 10) + .list_playback_history(&room.id, None, 10, SortDirection::Desc) .await .checked("history should be listed"); assert_eq!(history.entries.len(), 2); @@ -795,6 +795,179 @@ async fn test_sequential_advance_preserves_static_playlist_context() { assert_eq!(latest_entry.provider_instance_name, None); } +#[tokio::test] +#[ignore = "Requires Docker"] +async fn test_playback_history_sort_delete_and_clear() { + let (_container, pool) = create_test_pool().await; + let user_repo = UserRepository::new(pool.clone()); + let room_service = make_room_service(&pool); + let owner = user_repo + .create(&make_user("history_management_owner")) + .await + .checked("test operation should succeed"); + let member = user_repo + .create(&make_user("history_management_member")) + .await + .checked("test operation should succeed"); + let room = room_service + .create_room( + "History Management".to_string(), + String::new(), + owner.id, + None, + None, + ) + .await + .checked("test operation should succeed"); + let media = [ + insert_root_media(&pool, &room.id, "history_management_a", 0).await, + insert_root_media(&pool, &room.id, "history_management_b", 1).await, + insert_root_media(&pool, &room.id, "history_management_c", 2).await, + ]; + let playback = room_service.playback_service(); + for item in &media { + playback + .switch(room.id, owner.id, Some(item.id), None, None) + .await + .checked("history item should be recorded"); + } + RoomMemberRepository::new(pool.clone()) + .add(&RoomMember::new(room.id, member.id, RoomRole::Member)) + .await + .checked("room member should be added"); + + let newest = playback + .list_playback_history(&room.id, None, 2, SortDirection::Desc) + .await + .checked("newest history page should be listed"); + assert_eq!( + newest + .entries + .iter() + .filter_map(|entry| entry.media_id) + .collect::>(), + vec![media[2].id, media[1].id] + ); + let newest_cursor = newest + .next_cursor_entry_id + .checked("newest page should have a cursor"); + let newest_tail = playback + .list_playback_history(&room.id, Some(newest_cursor), 2, SortDirection::Desc) + .await + .checked("newest history tail should be listed"); + assert_eq!(newest_tail.entries[0].media_id, Some(media[0].id)); + + let oldest = playback + .list_playback_history(&room.id, None, 2, SortDirection::Asc) + .await + .checked("oldest history page should be listed"); + assert_eq!( + oldest + .entries + .iter() + .filter_map(|entry| entry.media_id) + .collect::>(), + vec![media[0].id, media[1].id] + ); + let oldest_cursor = oldest + .next_cursor_entry_id + .checked("oldest page should have a cursor"); + let oldest_tail = playback + .list_playback_history(&room.id, Some(oldest_cursor), 2, SortDirection::Asc) + .await + .checked("oldest history tail should be listed"); + assert_eq!(oldest_tail.entries[0].media_id, Some(media[2].id)); + + assert!( + playback + .delete_playback_history_entry_for_user( + &room.id, + member.id, + oldest.entries[1].id, + None, + ) + .await + .is_err(), + "member without room settings permission must not delete history" + ); + assert!( + playback + .clear_playback_history_for_user(&room.id, member.id, None) + .await + .is_err(), + "member without room settings permission must not clear history" + ); + let after_denied_mutations = playback + .list_playback_history(&room.id, None, 10, SortDirection::Desc) + .await + .checked("history should remain readable after denied mutations"); + assert_eq!(after_denied_mutations.entries.len(), media.len()); + + tokio::time::sleep(std::time::Duration::from_millis(1_100)).await; + let before_delete = playback + .get_state(&room.id) + .await + .checked("playback state should be readable"); + let position_before_delete = before_delete.computed_position(); + assert!(playback + .delete_playback_history_entry_for_user(&room.id, owner.id, oldest.entries[1].id, None,) + .await + .checked("history entry should be deleted")); + let after_delete = playback + .get_state(&room.id) + .await + .checked("playback state should be readable after deletion"); + assert_eq!(after_delete.version, before_delete.version + 1); + assert_eq!(after_delete.playing_media_id, Some(media[2].id)); + assert!( + after_delete.computed_position() >= position_before_delete - 0.1, + "deleting history must not move active playback backwards" + ); + assert!(!playback + .delete_playback_history_entry_for_user(&room.id, owner.id, oldest.entries[1].id, None,) + .await + .checked("repeated history deletion should be idempotent")); + + assert!(playback + .delete_playback_history_entry_for_user(&room.id, owner.id, newest.entries[0].id, None,) + .await + .checked("current history entry should be deleted")); + let after_current_delete = playback + .get_state(&room.id) + .await + .checked("playback state should survive current history deletion"); + assert_eq!(after_current_delete.version, after_delete.version + 1); + assert_eq!(after_current_delete.playing_media_id, Some(media[2].id)); + assert!(after_current_delete.history_cursor_id.is_none()); + + tokio::time::sleep(std::time::Duration::from_millis(1_100)).await; + let before_clear = playback + .get_state(&room.id) + .await + .checked("playback state should be readable before clearing history"); + let position_before_clear = before_clear.computed_position(); + let deleted_count = playback + .clear_playback_history_for_user(&room.id, owner.id, None) + .await + .checked("remaining history should be cleared"); + assert_eq!(deleted_count, 1); + let after_clear = playback + .get_state(&room.id) + .await + .checked("playback state should survive clearing history"); + assert_eq!(after_clear.version, before_clear.version + 1); + assert_eq!(after_clear.playing_media_id, Some(media[2].id)); + assert!( + after_clear.computed_position() >= position_before_clear - 0.1, + "clearing history must not move active playback backwards" + ); + let empty = playback + .list_playback_history(&room.id, None, 10, SortDirection::Desc) + .await + .checked("cleared history should be listed"); + assert!(empty.entries.is_empty()); +} + #[tokio::test] #[ignore = "Requires Docker"] async fn test_auto_advance_after_previous_uses_recorded_forward_history() { @@ -843,7 +1016,7 @@ async fn test_auto_advance_after_previous_uses_recorded_forward_history() { .checked("B should return to A"); assert_eq!(previous.position, 0.0); let history = playback - .list_playback_history(&room.id, None, 10) + .list_playback_history(&room.id, None, 10, SortDirection::Desc) .await .checked("history should be listed"); assert_eq!(history.entries.len(), 2); @@ -1015,7 +1188,7 @@ async fn test_playback_history_cleanup_preserves_cursor_and_adjacent_navigation( .checked("history cleanup should succeed"); assert_eq!(deleted, 2); let history = playback - .list_playback_history(&room.id, None, 10) + .list_playback_history(&room.id, None, 10, SortDirection::Desc) .await .checked("retained history should be listed"); assert_eq!(history.entries.len(), 2); @@ -1049,7 +1222,7 @@ async fn test_playback_history_cleanup_preserves_cursor_and_adjacent_navigation( .checked("count-based history cleanup should succeed"); assert_eq!(deleted, 1); let retained = playback - .list_playback_history(&room.id, None, 10) + .list_playback_history(&room.id, None, 10, SortDirection::Desc) .await .checked("count-limited history should be listed"); assert_eq!(retained.entries.len(), 2); @@ -1126,7 +1299,7 @@ async fn test_deleted_media_and_playlist_cascade_playback_history() { .checked("playlist deletion should succeed"); let history = playback - .list_playback_history(&room.id, None, 20) + .list_playback_history(&room.id, None, 20, SortDirection::Desc) .await .checked("history should be listed"); assert!(history @@ -1197,7 +1370,7 @@ async fn test_history_view_permission_cannot_change_playback() { .await .checked("second history entry should be recorded"); let history = playback - .list_playback_history(&room.id, None, 10) + .list_playback_history(&room.id, None, 10, SortDirection::Desc) .await .checked("history should be listed"); let entry_id = history diff --git a/synctv-proto/proto/client.proto b/synctv-proto/proto/client.proto index 40ef2fb0..e3478332 100644 --- a/synctv-proto/proto/client.proto +++ b/synctv-proto/proto/client.proto @@ -227,6 +227,8 @@ service RoomService { rpc PlayPrevious(PlayPreviousRequest) returns (PlaybackState); rpc ListPlaybackHistory(ListPlaybackHistoryRequest) returns (ListPlaybackHistoryResponse); rpc PlayHistoryEntry(PlayHistoryEntryRequest) returns (PlaybackState); + rpc DeletePlaybackHistoryEntry(DeletePlaybackHistoryEntryRequest) returns (DeletePlaybackHistoryEntryResponse); + rpc ClearPlaybackHistory(ClearPlaybackHistoryRequest) returns (ClearPlaybackHistoryResponse); rpc GetPlayback(GetPlaybackRequest) returns (GetPlaybackResponse); // Get current playback state and info rpc UpdatePlaybackState(UpdatePlaybackStateRequest) returns (PlaybackState); // Patch current playback state } @@ -2206,14 +2208,22 @@ message PlaybackHistoryEntry { } message ListPlaybackHistoryRequest { + option (buf.validate.message).cel = { + id: "list_playback_history.single_cursor" + message: "before_entry_id and cursor_entry_id cannot both be set" + expression: "!(has(this.before_entry_id) && has(this.cursor_entry_id))" + }; optional string before_entry_id = 1 [(buf.validate.field).string = {min_len: 4 max_len: 64 pattern: "^ph_[A-Za-z0-9]+$"}]; int32 limit = 2 [(buf.validate.field).int32 = {gte: 0 lte: 100}]; + optional string cursor_entry_id = 3 [(buf.validate.field).string = {min_len: 4 max_len: 64 pattern: "^ph_[A-Za-z0-9]+$"}]; + SortDirection sort_direction = 4 [(buf.validate.field).enum.defined_only = true]; } message ListPlaybackHistoryResponse { repeated PlaybackHistoryEntry entries = 1; string history_cursor_id = 2; string next_before_entry_id = 3; + string next_cursor_entry_id = 4; } message PlayHistoryEntryRequest { @@ -2221,6 +2231,20 @@ message PlayHistoryEntryRequest { optional string client_operation_id = 2 [(buf.validate.field).string = {uuid: true}]; } +message DeletePlaybackHistoryEntryRequest { + string entry_id = 1 [(buf.validate.field).string = {min_len: 4 max_len: 64 pattern: "^ph_[A-Za-z0-9]+$"}]; +} + +message DeletePlaybackHistoryEntryResponse { + bool deleted = 1; +} + +message ClearPlaybackHistoryRequest {} + +message ClearPlaybackHistoryResponse { + uint64 deleted_count = 1; +} + // Realtime API: update the current playback source. Empty media_id, playlist_id, and target clears playback. message UpdatePlaybackRequest { option (buf.validate.message).cel = {