Merge pull request #3 from Silo-Server/t3code/ef35ed7f

Rename webhook sync actors to profile mappings
This commit is contained in:
Quick
2026-05-24 00:55:41 -04:00
committed by GitHub
19 changed files with 553 additions and 316 deletions
+8 -7
View File
@@ -12,6 +12,7 @@ DEV_IMPORT_CATALOG_SEED_PATH ?= /tmp/silo-catalog-seed
DEV_PLUGIN_SDK_DIR ?= ../silo-plugin-sdk
DEV_LOCAL_IMAGE ?= silo:dev-local
DEV_JELLYCOMPAT_DEBUG_LOG ?= /opt/silo/jellycompat-debug.log
DEV_COMPOSE_FILE ?= docker-compose.yml
RSYNC ?= $(shell command -v rsync || echo /usr/bin/rsync)
SILO_IMAGE ?= ghcr.io/silo-server/silo-server:latest
@@ -44,7 +45,7 @@ deploy-dev:
--load \
.'
@echo "==> Starting services via Docker Compose..."
ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && SILO_IMAGE=$(DEV_LOCAL_IMAGE) MEDIA_ROOT="$(DEV_MEDIA_ROOT)" docker compose -f docker-compose.dev.yml --profile integrated up -d --force-recreate silo'
ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && SILO_IMAGE=$(DEV_LOCAL_IMAGE) MEDIA_ROOT="$(DEV_MEDIA_ROOT)" MEDIA_CONTAINER_ROOT="$(DEV_MEDIA_ROOT)" docker compose -f $(DEV_COMPOSE_FILE) up -d --force-recreate silo'
@echo "==> Pruning old images..."
ssh $(DEV_DEPLOY_HOST) 'docker image prune -f --filter "dangling=true"'
@echo "==> Verifying deployment..."
@@ -56,7 +57,7 @@ dev-deploy: deploy-dev
deploy-dev-image:
@echo "==> Syncing compose config to $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/..."
$(RSYNC) -az docker-compose.dev.yml $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/
$(RSYNC) -az $(DEV_COMPOSE_FILE) $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/
$(RSYNC) -az postgres/ $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/postgres/
@if [ -f .env ]; then \
$(RSYNC) -az .env $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/; \
@@ -68,7 +69,7 @@ deploy-dev-image:
@echo "==> Pulling $(SILO_IMAGE) on remote..."
ssh $(DEV_DEPLOY_HOST) 'docker pull $(SILO_IMAGE)'
@echo "==> Starting services via Docker Compose..."
ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && docker compose -f docker-compose.dev.yml --profile integrated up -d --force-recreate silo'
ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && MEDIA_ROOT="$(DEV_MEDIA_ROOT)" MEDIA_CONTAINER_ROOT="$(DEV_MEDIA_ROOT)" docker compose -f $(DEV_COMPOSE_FILE) up -d --force-recreate silo'
@echo "==> Verifying deployment..."
@sleep 5
ssh $(DEV_DEPLOY_HOST) 'docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"'
@@ -78,7 +79,7 @@ dev-remote-stop:
@echo "==> Stopping air tmux session if running..."
ssh $(DEV_DEPLOY_HOST) 'tmux kill-session -t silo-dev 2>/dev/null || true'
@echo "==> Stopping dev stack..."
ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && docker compose -f docker-compose.dev.yml --profile integrated down'
ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && docker compose -f $(DEV_COMPOSE_FILE) down'
@echo "==> Done."
deploy-dev-transcode:
@@ -94,16 +95,16 @@ deploy-dev-transcode:
dev-deploy-transcode: deploy-dev-transcode
deploy-dev-import:
$(RSYNC) -az docker-compose.dev.yml $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/
$(RSYNC) -az $(DEV_COMPOSE_FILE) $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/
$(RSYNC) -az postgres/ $(DEV_DEPLOY_HOST):$(DEV_DEPLOY_DIR)/Silo/postgres/
ssh $(DEV_DEPLOY_HOST) 'docker pull $(SILO_IMAGE)'
ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && MEDIA_ROOT="$(DEV_IMPORT_MEDIA_ROOT)" CATALOG_SEED_PATH="$(DEV_IMPORT_CATALOG_SEED_PATH)" docker compose -p silo-import -f docker-compose.dev.yml --profile integrated up -d --force-recreate'
ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && MEDIA_ROOT="$(DEV_IMPORT_MEDIA_ROOT)" MEDIA_CONTAINER_ROOT="$(DEV_IMPORT_MEDIA_ROOT)" CATALOG_SEED_PATH="$(DEV_IMPORT_CATALOG_SEED_PATH)" docker compose -p silo-import -f $(DEV_COMPOSE_FILE) up -d --force-recreate'
ssh $(DEV_DEPLOY_HOST) 'docker image prune -f --filter "dangling=true"'
dev-deploy-import: deploy-dev-import
deploy-dev-import-reset:
ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && docker compose -p silo-import -f docker-compose.dev.yml down -v --remove-orphans || true'
ssh $(DEV_DEPLOY_HOST) 'cd $(DEV_DEPLOY_DIR)/Silo && docker compose -p silo-import -f $(DEV_COMPOSE_FILE) down -v --remove-orphans || true'
$(MAKE) deploy-dev-import
dev-deploy-import-reset: deploy-dev-import-reset
+1 -1
View File
@@ -118,7 +118,7 @@ helper and cutover guide in [docs/continuum-to-silo-docker-migration.md](docs/co
## Development
Local development remains intentionally separate from the deploy-oriented compose setup. Use [docker-compose.dev.yml](docker-compose.dev.yml) and the existing source-build workflow for local development.
Local development remains intentionally separate from the deploy-oriented compose setup. Use [docker-compose.yml](docker-compose.yml) and the existing source-build workflow for local development.
```sh
# Run the frontend dev server (hot reload, proxies API to :8090)
+30 -30
View File
@@ -167,7 +167,7 @@ func (h *WebhookSyncHandler) HandleLegacyCreateConnection(w http.ResponseWriter,
h.writeError(w, err)
return
}
actors, err := h.service.GetActors(r.Context(), userID, resp.Connection.ID)
actors, err := h.service.GetProfileMappings(r.Context(), userID, resp.Connection.ID)
if err != nil {
h.writeError(w, err)
return
@@ -251,14 +251,14 @@ func (h *WebhookSyncHandler) HandleLegacyRotateWebhook(w http.ResponseWriter, r
})
}
func (h *WebhookSyncHandler) HandleGetActors(w http.ResponseWriter, r *http.Request) {
func (h *WebhookSyncHandler) HandleGetProfileMappings(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
id := chi.URLParam(r, "id")
if id == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Connection ID is required")
return
}
resp, err := h.service.GetActors(r.Context(), userID, id)
resp, err := h.service.GetProfileMappings(r.Context(), userID, id)
if err != nil {
h.writeError(w, err)
return
@@ -292,7 +292,7 @@ func (h *WebhookSyncHandler) HandleLegacyGetActors(w http.ResponseWriter, r *htt
writeError(w, http.StatusBadRequest, "bad_request", "Connection ID is required")
return
}
resp, err := h.service.GetActors(r.Context(), userID, id)
resp, err := h.service.GetProfileMappings(r.Context(), userID, id)
if err != nil {
h.writeError(w, err)
return
@@ -300,19 +300,19 @@ func (h *WebhookSyncHandler) HandleLegacyGetActors(w http.ResponseWriter, r *htt
writeJSON(w, http.StatusOK, toLegacyPlexActorsResponse(resp))
}
func (h *WebhookSyncHandler) HandleUpdateActors(w http.ResponseWriter, r *http.Request) {
func (h *WebhookSyncHandler) HandleUpdateProfileMappings(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
id := chi.URLParam(r, "id")
if id == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Connection ID is required")
return
}
var req webhooksync.UpdateActorMappingsInput
var req webhooksync.UpdateProfileMappingsInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
resp, err := h.service.UpdateActors(r.Context(), userID, id, req)
resp, err := h.service.UpdateProfileMappings(r.Context(), userID, id, req)
if err != nil {
h.writeError(w, err)
return
@@ -332,18 +332,18 @@ func (h *WebhookSyncHandler) HandleLegacyUpdateActors(w http.ResponseWriter, r *
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
input := webhooksync.UpdateActorMappingsInput{
Mappings: make([]webhooksync.UpdateActorMapping, 0, len(req.Mappings)),
input := webhooksync.UpdateProfileMappingsInput{
Mappings: make([]webhooksync.UpdateProfileMapping, 0, len(req.Mappings)),
}
for _, mapping := range req.Mappings {
profileID := mapping.SiloProfileID
input.Mappings = append(input.Mappings, webhooksync.UpdateActorMapping{
ExternalActorID: strconv.FormatInt(mapping.PlexAccountID, 10),
ExternalActorName: mapping.PlexAccountTitle,
SiloProfileID: &profileID,
input.Mappings = append(input.Mappings, webhooksync.UpdateProfileMapping{
ExternalUserID: strconv.FormatInt(mapping.PlexAccountID, 10),
ExternalUserName: mapping.PlexAccountTitle,
SiloProfileID: &profileID,
})
}
resp, err := h.service.UpdateActors(r.Context(), userID, id, input)
resp, err := h.service.UpdateProfileMappings(r.Context(), userID, id, input)
if err != nil {
h.writeError(w, err)
return
@@ -460,11 +460,11 @@ func webhookEventAttrs(meta webhooksync.WebhookRequestLogContext, result *webhoo
if result.Action != "" {
attrs["action"] = result.Action
}
if result.ActorID != "" {
attrs["actor_id"] = result.ActorID
if result.UserID != "" {
attrs["external_user_id"] = result.UserID
}
if result.ActorName != "" {
attrs["actor_name"] = result.ActorName
if result.UserName != "" {
attrs["external_user_name"] = result.UserName
}
if result.ExternalItemID != "" {
attrs["external_item_id"] = result.ExternalItemID
@@ -506,8 +506,8 @@ func logWebhookDelivery(result *webhooksync.ProcessWebhookResult, meta webhooksy
if result.Action != "" {
args = append(args, "action", result.Action)
}
if result.ActorID != "" {
args = append(args, "actor_id", result.ActorID)
if result.UserID != "" {
args = append(args, "external_user_id", result.UserID)
}
if result.ExternalItemID != "" {
args = append(args, "external_item_id", result.ExternalItemID)
@@ -576,7 +576,7 @@ func toLegacyPlexConnection(connection webhooksync.Connection, webhookURL string
PlexServerName: connection.ServerName,
WebhookURL: webhookURL,
BindingsReady: false,
ActorCount: connection.ActorCount,
ActorCount: connection.UserCount,
AccountDiscoveryAvailable: connection.AccountDiscoveryAvailable,
LastWebhookReceivedAt: connection.LastWebhookReceivedAt,
LastWebhookErrorAt: connection.LastWebhookErrorAt,
@@ -586,24 +586,24 @@ func toLegacyPlexConnection(connection webhooksync.Connection, webhookURL string
}
}
func toLegacyPlexActorsResponse(resp *webhooksync.ActorMappingsResponse) legacyPlexSyncActorsResponse {
func toLegacyPlexActorsResponse(resp *webhooksync.ProfileMappingsResponse) legacyPlexSyncActorsResponse {
if resp == nil {
return legacyPlexSyncActorsResponse{}
}
return legacyPlexSyncActorsResponse{
Mappings: toLegacyPlexActorMappings(resp.Mappings),
DiscoveredActors: toLegacyPlexDiscoveredActors(resp.DiscoveredActors),
DiscoveredActors: toLegacyPlexDiscoveredActors(resp.DiscoveredUsers),
AccountDiscoveryAvailable: resp.AccountDiscoveryAvailable,
}
}
func toLegacyPlexActorMappings(mappings []webhooksync.ActorMapping) []legacyPlexSyncActorMapping {
func toLegacyPlexActorMappings(mappings []webhooksync.ProfileMapping) []legacyPlexSyncActorMapping {
if len(mappings) == 0 {
return nil
}
out := make([]legacyPlexSyncActorMapping, 0, len(mappings))
for _, mapping := range mappings {
accountID, err := strconv.ParseInt(mapping.ExternalActorID, 10, 64)
accountID, err := strconv.ParseInt(mapping.ExternalUserID, 10, 64)
if err != nil {
continue
}
@@ -611,7 +611,7 @@ func toLegacyPlexActorMappings(mappings []webhooksync.ActorMapping) []legacyPlex
ID: mapping.ID,
ConnectionID: mapping.ConnectionID,
PlexAccountID: accountID,
PlexAccountTitle: mapping.ExternalActorName,
PlexAccountTitle: mapping.ExternalUserName,
SiloProfileID: valueOrEmpty(mapping.SiloProfileID),
CreatedAt: mapping.CreatedAt,
UpdatedAt: mapping.UpdatedAt,
@@ -620,25 +620,25 @@ func toLegacyPlexActorMappings(mappings []webhooksync.ActorMapping) []legacyPlex
return out
}
func toLegacyPlexDiscoveredActors(actors []webhooksync.DiscoveredActor) []legacyPlexSyncDiscoveredActor {
func toLegacyPlexDiscoveredActors(actors []webhooksync.DiscoveredUser) []legacyPlexSyncDiscoveredActor {
if len(actors) == 0 {
return nil
}
out := make([]legacyPlexSyncDiscoveredActor, 0, len(actors))
for _, actor := range actors {
accountID, err := strconv.ParseInt(actor.ExternalActorID, 10, 64)
accountID, err := strconv.ParseInt(actor.ExternalUserID, 10, 64)
if err != nil {
continue
}
out = append(out, legacyPlexSyncDiscoveredActor{
PlexAccountID: accountID,
PlexAccountTitle: actor.ExternalActorName,
PlexAccountTitle: actor.ExternalUserName,
})
}
return out
}
func firstLegacyMappedActor(mappings []webhooksync.ActorMapping) legacyPlexSyncActorMapping {
func firstLegacyMappedActor(mappings []webhooksync.ProfileMapping) legacyPlexSyncActorMapping {
legacyMappings := toLegacyPlexActorMappings(mappings)
if len(legacyMappings) == 0 {
return legacyPlexSyncActorMapping{}
+12 -12
View File
@@ -23,21 +23,21 @@ func TestToLegacyPlexActorsResponse(t *testing.T) {
now := time.Date(2026, 4, 7, 12, 0, 0, 0, time.UTC)
profileID := "profile-1"
resp := toLegacyPlexActorsResponse(&webhooksync.ActorMappingsResponse{
Mappings: []webhooksync.ActorMapping{
resp := toLegacyPlexActorsResponse(&webhooksync.ProfileMappingsResponse{
Mappings: []webhooksync.ProfileMapping{
{
ID: 11,
ConnectionID: "conn-1",
ExternalActorID: "42",
ExternalActorName: "Alice",
SiloProfileID: &profileID,
CreatedAt: now,
UpdatedAt: now,
ID: 11,
ConnectionID: "conn-1",
ExternalUserID: "42",
ExternalUserName: "Alice",
SiloProfileID: &profileID,
CreatedAt: now,
UpdatedAt: now,
},
},
DiscoveredActors: []webhooksync.DiscoveredActor{
{ExternalActorID: "42", ExternalActorName: "Alice"},
{ExternalActorID: "77", ExternalActorName: "Bob"},
DiscoveredUsers: []webhooksync.DiscoveredUser{
{ExternalUserID: "42", ExternalUserName: "Alice"},
{ExternalUserID: "77", ExternalUserName: "Bob"},
},
AccountDiscoveryAvailable: true,
})
+2 -2
View File
@@ -1437,8 +1437,8 @@ func NewRouter(deps Dependencies) chi.Router {
r.Delete("/connections/{id}", webhookSyncHandler.HandleDeleteConnection)
r.Post("/connections/{id}/webhook/rotate", webhookSyncHandler.HandleRotateWebhook)
r.Get("/connections/{id}/events", webhookSyncHandler.HandleListEvents)
r.Get("/connections/{id}/actors", webhookSyncHandler.HandleGetActors)
r.Put("/connections/{id}/actors", webhookSyncHandler.HandleUpdateActors)
r.Get("/connections/{id}/profile-mappings", webhookSyncHandler.HandleGetProfileMappings)
r.Put("/connections/{id}/profile-mappings", webhookSyncHandler.HandleUpdateProfileMappings)
})
}
+21 -21
View File
@@ -34,7 +34,7 @@ func (p *PlexProvider) ValidateCreateInput(input CreateConnectionInput) error {
return nil
}
func (p *PlexProvider) DefaultActor(ctx context.Context, _ *Connection, input CreateConnectionInput) (string, string, bool, error) {
func (p *PlexProvider) DefaultUser(ctx context.Context, _ *Connection, input CreateConnectionInput) (string, string, bool, error) {
account, err := p.client.GetCurrentUser(ctx, input.AccessToken)
if err != nil {
return "", "", false, err
@@ -42,20 +42,20 @@ func (p *PlexProvider) DefaultActor(ctx context.Context, _ *Connection, input Cr
return strconv.Itoa(account.ID), account.Title, true, nil
}
func (p *PlexProvider) DiscoverActors(ctx context.Context, conn *Connection, mappings []ActorMapping) ([]DiscoveredActor, bool, error) {
func (p *PlexProvider) DiscoverUsers(ctx context.Context, conn *Connection, mappings []ProfileMapping) ([]DiscoveredUser, bool, error) {
if strings.TrimSpace(conn.BaseURL) == "" || strings.TrimSpace(conn.AccessToken) == "" {
return mappingsToDiscoveredActors(mappings), false, nil
return mappingsToDiscoveredUsers(mappings), false, nil
}
accounts, err := p.client.ListAccounts(ctx, conn.BaseURL, conn.AccessToken)
if err != nil {
return nil, false, err
}
accounts = filterDiscoveredAccounts(accounts, mappings)
result := make([]DiscoveredActor, 0, len(accounts))
result := make([]DiscoveredUser, 0, len(accounts))
for _, a := range accounts {
result = append(result, DiscoveredActor{
ExternalActorID: a.ID,
ExternalActorName: a.Name,
result = append(result, DiscoveredUser{
ExternalUserID: a.ID,
ExternalUserName: a.Name,
})
}
return result, true, nil
@@ -121,8 +121,8 @@ func (p *PlexProvider) ParseWebhook(ctx context.Context, conn *Connection, r *ht
OccurredAt: occurredAt,
Action: ActionImportProgress,
EventKind: payload.Event,
ActorID: strconv.FormatInt(payload.Account.ID, 10),
ActorName: payload.Account.Title,
UserID: strconv.FormatInt(payload.Account.ID, 10),
UserName: payload.Account.Title,
ExternalItemID: payload.Metadata.RatingKey,
MediaKind: payload.Metadata.Type,
Completed: canonical.Played,
@@ -147,12 +147,12 @@ func (p *EmbyProvider) ValidateCreateInput(input CreateConnectionInput) error {
return nil
}
func (p *EmbyProvider) DefaultActor(context.Context, *Connection, CreateConnectionInput) (string, string, bool, error) {
func (p *EmbyProvider) DefaultUser(context.Context, *Connection, CreateConnectionInput) (string, string, bool, error) {
return "", "", false, nil
}
func (p *EmbyProvider) DiscoverActors(_ context.Context, _ *Connection, mappings []ActorMapping) ([]DiscoveredActor, bool, error) {
return mappingsToDiscoveredActors(mappings), false, nil
func (p *EmbyProvider) DiscoverUsers(_ context.Context, _ *Connection, mappings []ProfileMapping) ([]DiscoveredUser, bool, error) {
return mappingsToDiscoveredUsers(mappings), false, nil
}
func (p *EmbyProvider) ParseWebhook(_ context.Context, conn *Connection, r *http.Request) (*CanonicalEvent, error) {
@@ -191,8 +191,8 @@ func (p *EmbyProvider) ParseWebhook(_ context.Context, conn *Connection, r *http
OccurredAt: record.UpdatedAt,
Action: action,
EventKind: eventName,
ActorID: payload.User.ID,
ActorName: payload.User.Name,
UserID: payload.User.ID,
UserName: payload.User.Name,
ExternalItemID: payload.Item.ID,
MediaKind: mediaKind,
Completed: record.Played,
@@ -338,12 +338,12 @@ func (p *JellyfinProvider) ValidateCreateInput(input CreateConnectionInput) erro
return nil
}
func (p *JellyfinProvider) DefaultActor(context.Context, *Connection, CreateConnectionInput) (string, string, bool, error) {
func (p *JellyfinProvider) DefaultUser(context.Context, *Connection, CreateConnectionInput) (string, string, bool, error) {
return "", "", false, nil
}
func (p *JellyfinProvider) DiscoverActors(_ context.Context, _ *Connection, mappings []ActorMapping) ([]DiscoveredActor, bool, error) {
return mappingsToDiscoveredActors(mappings), false, nil
func (p *JellyfinProvider) DiscoverUsers(_ context.Context, _ *Connection, mappings []ProfileMapping) ([]DiscoveredUser, bool, error) {
return mappingsToDiscoveredUsers(mappings), false, nil
}
func (p *JellyfinProvider) ParseWebhook(_ context.Context, conn *Connection, r *http.Request) (*CanonicalEvent, error) {
@@ -412,8 +412,8 @@ func (p *JellyfinProvider) ParseWebhook(_ context.Context, conn *Connection, r *
OccurredAt: record.UpdatedAt,
Action: ActionImportProgress,
EventKind: payload.NotificationType,
ActorID: payload.User.ID,
ActorName: payload.User.Name,
UserID: payload.User.ID,
UserName: payload.User.Name,
ExternalItemID: payload.Item.ID,
MediaKind: mediaKind,
Completed: record.Played,
@@ -562,13 +562,13 @@ func shouldApplyPlexWebhookEvent(event string) bool {
}
}
func filterDiscoveredAccounts(accounts []historyimport.ExternalUser, mappings []ActorMapping) []historyimport.ExternalUser {
func filterDiscoveredAccounts(accounts []historyimport.ExternalUser, mappings []ProfileMapping) []historyimport.ExternalUser {
if len(accounts) == 0 {
return nil
}
mappedIDs := make(map[string]struct{}, len(mappings))
for _, mapping := range mappings {
mappedIDs[mapping.ExternalActorID] = struct{}{}
mappedIDs[mapping.ExternalUserID] = struct{}{}
}
filtered := make([]historyimport.ExternalUser, 0, len(accounts))
for _, account := range accounts {
+2 -2
View File
@@ -45,8 +45,8 @@ func TestEmbyProviderParseWebhook(t *testing.T) {
if event.Action != ActionImportProgress {
t.Fatalf("unexpected action: %q", event.Action)
}
if event.ActorID != "user-1" || event.ActorName != "Alice" {
t.Fatalf("unexpected actor: %#v", event)
if event.UserID != "user-1" || event.UserName != "Alice" {
t.Fatalf("unexpected external user: %#v", event)
}
if event.Record.Kind != "episode" || event.Record.SeriesTitle != "The Show" {
t.Fatalf("unexpected record: %#v", event.Record)
+47 -47
View File
@@ -50,9 +50,9 @@ func (r *Repository) ListConnections(ctx context.Context, userID int) ([]Connect
c.webhook_secret, c.account_discovery_available,
c.last_webhook_received_at, c.last_webhook_error_at, COALESCE(c.last_webhook_error_message, ''),
c.created_at, c.updated_at,
COUNT(a.id)::integer AS actor_count
COUNT(m.id)::integer AS user_count
FROM webhook_sync_connections c
LEFT JOIN webhook_sync_actor_mappings a ON a.connection_id = c.id
LEFT JOIN webhook_sync_profile_mappings m ON m.connection_id = c.id
WHERE c.user_id = $1
GROUP BY c.id
ORDER BY c.created_at DESC`, userID)
@@ -68,7 +68,7 @@ func (r *Repository) ListConnections(ctx context.Context, userID int) ([]Connect
&c.ID, &c.UserID, &c.Provider, &c.ServerID, &c.ServerName, &c.BaseURL, &c.AccessToken, &c.DefaultProfileID,
&c.WebhookSecret, &c.AccountDiscoveryAvailable,
&c.LastWebhookReceivedAt, &c.LastWebhookErrorAt, &c.LastWebhookErrorMessage,
&c.CreatedAt, &c.UpdatedAt, &c.ActorCount,
&c.CreatedAt, &c.UpdatedAt, &c.UserCount,
); err != nil {
return nil, fmt.Errorf("scanning webhook sync connection: %w", err)
}
@@ -170,22 +170,22 @@ func (r *Repository) SetDiscoveryAvailable(ctx context.Context, connectionID str
return nil
}
func (r *Repository) ListMappings(ctx context.Context, connectionID string) ([]ActorMapping, error) {
func (r *Repository) ListMappings(ctx context.Context, connectionID string) ([]ProfileMapping, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, connection_id, external_actor_id, external_actor_name, silo_profile_id,
SELECT id, connection_id, external_user_id, external_user_name, silo_profile_id,
last_seen_at, created_at, updated_at
FROM webhook_sync_actor_mappings
FROM webhook_sync_profile_mappings
WHERE connection_id = $1
ORDER BY external_actor_name ASC, id ASC`, connectionID)
ORDER BY external_user_name ASC, id ASC`, connectionID)
if err != nil {
return nil, fmt.Errorf("listing webhook sync mappings: %w", err)
}
defer rows.Close()
var out []ActorMapping
var out []ProfileMapping
for rows.Next() {
var m ActorMapping
var m ProfileMapping
if err := rows.Scan(
&m.ID, &m.ConnectionID, &m.ExternalActorID, &m.ExternalActorName, &m.SiloProfileID,
&m.ID, &m.ConnectionID, &m.ExternalUserID, &m.ExternalUserName, &m.SiloProfileID,
&m.LastSeenAt, &m.CreatedAt, &m.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scanning webhook sync mapping: %w", err)
@@ -198,15 +198,15 @@ func (r *Repository) ListMappings(ctx context.Context, connectionID string) ([]A
return out, nil
}
func (r *Repository) GetMappingByActor(ctx context.Context, connectionID, actorID string) (*ActorMapping, error) {
func (r *Repository) GetMappingByUser(ctx context.Context, connectionID, externalUserID string) (*ProfileMapping, error) {
row := r.pool.QueryRow(ctx, `
SELECT id, connection_id, external_actor_id, external_actor_name, silo_profile_id,
SELECT id, connection_id, external_user_id, external_user_name, silo_profile_id,
last_seen_at, created_at, updated_at
FROM webhook_sync_actor_mappings
WHERE connection_id = $1 AND external_actor_id = $2`, connectionID, actorID)
var m ActorMapping
FROM webhook_sync_profile_mappings
WHERE connection_id = $1 AND external_user_id = $2`, connectionID, externalUserID)
var m ProfileMapping
if err := row.Scan(
&m.ID, &m.ConnectionID, &m.ExternalActorID, &m.ExternalActorName, &m.SiloProfileID,
&m.ID, &m.ConnectionID, &m.ExternalUserID, &m.ExternalUserName, &m.SiloProfileID,
&m.LastSeenAt, &m.CreatedAt, &m.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -217,30 +217,30 @@ func (r *Repository) GetMappingByActor(ctx context.Context, connectionID, actorI
return &m, nil
}
func (r *Repository) ReplaceMappings(ctx context.Context, connectionID string, mappings []UpdateActorMapping) ([]ActorMapping, error) {
func (r *Repository) ReplaceMappings(ctx context.Context, connectionID string, mappings []UpdateProfileMapping) ([]ProfileMapping, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin replace webhook mappings: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
if _, err := tx.Exec(ctx, `DELETE FROM webhook_sync_actor_mappings WHERE connection_id = $1`, connectionID); err != nil {
if _, err := tx.Exec(ctx, `DELETE FROM webhook_sync_profile_mappings WHERE connection_id = $1`, connectionID); err != nil {
return nil, fmt.Errorf("deleting webhook sync mappings: %w", err)
}
out := make([]ActorMapping, 0, len(mappings))
out := make([]ProfileMapping, 0, len(mappings))
for _, input := range mappings {
row := tx.QueryRow(ctx, `
INSERT INTO webhook_sync_actor_mappings (
connection_id, external_actor_id, external_actor_name, silo_profile_id, last_seen_at
INSERT INTO webhook_sync_profile_mappings (
connection_id, external_user_id, external_user_name, silo_profile_id, last_seen_at
) VALUES ($1, $2, $3, $4, NOW())
RETURNING id, connection_id, external_actor_id, external_actor_name, silo_profile_id,
RETURNING id, connection_id, external_user_id, external_user_name, silo_profile_id,
last_seen_at, created_at, updated_at`,
connectionID, input.ExternalActorID, input.ExternalActorName, input.SiloProfileID,
connectionID, input.ExternalUserID, input.ExternalUserName, input.SiloProfileID,
)
var m ActorMapping
var m ProfileMapping
if err := row.Scan(
&m.ID, &m.ConnectionID, &m.ExternalActorID, &m.ExternalActorName, &m.SiloProfileID,
&m.ID, &m.ConnectionID, &m.ExternalUserID, &m.ExternalUserName, &m.SiloProfileID,
&m.LastSeenAt, &m.CreatedAt, &m.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("creating webhook sync mapping: %w", err)
@@ -254,18 +254,18 @@ func (r *Repository) ReplaceMappings(ctx context.Context, connectionID string, m
return out, nil
}
func (r *Repository) CreateDefaultMapping(ctx context.Context, connectionID, actorID, actorName, profileID string) (*ActorMapping, error) {
func (r *Repository) CreateDefaultMapping(ctx context.Context, connectionID, externalUserID, externalUserName, profileID string) (*ProfileMapping, error) {
row := r.pool.QueryRow(ctx, `
INSERT INTO webhook_sync_actor_mappings (
connection_id, external_actor_id, external_actor_name, silo_profile_id, last_seen_at
INSERT INTO webhook_sync_profile_mappings (
connection_id, external_user_id, external_user_name, silo_profile_id, last_seen_at
) VALUES ($1, $2, $3, $4, NOW())
RETURNING id, connection_id, external_actor_id, external_actor_name, silo_profile_id,
RETURNING id, connection_id, external_user_id, external_user_name, silo_profile_id,
last_seen_at, created_at, updated_at`,
connectionID, actorID, actorName, &profileID,
connectionID, externalUserID, externalUserName, &profileID,
)
var m ActorMapping
var m ProfileMapping
if err := row.Scan(
&m.ID, &m.ConnectionID, &m.ExternalActorID, &m.ExternalActorName, &m.SiloProfileID,
&m.ID, &m.ConnectionID, &m.ExternalUserID, &m.ExternalUserName, &m.SiloProfileID,
&m.LastSeenAt, &m.CreatedAt, &m.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("creating default webhook mapping: %w", err)
@@ -273,34 +273,34 @@ func (r *Repository) CreateDefaultMapping(ctx context.Context, connectionID, act
return &m, nil
}
func (r *Repository) UpsertSeenActor(ctx context.Context, connectionID, actorID, actorName string) error {
func (r *Repository) UpsertSeenUser(ctx context.Context, connectionID, externalUserID, externalUserName string) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO webhook_sync_actor_mappings (
connection_id, external_actor_id, external_actor_name, last_seen_at
INSERT INTO webhook_sync_profile_mappings (
connection_id, external_user_id, external_user_name, last_seen_at
) VALUES ($1, $2, $3, NOW())
ON CONFLICT (connection_id, external_actor_id) DO UPDATE SET
external_actor_name = EXCLUDED.external_actor_name,
ON CONFLICT (connection_id, external_user_id) DO UPDATE SET
external_user_name = EXCLUDED.external_user_name,
last_seen_at = NOW(),
updated_at = NOW()`,
connectionID, actorID, actorName,
connectionID, externalUserID, externalUserName,
)
if err != nil {
return fmt.Errorf("upserting seen actor: %w", err)
return fmt.Errorf("upserting seen external user: %w", err)
}
return nil
}
func (r *Repository) GetItemState(ctx context.Context, connectionID, externalActorID, externalItemID string) (*ItemState, error) {
func (r *Repository) GetItemState(ctx context.Context, connectionID, externalUserID, externalItemID string) (*ItemState, error) {
row := r.pool.QueryRow(ctx, `
SELECT connection_id, external_actor_id, external_item_id, COALESCE(media_item_id, ''),
SELECT connection_id, external_user_id, external_item_id, COALESCE(media_item_id, ''),
last_event_at, last_completed, last_position_seconds, updated_at
FROM webhook_sync_item_state
WHERE connection_id = $1 AND external_actor_id = $2 AND external_item_id = $3`,
connectionID, externalActorID, externalItemID,
WHERE connection_id = $1 AND external_user_id = $2 AND external_item_id = $3`,
connectionID, externalUserID, externalItemID,
)
var state ItemState
if err := row.Scan(
&state.ConnectionID, &state.ExternalActorID, &state.ExternalItemID, &state.MediaItemID,
&state.ConnectionID, &state.ExternalUserID, &state.ExternalItemID, &state.MediaItemID,
&state.LastEventAt, &state.LastCompleted, &state.LastPositionSecond, &state.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -314,16 +314,16 @@ func (r *Repository) GetItemState(ctx context.Context, connectionID, externalAct
func (r *Repository) UpsertItemState(ctx context.Context, state ItemState) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO webhook_sync_item_state (
connection_id, external_actor_id, external_item_id, media_item_id,
connection_id, external_user_id, external_item_id, media_item_id,
last_event_at, last_completed, last_position_seconds, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
ON CONFLICT (connection_id, external_actor_id, external_item_id) DO UPDATE SET
ON CONFLICT (connection_id, external_user_id, external_item_id) DO UPDATE SET
media_item_id = EXCLUDED.media_item_id,
last_event_at = EXCLUDED.last_event_at,
last_completed = EXCLUDED.last_completed,
last_position_seconds = EXCLUDED.last_position_seconds,
updated_at = NOW()`,
state.ConnectionID, state.ExternalActorID, state.ExternalItemID, state.MediaItemID,
state.ConnectionID, state.ExternalUserID, state.ExternalItemID, state.MediaItemID,
state.LastEventAt, state.LastCompleted, state.LastPositionSecond,
)
if err != nil {
+28 -28
View File
@@ -64,7 +64,7 @@ func (s *Service) ListConnections(ctx context.Context, userID int) ([]Connection
if providerErr != nil {
continue
}
if _, available, err := provider.DiscoverActors(ctx, &connections[i], nil); err == nil {
if _, available, err := provider.DiscoverUsers(ctx, &connections[i], nil); err == nil {
connections[i].AccountDiscoveryAvailable = available
if err := s.repo.SetDiscoveryAvailable(ctx, connections[i].ID, available); err != nil {
slog.Warn("webhook sync: failed to persist discovery availability", "connection_id", connections[i].ID, "error", err)
@@ -107,10 +107,10 @@ func (s *Service) CreateConnection(ctx context.Context, userID int, input Create
if err != nil {
return nil, err
}
if actorID, actorName, ok, err := provider.DefaultActor(ctx, conn, input); err != nil {
if userID, userName, ok, err := provider.DefaultUser(ctx, conn, input); err != nil {
return nil, err
} else if ok {
if _, err := s.repo.CreateDefaultMapping(ctx, conn.ID, actorID, actorName, input.DefaultProfileID); err != nil {
if _, err := s.repo.CreateDefaultMapping(ctx, conn.ID, userID, userName, input.DefaultProfileID); err != nil {
return nil, err
}
}
@@ -152,7 +152,7 @@ func (s *Service) RotateWebhook(ctx context.Context, userID int, id, baseURL str
return &RotateWebhookResult{WebhookURL: buildWebhookURL(baseURL, secret)}, nil
}
func (s *Service) GetActors(ctx context.Context, userID int, id string) (*ActorMappingsResponse, error) {
func (s *Service) GetProfileMappings(ctx context.Context, userID int, id string) (*ProfileMappingsResponse, error) {
conn, err := s.repo.GetConnection(ctx, userID, id)
if err != nil {
return nil, err
@@ -165,24 +165,24 @@ func (s *Service) GetActors(ctx context.Context, userID int, id string) (*ActorM
if err != nil {
return nil, err
}
discovered, available, discoverErr := provider.DiscoverActors(ctx, conn, mappings)
discovered, available, discoverErr := provider.DiscoverUsers(ctx, conn, mappings)
if discoverErr == nil {
conn.AccountDiscoveryAvailable = available
_ = s.repo.SetDiscoveryAvailable(ctx, conn.ID, available)
} else {
discovered = mappingsToDiscoveredActors(mappings)
discovered = mappingsToDiscoveredUsers(mappings)
}
if len(discovered) == 0 {
discovered = mappingsToDiscoveredActors(mappings)
discovered = mappingsToDiscoveredUsers(mappings)
}
return &ActorMappingsResponse{
return &ProfileMappingsResponse{
Mappings: mappings,
DiscoveredActors: discovered,
DiscoveredUsers: discovered,
AccountDiscoveryAvailable: conn.AccountDiscoveryAvailable,
}, nil
}
func (s *Service) UpdateActors(ctx context.Context, userID int, id string, input UpdateActorMappingsInput) ([]ActorMapping, error) {
func (s *Service) UpdateProfileMappings(ctx context.Context, userID int, id string, input UpdateProfileMappingsInput) ([]ProfileMapping, error) {
conn, err := s.repo.GetConnection(ctx, userID, id)
if err != nil {
return nil, err
@@ -238,8 +238,8 @@ func (s *Service) ProcessWebhook(ctx context.Context, secret string, r *http.Req
if event != nil {
result.EventKind = event.EventKind
result.Action = event.Action
result.ActorID = event.ActorID
result.ActorName = event.ActorName
result.UserID = event.UserID
result.UserName = event.UserName
result.ExternalItemID = event.ExternalItemID
result.MediaKind = event.MediaKind
}
@@ -251,20 +251,20 @@ func (s *Service) ProcessWebhook(ctx context.Context, secret string, r *http.Req
result.Summary = webhookResultSummary(event, "Ignored unsupported webhook event")
return result, nil
}
if err := s.repo.UpsertSeenActor(ctx, conn.ID, event.ActorID, event.ActorName); err != nil {
slog.Warn("webhook sync: failed to upsert seen actor", "connection_id", conn.ID, "actor_id", event.ActorID, "error", err)
if err := s.repo.UpsertSeenUser(ctx, conn.ID, event.UserID, event.UserName); err != nil {
slog.Warn("webhook sync: failed to upsert seen external user", "connection_id", conn.ID, "external_user_id", event.UserID, "error", err)
}
profileID := conn.DefaultProfileID
if mapping, err := s.repo.GetMappingByActor(ctx, conn.ID, event.ActorID); err != nil {
return s.failWebhook(ctx, conn.ID, result, err, "Failed to resolve actor routing")
if mapping, err := s.repo.GetMappingByUser(ctx, conn.ID, event.UserID); err != nil {
return s.failWebhook(ctx, conn.ID, result, err, "Failed to resolve profile mapping")
} else if mapping != nil && mapping.SiloProfileID != nil && *mapping.SiloProfileID != "" {
profileID = *mapping.SiloProfileID
}
result.ProfileID = profileID
if profileID == "" {
result.Outcome = OutcomeSkipped
result.Summary = "Skipped because no default or actor-specific profile is configured"
result.Summary = "Skipped because no default or user-specific profile is configured"
return result, nil
}
@@ -283,7 +283,7 @@ func (s *Service) ProcessWebhook(ctx context.Context, secret string, r *http.Req
switch event.Action {
case ActionMarkUnplayed:
if state, err := s.repo.GetItemState(ctx, conn.ID, event.ActorID, event.ExternalItemID); err != nil {
if state, err := s.repo.GetItemState(ctx, conn.ID, event.UserID, event.ExternalItemID); err != nil {
return s.failWebhook(ctx, conn.ID, result, err, "Failed to load existing item state")
} else if state != nil && !event.OccurredAt.After(state.LastEventAt) {
result.Outcome = OutcomeSkipped
@@ -295,7 +295,7 @@ func (s *Service) ProcessWebhook(ctx context.Context, secret string, r *http.Req
}
if err := s.repo.UpsertItemState(ctx, ItemState{
ConnectionID: conn.ID,
ExternalActorID: event.ActorID,
ExternalUserID: event.UserID,
ExternalItemID: event.ExternalItemID,
MediaItemID: match.MediaItemID,
LastEventAt: event.OccurredAt,
@@ -342,7 +342,7 @@ func (s *Service) ProcessWebhook(ctx context.Context, secret string, r *http.Req
return result, nil
}
state, err := s.repo.GetItemState(ctx, conn.ID, event.ActorID, event.ExternalItemID)
state, err := s.repo.GetItemState(ctx, conn.ID, event.UserID, event.ExternalItemID)
if err != nil {
return s.failWebhook(ctx, conn.ID, result, err, "Failed to load existing item state")
}
@@ -369,7 +369,7 @@ func (s *Service) ProcessWebhook(ctx context.Context, secret string, r *http.Req
if err := s.repo.UpsertItemState(ctx, ItemState{
ConnectionID: conn.ID,
ExternalActorID: event.ActorID,
ExternalUserID: event.UserID,
ExternalItemID: event.ExternalItemID,
MediaItemID: match.MediaItemID,
LastEventAt: event.OccurredAt,
@@ -402,15 +402,15 @@ func webhookResultSummary(event *CanonicalEvent, fallback string) string {
return fallback
}
func mappingsToDiscoveredActors(mappings []ActorMapping) []DiscoveredActor {
func mappingsToDiscoveredUsers(mappings []ProfileMapping) []DiscoveredUser {
if len(mappings) == 0 {
return nil
}
out := make([]DiscoveredActor, 0, len(mappings))
out := make([]DiscoveredUser, 0, len(mappings))
for _, mapping := range mappings {
out = append(out, DiscoveredActor{
ExternalActorID: mapping.ExternalActorID,
ExternalActorName: mapping.ExternalActorName,
out = append(out, DiscoveredUser{
ExternalUserID: mapping.ExternalUserID,
ExternalUserName: mapping.ExternalUserName,
})
}
return out
@@ -481,7 +481,7 @@ func (r CanonicalRecord) toHistoryImportRecord() historyimport.Record {
type Provider interface {
ID() string
ValidateCreateInput(input CreateConnectionInput) error
DefaultActor(ctx context.Context, conn *Connection, input CreateConnectionInput) (actorID string, actorName string, ok bool, err error)
DiscoverActors(ctx context.Context, conn *Connection, mappings []ActorMapping) ([]DiscoveredActor, bool, error)
DefaultUser(ctx context.Context, conn *Connection, input CreateConnectionInput) (externalUserID string, externalUserName string, ok bool, err error)
DiscoverUsers(ctx context.Context, conn *Connection, mappings []ProfileMapping) ([]DiscoveredUser, bool, error)
ParseWebhook(ctx context.Context, conn *Connection, r *http.Request) (*CanonicalEvent, error)
}
+2 -2
View File
@@ -52,8 +52,8 @@ func TestFilterDiscoveredAccounts(t *testing.T) {
{ID: "3", Name: "Friend"},
{ID: "4", Name: "Mapped Friend"},
}
mappings := []ActorMapping{
{ExternalActorID: "4"},
mappings := []ProfileMapping{
{ExternalUserID: "4"},
}
filtered := filterDiscoveredAccounts(accounts, mappings)
+28 -28
View File
@@ -22,7 +22,7 @@ type Connection struct {
AccessToken string `json:"-"`
WebhookSecret string `json:"-"`
AccountDiscoveryAvailable bool `json:"account_discovery_available"`
ActorCount int `json:"actor_count"`
UserCount int `json:"user_count"`
LastWebhookReceivedAt *time.Time `json:"last_webhook_received_at,omitempty"`
LastWebhookErrorAt *time.Time `json:"last_webhook_error_at,omitempty"`
LastWebhookErrorMessage string `json:"last_webhook_error_message,omitempty"`
@@ -30,25 +30,25 @@ type Connection struct {
UpdatedAt time.Time `json:"updated_at"`
}
type ActorMapping struct {
ID int `json:"id"`
ConnectionID string `json:"connection_id"`
ExternalActorID string `json:"external_actor_id"`
ExternalActorName string `json:"external_actor_name"`
SiloProfileID *string `json:"silo_profile_id,omitempty"`
LastSeenAt time.Time `json:"last_seen_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
type ProfileMapping struct {
ID int `json:"id"`
ConnectionID string `json:"connection_id"`
ExternalUserID string `json:"external_user_id"`
ExternalUserName string `json:"external_user_name"`
SiloProfileID *string `json:"silo_profile_id,omitempty"`
LastSeenAt time.Time `json:"last_seen_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type DiscoveredActor struct {
ExternalActorID string `json:"external_actor_id"`
ExternalActorName string `json:"external_actor_name"`
type DiscoveredUser struct {
ExternalUserID string `json:"external_user_id"`
ExternalUserName string `json:"external_user_name"`
}
type ItemState struct {
ConnectionID string
ExternalActorID string
ExternalUserID string
ExternalItemID string
MediaItemID string
LastEventAt time.Time
@@ -80,20 +80,20 @@ type RotateWebhookResult struct {
WebhookURL string `json:"webhook_url"`
}
type ActorMappingsResponse struct {
Mappings []ActorMapping `json:"mappings"`
DiscoveredActors []DiscoveredActor `json:"discovered_actors"`
AccountDiscoveryAvailable bool `json:"account_discovery_available"`
type ProfileMappingsResponse struct {
Mappings []ProfileMapping `json:"mappings"`
DiscoveredUsers []DiscoveredUser `json:"discovered_users"`
AccountDiscoveryAvailable bool `json:"account_discovery_available"`
}
type UpdateActorMappingsInput struct {
Mappings []UpdateActorMapping `json:"mappings"`
type UpdateProfileMappingsInput struct {
Mappings []UpdateProfileMapping `json:"mappings"`
}
type UpdateActorMapping struct {
ExternalActorID string `json:"external_actor_id"`
ExternalActorName string `json:"external_actor_name"`
SiloProfileID *string `json:"silo_profile_id"`
type UpdateProfileMapping struct {
ExternalUserID string `json:"external_user_id"`
ExternalUserName string `json:"external_user_name"`
SiloProfileID *string `json:"silo_profile_id"`
}
type CanonicalEvent struct {
@@ -102,8 +102,8 @@ type CanonicalEvent struct {
OccurredAt time.Time
Action string
EventKind string
ActorID string
ActorName string
UserID string
UserName string
ExternalItemID string
MediaKind string
Completed bool
@@ -158,8 +158,8 @@ type ProcessWebhookResult struct {
Provider string
EventKind string
Action string
ActorID string
ActorName string
UserID string
UserName string
ExternalItemID string
MediaKind string
MatchedMediaItemID string
@@ -0,0 +1,30 @@
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'webhook_sync_actor_mappings'
AND column_name = 'silo_profile_id'
) AND NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'webhook_sync_actor_mappings'
AND column_name = 'continuum_profile_id'
) THEN
ALTER TABLE public.webhook_sync_actor_mappings
RENAME COLUMN silo_profile_id TO continuum_profile_id;
ELSIF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'webhook_sync_actor_mappings'
AND column_name = 'silo_profile_id'
) THEN
UPDATE public.webhook_sync_actor_mappings
SET continuum_profile_id = COALESCE(continuum_profile_id, silo_profile_id)
WHERE continuum_profile_id IS NULL
AND silo_profile_id IS NOT NULL;
END IF;
END $$;
@@ -0,0 +1,30 @@
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'webhook_sync_actor_mappings'
AND column_name = 'continuum_profile_id'
) AND NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'webhook_sync_actor_mappings'
AND column_name = 'silo_profile_id'
) THEN
ALTER TABLE public.webhook_sync_actor_mappings
RENAME COLUMN continuum_profile_id TO silo_profile_id;
ELSIF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'webhook_sync_actor_mappings'
AND column_name = 'continuum_profile_id'
) THEN
UPDATE public.webhook_sync_actor_mappings
SET silo_profile_id = COALESCE(silo_profile_id, continuum_profile_id)
WHERE silo_profile_id IS NULL
AND continuum_profile_id IS NOT NULL;
END IF;
END $$;
@@ -0,0 +1,26 @@
UPDATE public.webhook_sync_event_logs
SET attrs = (attrs - 'external_user_id' - 'external_user_name')
|| jsonb_strip_nulls(jsonb_build_object(
'actor_id', attrs->'external_user_id',
'actor_name', attrs->'external_user_name'
))
WHERE attrs ? 'external_user_id' OR attrs ? 'external_user_name';
ALTER TABLE public.webhook_sync_item_state
RENAME COLUMN external_user_id TO external_actor_id;
ALTER INDEX public.idx_webhook_sync_profile_mappings_profile
RENAME TO idx_webhook_sync_actor_mappings_profile;
ALTER INDEX public.idx_webhook_sync_profile_mappings_connection
RENAME TO idx_webhook_sync_actor_mappings_connection;
ALTER TABLE public.webhook_sync_profile_mappings
RENAME CONSTRAINT webhook_sync_profile_mappings_connection_user_unique
TO webhook_sync_actor_mappings_connection_actor_unique;
ALTER TABLE public.webhook_sync_profile_mappings
RENAME COLUMN external_user_name TO external_actor_name;
ALTER TABLE public.webhook_sync_profile_mappings
RENAME COLUMN external_user_id TO external_actor_id;
ALTER TABLE public.webhook_sync_profile_mappings RENAME TO webhook_sync_actor_mappings;
@@ -0,0 +1,26 @@
ALTER TABLE public.webhook_sync_actor_mappings RENAME TO webhook_sync_profile_mappings;
ALTER TABLE public.webhook_sync_profile_mappings
RENAME COLUMN external_actor_id TO external_user_id;
ALTER TABLE public.webhook_sync_profile_mappings
RENAME COLUMN external_actor_name TO external_user_name;
ALTER TABLE public.webhook_sync_profile_mappings
RENAME CONSTRAINT webhook_sync_actor_mappings_connection_actor_unique
TO webhook_sync_profile_mappings_connection_user_unique;
ALTER INDEX public.idx_webhook_sync_actor_mappings_connection
RENAME TO idx_webhook_sync_profile_mappings_connection;
ALTER INDEX public.idx_webhook_sync_actor_mappings_profile
RENAME TO idx_webhook_sync_profile_mappings_profile;
ALTER TABLE public.webhook_sync_item_state
RENAME COLUMN external_actor_id TO external_user_id;
UPDATE public.webhook_sync_event_logs
SET attrs = (attrs - 'actor_id' - 'actor_name')
|| jsonb_strip_nulls(jsonb_build_object(
'external_user_id', attrs->'actor_id',
'external_user_name', attrs->'actor_name'
))
WHERE attrs ? 'actor_id' OR attrs ? 'actor_name';
+13 -13
View File
@@ -235,7 +235,7 @@ export interface WebhookSyncConnection {
server_name: string;
default_profile_id: string;
webhook_url?: string;
actor_count?: number;
user_count?: number;
account_discovery_available?: boolean;
last_webhook_received_at?: string | null;
last_webhook_error_at?: string | null;
@@ -257,25 +257,25 @@ export interface WebhookSyncEventLog {
attrs?: Record<string, unknown>;
}
export interface WebhookSyncActorMapping {
export interface WebhookSyncProfileMapping {
id: number;
connection_id?: string;
external_actor_id: string;
external_actor_name: string;
external_user_id: string;
external_user_name: string;
silo_profile_id?: string | null;
last_seen_at?: string;
created_at?: string;
updated_at?: string;
}
export interface WebhookSyncDiscoveredActor {
external_actor_id: string;
external_actor_name: string;
export interface WebhookSyncDiscoveredUser {
external_user_id: string;
external_user_name: string;
}
export interface WebhookSyncActorsResponse {
mappings: WebhookSyncActorMapping[];
discovered_actors?: WebhookSyncDiscoveredActor[];
export interface WebhookSyncProfileMappingsResponse {
mappings: WebhookSyncProfileMapping[];
discovered_users?: WebhookSyncDiscoveredUser[];
account_discovery_available?: boolean;
}
@@ -308,10 +308,10 @@ export interface UpdateWebhookSyncConnectionRequest {
default_profile_id?: string;
}
export interface UpdateWebhookSyncActorsRequest {
export interface UpdateWebhookSyncProfileMappingsRequest {
mappings: Array<{
external_actor_id: string;
external_actor_name: string;
external_user_id: string;
external_user_name: string;
silo_profile_id: string | null;
}>;
}
+2 -1
View File
@@ -204,7 +204,8 @@ export const webhookSyncKeys = {
all: ["webhook-sync"] as const,
connections: () => ["webhook-sync", "connections"] as const,
events: (connectionId?: string) => ["webhook-sync", "events", connectionId] as const,
actors: (connectionId?: string) => ["webhook-sync", "actors", connectionId] as const,
profileMappings: (connectionId?: string) =>
["webhook-sync", "profile-mappings", connectionId] as const,
connection: (connectionId?: string) => ["webhook-sync", "connection", connectionId] as const,
};
+20 -18
View File
@@ -4,11 +4,11 @@ import type {
CreateWebhookSyncConnectionRequest,
CreateWebhookSyncConnectionResponse,
RotateWebhookSyncWebhookResponse,
UpdateWebhookSyncActorsRequest,
UpdateWebhookSyncConnectionRequest,
WebhookSyncActorsResponse,
UpdateWebhookSyncProfileMappingsRequest,
WebhookSyncConnection,
WebhookSyncEventLog,
WebhookSyncProfileMappingsResponse,
} from "@/api/types";
import { webhookSyncKeys } from "./keys";
import { toast } from "sonner";
@@ -32,7 +32,9 @@ export function useCreateWebhookSyncConnection() {
onSuccess: (result) => {
toast.success("Webhook connection created");
queryClient.invalidateQueries({ queryKey: webhookSyncKeys.connections() });
queryClient.invalidateQueries({ queryKey: webhookSyncKeys.actors(result.connection.id) });
queryClient.invalidateQueries({
queryKey: webhookSyncKeys.profileMappings(result.connection.id),
});
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to create webhook connection");
@@ -105,17 +107,17 @@ export function useRotateWebhookSyncWebhook() {
});
}
export function useWebhookSyncActors(connectionId?: string) {
export function useWebhookSyncProfileMappings(connectionId?: string) {
return useQuery({
queryKey: webhookSyncKeys.actors(connectionId),
queryKey: webhookSyncKeys.profileMappings(connectionId),
queryFn: () =>
api<WebhookSyncActorsResponse>(`/webhook-sync/connections/${connectionId}/actors`).then(
(d) => ({
mappings: d?.mappings ?? [],
discovered_actors: d?.discovered_actors ?? [],
account_discovery_available: d?.account_discovery_available ?? false,
}),
),
api<WebhookSyncProfileMappingsResponse>(
`/webhook-sync/connections/${connectionId}/profile-mappings`,
).then((d) => ({
mappings: d?.mappings ?? [],
discovered_users: d?.discovered_users ?? [],
account_discovery_available: d?.account_discovery_available ?? false,
})),
enabled: !!connectionId,
staleTime: 10_000,
});
@@ -134,7 +136,7 @@ export function useWebhookSyncEvents(connectionId?: string) {
});
}
export function useUpdateWebhookSyncActors() {
export function useUpdateWebhookSyncProfileMappings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
@@ -142,21 +144,21 @@ export function useUpdateWebhookSyncActors() {
body,
}: {
connectionId: string;
body: UpdateWebhookSyncActorsRequest;
body: UpdateWebhookSyncProfileMappingsRequest;
}) =>
api(`/webhook-sync/connections/${connectionId}/actors`, {
api(`/webhook-sync/connections/${connectionId}/profile-mappings`, {
method: "PUT",
body: JSON.stringify(body),
}),
onSuccess: (_, variables) => {
toast.success("Actor routing saved");
toast.success("Profile mappings saved");
queryClient.invalidateQueries({
queryKey: webhookSyncKeys.actors(variables.connectionId),
queryKey: webhookSyncKeys.profileMappings(variables.connectionId),
});
queryClient.invalidateQueries({ queryKey: webhookSyncKeys.connections() });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to save actor routing");
toast.error(err instanceof Error ? err.message : "Failed to save profile mappings");
},
});
}
+225 -104
View File
@@ -48,20 +48,20 @@ import {
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import type {
WebhookSyncActorMapping,
WebhookSyncConnection,
WebhookSyncDiscoveredActor,
WebhookSyncDiscoveredUser,
WebhookSyncEventLog,
WebhookSyncProfileMapping,
} from "@/api/types";
import {
useCreateWebhookSyncConnection,
useDeleteWebhookSyncConnection,
useRotateWebhookSyncWebhook,
useUpdateWebhookSyncConnection,
useUpdateWebhookSyncActors,
useWebhookSyncActors,
useUpdateWebhookSyncProfileMappings,
useWebhookSyncConnections,
useWebhookSyncEvents,
useWebhookSyncProfileMappings,
} from "@/hooks/queries/webhook-sync";
import {
buildPlexAuthURL,
@@ -190,11 +190,11 @@ function eventAttrString(event: WebhookSyncEventLog, key: string) {
return "";
}
function eventActorLabel(event: WebhookSyncEventLog) {
const actorName = eventAttrString(event, "actor_name");
const actorID = eventAttrString(event, "actor_id");
if (actorName && actorID) return `${actorName} (${actorID})`;
return actorName || actorID || "Unknown actor";
function eventUserLabel(event: WebhookSyncEventLog) {
const userName = eventAttrString(event, "external_user_name");
const userID = eventAttrString(event, "external_user_id");
if (userName && userID) return `${userName} (${userID})`;
return userName || userID || "Unknown user";
}
function eventMatchedItemLabel(event: WebhookSyncEventLog) {
@@ -204,8 +204,8 @@ function eventMatchedItemLabel(event: WebhookSyncEventLog) {
const EVENT_ATTR_LABELS: Record<string, string> = {
event_kind: "Event kind",
action: "Action",
actor_id: "Actor ID",
actor_name: "Actor",
external_user_id: "External user ID",
external_user_name: "External user",
external_item_id: "External item ID",
media_kind: "Media kind",
matched_media_item_id: "Matched item ID",
@@ -224,6 +224,46 @@ function eventAttrLabel(key: string) {
type ProviderType = "plex" | "emby" | "jellyfin";
type ConnectionDraft = { serverName: string; defaultProfileId: string };
type EventMatrixTone = "required" | "recommended" | "skip";
type EventMatrixSection = {
label: string;
tone: EventMatrixTone;
items: { event: string; note: string }[];
};
const EVENT_MATRIX_TONE: Record<EventMatrixTone, string> = {
required: "text-emerald-300",
recommended: "text-sky-300",
skip: "text-muted-foreground",
};
function EventMatrix({ sections }: { sections: EventMatrixSection[] }) {
return (
<div className="border-border/50 divide-border/50 divide-y rounded-md border">
{sections.map((section) => (
<div key={section.label} className="space-y-1.5 px-3 py-2.5">
<p
className={cn(
"text-[11px] font-medium tracking-wide uppercase",
EVENT_MATRIX_TONE[section.tone],
)}
>
{section.label}
</p>
<ul className="space-y-1">
{section.items.map((item) => (
<li key={item.event} className="text-[13px] leading-relaxed">
<span className="text-foreground font-medium">{item.event}</span>
<span className="text-muted-foreground"> — {item.note}</span>
</li>
))}
</ul>
</div>
))}
</div>
);
}
export default function WebhookSyncSettings() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@@ -234,7 +274,7 @@ export default function WebhookSyncSettings() {
const deleteConnectionMutation = useDeleteWebhookSyncConnection();
const rotateWebhookMutation = useRotateWebhookSyncWebhook();
const updateConnectionMutation = useUpdateWebhookSyncConnection();
const updateActorsMutation = useUpdateWebhookSyncActors();
const updateMappingsMutation = useUpdateWebhookSyncProfileMappings();
const [provider, setProvider] = useState<ProviderType>("plex");
const [manualServerName, setManualServerName] = useState("");
@@ -246,7 +286,7 @@ export default function WebhookSyncSettings() {
const [connectionDraftsById, setConnectionDraftsById] = useState<Record<string, ConnectionDraft>>(
{},
);
const [actorDraftsByConnection, setActorDraftsByConnection] = useState<
const [mappingDraftsByConnection, setMappingDraftsByConnection] = useState<
Record<string, Record<string, string>>
>({});
const [plexAuthPending, setPlexAuthPending] = useState(false);
@@ -265,11 +305,11 @@ export default function WebhookSyncSettings() {
}, [connections, selectedConnectionId]);
const currentConnectionId = selectedConnection?.id ?? "";
const actorsQuery = useWebhookSyncActors(currentConnectionId || undefined);
const mappingsQuery = useWebhookSyncProfileMappings(currentConnectionId || undefined);
const eventsQuery = useWebhookSyncEvents(currentConnectionId || undefined);
const effectiveSelectedServerId = selectedServerId || plexServers[0]?.clientIdentifier || "";
const effectiveDefaultProfileId = defaultProfileId || profile?.id || profiles[0]?.id || "";
const currentActorDrafts = actorDraftsByConnection[currentConnectionId] ?? {};
const currentMappingDrafts = mappingDraftsByConnection[currentConnectionId] ?? {};
const currentConnectionDraft = currentConnectionId
? connectionDraftsById[currentConnectionId]
: undefined;
@@ -279,36 +319,36 @@ export default function WebhookSyncSettings() {
[effectiveSelectedServerId, plexServers],
);
const actorRows = useMemo(() => {
const discoveredActors = actorsQuery.data?.discovered_actors ?? [];
const mappings = actorsQuery.data?.mappings ?? [];
const byActorId = new Map<
const mappingRows = useMemo(() => {
const discoveredUsers = mappingsQuery.data?.discovered_users ?? [];
const mappings = mappingsQuery.data?.mappings ?? [];
const byUserId = new Map<
string,
{ actor: WebhookSyncDiscoveredActor; mapping?: WebhookSyncActorMapping }
{ user: WebhookSyncDiscoveredUser; mapping?: WebhookSyncProfileMapping }
>();
for (const actor of discoveredActors) {
byActorId.set(actor.external_actor_id, { actor });
for (const user of discoveredUsers) {
byUserId.set(user.external_user_id, { user });
}
for (const mapping of mappings) {
const existing = byActorId.get(mapping.external_actor_id);
byActorId.set(mapping.external_actor_id, {
actor: existing?.actor ?? {
external_actor_id: mapping.external_actor_id,
external_actor_name: mapping.external_actor_name,
const existing = byUserId.get(mapping.external_user_id);
byUserId.set(mapping.external_user_id, {
user: existing?.user ?? {
external_user_id: mapping.external_user_id,
external_user_name: mapping.external_user_name,
},
mapping,
});
}
return Array.from(byActorId.values())
.map(({ actor, mapping }) => ({
external_actor_id: actor.external_actor_id,
external_actor_name: actor.external_actor_name,
return Array.from(byUserId.values())
.map(({ user, mapping }) => ({
external_user_id: user.external_user_id,
external_user_name: user.external_user_name,
silo_profile_id: mapping?.silo_profile_id ?? "",
}))
.sort((a, b) => a.external_actor_name.localeCompare(b.external_actor_name));
}, [actorsQuery.data]);
.sort((a, b) => a.external_user_name.localeCompare(b.external_user_name));
}, [mappingsQuery.data]);
const EVENTS_PER_PAGE = 15;
@@ -322,7 +362,7 @@ export default function WebhookSyncSettings() {
events = events.filter(
(e) =>
e.summary.toLowerCase().includes(q) ||
eventActorLabel(e).toLowerCase().includes(q) ||
eventUserLabel(e).toLowerCase().includes(q) ||
(e.error_message ?? "").toLowerCase().includes(q),
);
}
@@ -485,19 +525,19 @@ export default function WebhookSyncSettings() {
setSelectedConnectionId(result.connection.id);
}
async function handleSaveActors() {
async function handleSaveMappings() {
if (!currentConnectionId) {
return;
}
await updateActorsMutation.mutateAsync({
await updateMappingsMutation.mutateAsync({
connectionId: currentConnectionId,
body: {
mappings: actorRows.map((row) => {
const value = currentActorDrafts[row.external_actor_id] ?? row.silo_profile_id;
mappings: mappingRows.map((row) => {
const value = currentMappingDrafts[row.external_user_id] ?? row.silo_profile_id;
return {
external_actor_id: row.external_actor_id,
external_actor_name: row.external_actor_name,
external_user_id: row.external_user_id,
external_user_name: row.external_user_name,
silo_profile_id: !value || value === UNMAPPED_VALUE ? null : value,
};
}),
@@ -505,15 +545,15 @@ export default function WebhookSyncSettings() {
});
}
function setDraftProfile(actorId: string, profileId: string) {
function setDraftProfile(externalUserId: string, profileId: string) {
if (!currentConnectionId) {
return;
}
setActorDraftsByConnection((current) => ({
setMappingDraftsByConnection((current) => ({
...current,
[currentConnectionId]: {
...(current[currentConnectionId] ?? {}),
[actorId]: profileId,
[externalUserId]: profileId,
},
}));
}
@@ -678,7 +718,7 @@ export default function WebhookSyncSettings() {
{/* ── Connected servers ── */}
<SettingsGroup
title="Connected servers"
description="Manage webhook endpoints, provider setup instructions, and actor routing."
description="Manage webhook endpoints, provider setup instructions, and profile mappings."
>
{connectionsQuery.isLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
@@ -724,8 +764,8 @@ export default function WebhookSyncSettings() {
</span>
</span>
<p className="text-muted-foreground text-xs">
{connection.actor_count ?? 0} actor
{(connection.actor_count ?? 0) === 1 ? "" : "s"}
{connection.user_count ?? 0} user
{(connection.user_count ?? 0) === 1 ? "" : "s"}
{connection.last_webhook_received_at
? ` · last event ${relativeTime(connection.last_webhook_received_at) ?? formatTimestamp(connection.last_webhook_received_at)}`
: ""}
@@ -896,51 +936,51 @@ export default function WebhookSyncSettings() {
</div>
</div>
{/* Actor routing — part of connection config */}
{/* Profile mapping — part of connection config */}
<div className="border-border/50 space-y-3 border-t pt-4">
<div className="space-y-0.5">
<Label className="text-sm font-medium">Actor routing</Label>
<Label className="text-sm font-medium">Profile mapping</Label>
<p className="text-muted-foreground text-[13px] leading-relaxed">
Map external users to Silo profiles. Unmapped users fall back to the default
profile above.
Map each external user to a Silo profile. Unmapped users fall back to the
default profile above.
</p>
</div>
{actorsQuery.isLoading ? (
{mappingsQuery.isLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Loading actors...
Loading users...
</div>
) : actorsQuery.isError ? (
) : mappingsQuery.isError ? (
<div className="flex items-start gap-2.5 rounded-md bg-red-500/10 px-3 py-2.5">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-400" />
<p className="text-sm leading-relaxed text-red-300">
{actorsQuery.error instanceof Error
? actorsQuery.error.message
: "Failed to load actor routing"}
{mappingsQuery.error instanceof Error
? mappingsQuery.error.message
: "Failed to load profile mappings"}
</p>
</div>
) : actorRows.length > 0 ? (
) : mappingRows.length > 0 ? (
<div className="space-y-3">
{actorRows.map((row) => (
{mappingRows.map((row) => (
<div
key={row.external_actor_id}
key={row.external_user_id}
className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<p className="text-sm font-medium">{row.external_actor_name}</p>
<p className="text-sm font-medium">{row.external_user_name}</p>
<p className="text-muted-foreground truncate text-xs">
{row.external_actor_id}
{row.external_user_id}
</p>
</div>
<Select
value={
currentActorDrafts[row.external_actor_id] ??
currentMappingDrafts[row.external_user_id] ??
row.silo_profile_id ??
UNMAPPED_VALUE
}
onValueChange={(value) =>
setDraftProfile(row.external_actor_id, value)
setDraftProfile(row.external_user_id, value)
}
>
<SelectTrigger className="w-full sm:w-[240px]">
@@ -960,19 +1000,19 @@ export default function WebhookSyncSettings() {
<div className="flex justify-end">
<Button
onClick={handleSaveActors}
disabled={updateActorsMutation.isPending}
onClick={handleSaveMappings}
disabled={updateMappingsMutation.isPending}
>
{updateActorsMutation.isPending ? (
{updateMappingsMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : null}
Save routing
Save mappings
</Button>
</div>
</div>
) : (
<p className="text-muted-foreground text-sm">
No actors discovered yet. Send a webhook event first, then map users here.
No users discovered yet. Send a webhook event first, then map them here.
</p>
)}
</div>
@@ -985,47 +1025,128 @@ export default function WebhookSyncSettings() {
</summary>
<div className="mt-3">
{selectedConnection.provider === "plex" ? (
<p className="text-muted-foreground text-sm leading-relaxed">
In Plex Media Server, add the webhook URL above under server webhook
settings. Existing `/api/v1/plex-sync/webhooks/...` endpoints remain
valid.
</p>
<ol className="text-muted-foreground list-decimal space-y-1.5 pl-5 text-sm leading-relaxed">
<li>
In Plex, open{" "}
<span className="text-foreground">Settings → Webhooks</span> (Plex Pass
required).
</li>
<li>
Click <span className="text-foreground">Add Webhook</span> and paste the
URL above.
</li>
<li>
Save. Plex sends events automatically — no per-event toggles to
configure.
</li>
</ol>
) : null}
{selectedConnection.provider === "emby" ? (
<div className="text-muted-foreground space-y-1 text-sm leading-relaxed">
<p>In Emby Webhooks, configure a POST to the URL above.</p>
<p>
Enable <code>Playback &gt; Stop</code>.{" "}
<code>Users &gt; Mark Played</code> is optional but may duplicate
completion events.
</p>
<div className="space-y-4 text-sm">
<ol className="text-muted-foreground list-decimal space-y-1.5 pl-5 leading-relaxed">
<li>
In the Emby dashboard, open{" "}
<span className="text-foreground">Notifications</span> and add a new{" "}
<span className="text-foreground">Webhooks</span> notification.
</li>
<li>
Paste the URL above into <span className="text-foreground">Url</span>,
and set <span className="text-foreground">Request content type</span>{" "}
to <span className="text-foreground">application/json</span>.
</li>
<li>Enable the events listed below, then save.</li>
</ol>
<EventMatrix
sections={[
{
label: "Required",
tone: "required",
items: [
{
event: "Playback → Stop",
note: "Records watch progress and completion.",
},
],
},
{
label: "Recommended",
tone: "recommended",
items: [
{
event: "Users → Add to Favorites, Remove from Favorites",
note: "Syncs favorites to the mapped Silo profile.",
},
{
event: "Users → Mark Played, Mark Unplayed",
note: "Needed only if your household manually marks items watched without playing them. Mark Played will duplicate Stop for normal completions, but the result is the same.",
},
],
},
{
label: "Skip",
tone: "skip",
items: [
{
event: "Playback → Start, Pause, Unpause",
note: "Silo only records completion, not in-progress state.",
},
],
},
]}
/>
</div>
) : null}
{selectedConnection.provider === "jellyfin" ? (
<div className="space-y-2">
<div className="text-muted-foreground space-y-1 text-sm leading-relaxed">
<p>
Use the official Jellyfin webhook plugin with destination type
`Generic Notification`.
</p>
<p>
Enable only `PlaybackStop`. Do not enable `PlaybackProgress` or
`UserDataSaved`.
</p>
<div className="space-y-4 text-sm">
<ol className="text-muted-foreground list-decimal space-y-1.5 pl-5 leading-relaxed">
<li>
Install the official <span className="text-foreground">Webhook</span>{" "}
plugin from{" "}
<span className="text-foreground">Dashboard → Plugins → Catalog</span>{" "}
and restart Jellyfin.
</li>
<li>
Open{" "}
<span className="text-foreground">Dashboard → Plugins → Webhook</span>{" "}
and add a <span className="text-foreground">Generic Destination</span>
.
</li>
<li>
Paste the URL above into{" "}
<span className="text-foreground">Webhook Url</span>.
</li>
<li>
Under <span className="text-foreground">Notification Type</span>,
enable only <span className="text-foreground">Playback Stop</span>.
Leave <span className="text-foreground">Playback Progress</span> and{" "}
<span className="text-foreground">User Data Saved</span> off — Silo
ignores them and they generate heavy traffic.
</li>
<li>
Paste the template below into{" "}
<span className="text-foreground">Template</span> and save.
</li>
</ol>
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs">
Webhook payload template
</Label>
<textarea
readOnly
value={JELLYFIN_TEMPLATE}
className="border-input bg-background min-h-56 w-full rounded-md border px-3 py-2 font-mono text-xs"
/>
<Button
variant="outline"
size="sm"
onClick={() => void copyText(JELLYFIN_TEMPLATE)}
>
<Copy className="h-3.5 w-3.5" />
Copy template
</Button>
</div>
<textarea
readOnly
value={JELLYFIN_TEMPLATE}
className="border-input bg-background min-h-56 w-full rounded-md border px-3 py-2 font-mono text-xs"
/>
<Button
variant="outline"
size="sm"
onClick={() => void copyText(JELLYFIN_TEMPLATE)}
>
<Copy className="h-3.5 w-3.5" />
Copy template
</Button>
</div>
) : null}
</div>
@@ -1130,7 +1251,7 @@ export default function WebhookSyncSettings() {
)}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{eventActorLabel(event)}
{eventUserLabel(event)}
</TableCell>
<TableCell className="text-muted-foreground text-right text-xs">
{relativeTime(event.received_at) ?? formatTimestamp(event.received_at)}