Merge pull request #3 from Silo-Server/t3code/ef35ed7f
Rename webhook sync actors to profile mappings
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 > Stop</code>.{" "}
|
||||
<code>Users > 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)}
|
||||
|
||||
Reference in New Issue
Block a user