feat: add playback history management (#431)

## Summary

- add ordered cursor pagination for room playback history
- add room-manager APIs to delete individual entries or clear history
- publish history mutations through the existing playback realtime path
- preserve live playback progress and cross-instance version fencing
during mutations
- document the new query parameters and refresh SQLx offline metadata

## Testing

- `cargo check --workspace --all-targets`
- `make clippy-check`
- `cargo fmt --all -- --check`
- core playback tests: 108 passed, 8 ignored
- HTTP/OpenAPI tests: 226 passed, 59 ignored
- PostgreSQL integration test for delete, clear, current-entry handling,
and playback position continuity
pull/436/head
zijiren 1 month ago committed by GitHub
parent 89b16ab21e
commit 9936e555d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

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

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

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

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

@ -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))?

@ -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<synctv_proto::client::DeletePlaybackHistoryEntryResponse, ApiError> {
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<synctv_proto::client::ClearPlaybackHistoryResponse, ApiError> {
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(

@ -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())?;

@ -712,6 +712,20 @@ impl RoomService for ClientServiceImpl {
playback::play_history_entry(self, request).await
}
async fn delete_playback_history_entry(
&self,
request: Request<DeletePlaybackHistoryEntryRequest>,
) -> Result<Response<DeletePlaybackHistoryEntryResponse>, Status> {
playback::delete_playback_history_entry(self, request).await
}
async fn clear_playback_history(
&self,
request: Request<ClearPlaybackHistoryRequest>,
) -> Result<Response<ClearPlaybackHistoryResponse>, Status> {
playback::clear_playback_history(self, request).await
}
async fn get_playback(
&self,
request: Request<GetPlaybackRequest>,

@ -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<DeletePlaybackHistoryEntryRequest>,
) -> Result<Response<DeletePlaybackHistoryEntryResponse>, 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<ClearPlaybackHistoryRequest>,
) -> Result<Response<ClearPlaybackHistoryResponse>, 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<GetPlaybackRequest>,

@ -538,6 +538,10 @@ fn register_write_routes() -> Router<AppState> {
"/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<AppState> {
.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",

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

@ -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<String>, Query, description = "History pagination cursor"),
("limit" = Option<i32>, Query, description = "Page size, up to 100")
("beforeEntryId" = Option<String>, Query, description = "Legacy newest-first pagination cursor"),
("cursorEntryId" = Option<String>, Query, description = "Pagination cursor for the selected sort direction"),
("limit" = Option<i32>, Query, description = "Page size, up to 100"),
("sortDirection" = Option<i32>, 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<AppState>,
Path((room_id, entry_id)): Path<(String, String)>,
) -> AppResult<Json<DeletePlaybackHistoryEntryResponse>> {
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<AppState>,
Path(path): Path<synctv_proto::client::RoomPathRequest>,
) -> AppResult<Json<ClearPlaybackHistoryResponse>> {
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(

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

@ -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(())
}

@ -106,7 +106,7 @@ pub struct PlaybackHistoryEntry {
pub struct PlaybackHistoryPage {
pub entries: Vec<PlaybackHistoryEntry>,
pub history_cursor_id: Option<i64>,
pub next_before_entry_id: Option<i64>,
pub next_cursor_entry_id: Option<i64>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]

@ -480,6 +480,37 @@ impl RoomPlaybackStateRepository {
Ok(result)
}
pub async fn get_for_update_with_executor(
&self,
room_id: &RoomId,
conn: &mut PgConnection,
) -> Result<Option<RoomPlaybackState>> {
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(

@ -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<i64>,
cursor_entry_id: Option<i64>,
limit: i32,
sort_direction: SortDirection,
) -> Result<PlaybackHistoryPage> {
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<bool> {
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<u64> {
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<u64> {
if retention_days == 0 && max_entries_per_room <= 0 {
return Ok(0);

@ -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<i64>,
cursor_entry_id: Option<i64>,
limit: i32,
sort_direction: SortDirection,
) -> Result<PlaybackHistoryPage> {
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<RoomPlaybackState>,
deleted_count: u64,
outbox_event_factory: Option<&RealtimeOutboxPlaybackStateEventFactory>,
context: &'static str,
) -> Result<u64> {
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<RealtimeOutboxPlaybackStateEventFactory>,
) -> Result<bool> {
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<RealtimeOutboxPlaybackStateEventFactory>,
) -> Result<u64> {
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,

@ -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<_>>(),
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<_>>(),
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

@ -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 = {

Loading…
Cancel
Save