Files
silo-server/internal/webhooksync/providers.go
T
Silo Server Migration 5f5e404d93 Rename webhook sync actors to profile mappings
- Rename actor-oriented API, storage, and webhook fields to user/profile terminology
- Switch dev compose helpers to use docker-compose.yml
- Update frontend types and webhook sync settings for the new endpoints
2026-05-24 00:04:25 -04:00

598 lines
19 KiB
Go

package webhooksync
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/historyimport"
)
type PlexProvider struct {
client *historyimport.PlexClient
}
func NewPlexProvider(client *historyimport.PlexClient) *PlexProvider {
return &PlexProvider{client: client}
}
func (p *PlexProvider) ID() string { return ProviderPlex }
func (p *PlexProvider) ValidateCreateInput(input CreateConnectionInput) error {
if strings.TrimSpace(input.ServerID) == "" || strings.TrimSpace(input.ServerName) == "" || strings.TrimSpace(input.BaseURL) == "" || strings.TrimSpace(input.AccessToken) == "" || strings.TrimSpace(input.DefaultProfileID) == "" {
return fmt.Errorf("provider, server_id, server_name, base_url, access_token, and default_profile_id are required")
}
return nil
}
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
}
return strconv.Itoa(account.ID), account.Title, true, nil
}
func (p *PlexProvider) DiscoverUsers(ctx context.Context, conn *Connection, mappings []ProfileMapping) ([]DiscoveredUser, bool, error) {
if strings.TrimSpace(conn.BaseURL) == "" || strings.TrimSpace(conn.AccessToken) == "" {
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([]DiscoveredUser, 0, len(accounts))
for _, a := range accounts {
result = append(result, DiscoveredUser{
ExternalUserID: a.ID,
ExternalUserName: a.Name,
})
}
return result, true, nil
}
func (p *PlexProvider) ParseWebhook(ctx context.Context, conn *Connection, r *http.Request) (*CanonicalEvent, error) {
if err := r.ParseMultipartForm(10 << 20); err != nil {
return nil, fmt.Errorf("invalid multipart request")
}
payloadRaw := r.FormValue("payload")
if payloadRaw == "" {
return nil, fmt.Errorf("missing payload")
}
var payload struct {
Event string `json:"event"`
Account struct {
ID int64 `json:"id"`
Title string `json:"title"`
} `json:"Account"`
Metadata struct {
RatingKey string `json:"ratingKey"`
Type string `json:"type"`
} `json:"Metadata"`
}
if err := json.Unmarshal([]byte(payloadRaw), &payload); err != nil {
return nil, fmt.Errorf("invalid Plex payload")
}
if payload.Event == "" || payload.Account.ID == 0 || payload.Metadata.RatingKey == "" || payload.Metadata.Type == "" {
return nil, fmt.Errorf("invalid Plex webhook payload")
}
if !shouldApplyPlexWebhookEvent(payload.Event) {
return &CanonicalEvent{
EventKind: payload.Event,
Summary: "Ignored unsupported Plex event type",
Apply: false,
}, nil
}
item, err := p.client.FetchMetadata(ctx, conn.BaseURL, conn.AccessToken, payload.Metadata.RatingKey)
if err != nil {
return nil, err
}
if item == nil {
return &CanonicalEvent{
EventKind: payload.Event,
Summary: "Ignored Plex event because item metadata could not be resolved",
Apply: false,
}, nil
}
var series *historyimport.PlexItem
if item.Type == "episode" && item.GrandparentRatingKey != "" {
series, _ = p.client.FetchMetadata(ctx, conn.BaseURL, conn.AccessToken, item.GrandparentRatingKey)
}
record := historyimport.NormalizePlexItem(*item, series)
canonical := fromHistoryImportRecord(record)
occurredAt := canonical.UpdatedAt
if occurredAt.IsZero() {
occurredAt = time.Now().UTC()
canonical.UpdatedAt = occurredAt
}
return &CanonicalEvent{
Provider: ProviderPlex,
ServerName: conn.ServerName,
OccurredAt: occurredAt,
Action: ActionImportProgress,
EventKind: payload.Event,
UserID: strconv.FormatInt(payload.Account.ID, 10),
UserName: payload.Account.Title,
ExternalItemID: payload.Metadata.RatingKey,
MediaKind: payload.Metadata.Type,
Completed: canonical.Played,
PositionSeconds: canonical.PositionSeconds,
DurationSeconds: canonical.DurationSeconds,
Record: canonical,
Summary: "Applied Plex watch progress event",
Apply: true,
}, nil
}
type EmbyProvider struct{}
func NewEmbyProvider() *EmbyProvider { return &EmbyProvider{} }
func (p *EmbyProvider) ID() string { return ProviderEmby }
func (p *EmbyProvider) ValidateCreateInput(input CreateConnectionInput) error {
if strings.TrimSpace(input.ServerName) == "" || strings.TrimSpace(input.DefaultProfileID) == "" {
return fmt.Errorf("provider, server_name, and default_profile_id are required")
}
return nil
}
func (p *EmbyProvider) DefaultUser(context.Context, *Connection, CreateConnectionInput) (string, string, bool, error) {
return "", "", 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) {
var payload embyWebhookPayload
if err := decodeEmbyPayload(r, &payload); err != nil {
return nil, err
}
eventName := strings.ToLower(strings.TrimSpace(payload.Event))
if eventName == "" || payload.User.ID == "" || payload.Item.ID == "" || payload.Item.Type == "" {
return nil, fmt.Errorf("invalid Emby webhook payload")
}
action, ok := embyWebhookAction(eventName)
if !ok {
return &CanonicalEvent{
EventKind: eventName,
Summary: "Ignored unsupported Emby event type",
Apply: false,
}, nil
}
record, mediaKind, ok := embyLikeRecord(payload.Item.Type, payload.Item.Name, payload.Item.SeriesName, payload.Item.ProductionYear, payload.Item.IndexNumber, payload.Item.ParentIndexNumber, payload.Item.RunTimeTicks, payload.PlaybackInfo.PositionTicks, payload.PlaybackInfo.PlayedToCompletion || action == ActionImportProgress && isEmbyMarkPlayedEvent(eventName), payload.Item.ProviderIDs, payload.Item.ID, payload.Date, actionAllowsSeries(action))
if !ok {
return &CanonicalEvent{
EventKind: eventName,
Action: action,
Summary: "Ignored Emby event for unsupported media kind",
Apply: false,
}, nil
}
serverName := conn.ServerName
if strings.TrimSpace(payload.Server.Name) != "" {
serverName = payload.Server.Name
}
return &CanonicalEvent{
Provider: ProviderEmby,
ServerName: serverName,
OccurredAt: record.UpdatedAt,
Action: action,
EventKind: eventName,
UserID: payload.User.ID,
UserName: payload.User.Name,
ExternalItemID: payload.Item.ID,
MediaKind: mediaKind,
Completed: record.Played,
PositionSeconds: record.PositionSeconds,
DurationSeconds: record.DurationSeconds,
Record: record,
Summary: "Applied Emby webhook event",
Apply: true,
}, nil
}
type embyWebhookPayload struct {
Event string `json:"Event"`
Date time.Time `json:"Date"`
User struct {
ID string `json:"Id"`
Name string `json:"Name"`
} `json:"User"`
Item struct {
ID string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
SeriesName string `json:"SeriesName"`
ProductionYear int `json:"ProductionYear"`
IndexNumber int `json:"IndexNumber"`
ParentIndexNumber int `json:"ParentIndexNumber"`
RunTimeTicks int64 `json:"RunTimeTicks"`
ProviderIDs map[string]string `json:"ProviderIds"`
} `json:"Item"`
PlaybackInfo struct {
PlayedToCompletion bool `json:"PlayedToCompletion"`
PositionTicks int64 `json:"PositionTicks"`
} `json:"PlaybackInfo"`
Server struct {
Name string `json:"Name"`
} `json:"Server"`
}
func decodeEmbyPayload(r *http.Request, payload *embyWebhookPayload) error {
body, err := io.ReadAll(r.Body)
if err != nil {
return fmt.Errorf("invalid Emby payload")
}
body = bytes.TrimSpace(body)
if len(body) == 0 {
return fmt.Errorf("invalid Emby payload")
}
contentType, params, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
switch strings.ToLower(contentType) {
case "", "application/json", "text/json":
return decodeEmbyJSON(body, payload)
case "application/x-www-form-urlencoded":
return decodeEmbyForm(body, payload)
case "multipart/form-data":
boundary := params["boundary"]
if boundary == "" {
return fmt.Errorf("invalid Emby payload")
}
return decodeEmbyMultipart(body, boundary, payload)
default:
if err := decodeEmbyJSON(body, payload); err == nil {
return nil
}
if err := decodeEmbyForm(body, payload); err == nil {
return nil
}
return fmt.Errorf("invalid Emby payload")
}
}
func decodeEmbyJSON(body []byte, payload *embyWebhookPayload) error {
if err := json.Unmarshal(body, payload); err != nil {
return fmt.Errorf("invalid Emby payload")
}
return nil
}
func decodeEmbyForm(body []byte, payload *embyWebhookPayload) error {
values, err := url.ParseQuery(string(body))
if err != nil {
return fmt.Errorf("invalid Emby payload")
}
for _, key := range []string{"payload", "data", "json", "body"} {
if value := strings.TrimSpace(values.Get(key)); value != "" {
return decodeEmbyJSON([]byte(value), payload)
}
}
if len(values) == 1 {
for _, vals := range values {
if len(vals) == 1 && strings.TrimSpace(vals[0]) != "" {
return decodeEmbyJSON([]byte(vals[0]), payload)
}
}
}
return fmt.Errorf("invalid Emby payload")
}
func decodeEmbyMultipart(body []byte, boundary string, payload *embyWebhookPayload) error {
reader := multipart.NewReader(bytes.NewReader(body), boundary)
var fallback []byte
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("invalid Emby payload")
}
partBody, err := io.ReadAll(part)
if err != nil {
return fmt.Errorf("invalid Emby payload")
}
partBody = bytes.TrimSpace(partBody)
if len(partBody) == 0 {
continue
}
name := strings.ToLower(strings.TrimSpace(part.FormName()))
partType, _, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
if name == "payload" || name == "data" || name == "json" || name == "body" || strings.EqualFold(partType, "application/json") {
return decodeEmbyJSON(partBody, payload)
}
if fallback == nil {
fallback = partBody
}
}
if len(fallback) > 0 {
return decodeEmbyJSON(fallback, payload)
}
return fmt.Errorf("invalid Emby payload")
}
type JellyfinProvider struct{}
func NewJellyfinProvider() *JellyfinProvider { return &JellyfinProvider{} }
func (p *JellyfinProvider) ID() string { return ProviderJellyfin }
func (p *JellyfinProvider) ValidateCreateInput(input CreateConnectionInput) error {
if strings.TrimSpace(input.ServerName) == "" || strings.TrimSpace(input.DefaultProfileID) == "" {
return fmt.Errorf("provider, server_name, and default_profile_id are required")
}
return nil
}
func (p *JellyfinProvider) DefaultUser(context.Context, *Connection, CreateConnectionInput) (string, string, bool, error) {
return "", "", 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) {
var payload struct {
Provider string `json:"provider"`
NotificationType string `json:"notification_type"`
Timestamp string `json:"timestamp"`
ServerName string `json:"server_name"`
User struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"user"`
Item struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
SeriesName string `json:"series_name"`
Year int `json:"year"`
SeasonNumber int `json:"season_number"`
EpisodeNumber int `json:"episode_number"`
ProviderIDs map[string]string `json:"provider_ids"`
RuntimeTicks int64 `json:"runtime_ticks"`
} `json:"item"`
Playback struct {
PositionTicks int64 `json:"position_ticks"`
PlayedToCompletion bool `json:"played_to_completion"`
RuntimeTicks int64 `json:"runtime_ticks"`
} `json:"playback"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
return nil, fmt.Errorf("invalid Jellyfin payload")
}
if payload.NotificationType != "PlaybackStop" {
return &CanonicalEvent{
EventKind: payload.NotificationType,
Summary: "Ignored Jellyfin notification because only PlaybackStop is supported",
Apply: false,
}, nil
}
if payload.User.ID == "" || payload.Item.ID == "" || payload.Item.Type == "" {
return nil, fmt.Errorf("invalid Jellyfin webhook payload")
}
occurredAt, err := time.Parse(time.RFC3339, payload.Timestamp)
if err != nil {
occurredAt = time.Now().UTC()
}
runtimeTicks := payload.Playback.RuntimeTicks
if runtimeTicks == 0 {
runtimeTicks = payload.Item.RuntimeTicks
}
record, mediaKind, ok := embyLikeRecord(payload.Item.Type, payload.Item.Name, payload.Item.SeriesName, payload.Item.Year, payload.Item.EpisodeNumber, payload.Item.SeasonNumber, runtimeTicks, payload.Playback.PositionTicks, payload.Playback.PlayedToCompletion, payload.Item.ProviderIDs, payload.Item.ID, occurredAt, false)
if !ok {
return &CanonicalEvent{
EventKind: payload.NotificationType,
Summary: "Ignored Jellyfin event for unsupported media kind",
Apply: false,
}, nil
}
serverName := conn.ServerName
if strings.TrimSpace(payload.ServerName) != "" {
serverName = payload.ServerName
}
return &CanonicalEvent{
Provider: ProviderJellyfin,
ServerName: serverName,
OccurredAt: record.UpdatedAt,
Action: ActionImportProgress,
EventKind: payload.NotificationType,
UserID: payload.User.ID,
UserName: payload.User.Name,
ExternalItemID: payload.Item.ID,
MediaKind: mediaKind,
Completed: record.Played,
PositionSeconds: record.PositionSeconds,
DurationSeconds: record.DurationSeconds,
Record: record,
Summary: "Applied Jellyfin watch progress event",
Apply: true,
}, nil
}
func embyLikeRecord(itemType, itemName, seriesName string, productionYear, episodeNumber, seasonNumber int, runTimeTicks, positionTicks int64, completed bool, providerIDs map[string]string, externalID string, occurredAt time.Time, allowSeries bool) (CanonicalRecord, string, bool) {
record := CanonicalRecord{
ExternalID: externalID,
Title: itemName,
Year: productionYear,
Played: completed,
PositionSeconds: ticksToSeconds(positionTicks),
DurationSeconds: ticksToSeconds(runTimeTicks),
UpdatedAt: occurredAt.UTC(),
}
record.LastPlayedAt = &record.UpdatedAt
record.IMDbID = providerID(providerIDs, "imdb")
record.TMDBID = providerID(providerIDs, "tmdb")
record.TVDBID = providerID(providerIDs, "tvdb")
switch strings.ToLower(strings.TrimSpace(itemType)) {
case "movie":
record.Kind = historyimport.KindMovie
return record, "movie", true
case "episode":
record.Kind = historyimport.KindEpisode
record.SeriesTitle = seriesName
record.SeasonNumber = seasonNumber
record.EpisodeNumber = episodeNumber
return record, "episode", true
case "series":
if !allowSeries {
return CanonicalRecord{}, "series", false
}
record.Kind = historyimport.KindSeries
return record, "series", true
default:
return CanonicalRecord{}, strings.ToLower(strings.TrimSpace(itemType)), false
}
}
func fromHistoryImportRecord(record historyimport.Record) CanonicalRecord {
return CanonicalRecord{
ExternalID: record.ExternalID,
Kind: record.Kind,
Title: record.Title,
Year: record.Year,
IMDbID: record.IMDbID,
TMDBID: record.TMDBID,
TVDBID: record.TVDBID,
SeriesTitle: record.SeriesTitle,
SeriesYear: record.SeriesYear,
SeriesIMDbID: record.SeriesIMDbID,
SeriesTMDBID: record.SeriesTMDBID,
SeriesTVDBID: record.SeriesTVDBID,
SeasonNumber: record.SeasonNumber,
EpisodeNumber: record.EpisodeNumber,
Played: record.Played,
LastPlayedAt: record.LastPlayedAt,
PositionSeconds: record.PositionSeconds,
DurationSeconds: record.DurationSeconds,
UpdatedAt: record.UpdatedAt,
}
}
func providerID(ids map[string]string, key string) string {
for k, v := range ids {
if strings.EqualFold(k, key) {
return strings.TrimSpace(v)
}
}
return ""
}
func ticksToSeconds(ticks int64) float64 {
if ticks <= 0 {
return 0
}
return float64(ticks) / 10_000_000
}
func embyWebhookAction(eventName string) (string, bool) {
switch {
case eventName == "playback.stop", isEmbyMarkPlayedEvent(eventName):
return ActionImportProgress, true
case isEmbyMarkUnplayedEvent(eventName):
return ActionMarkUnplayed, true
case eventName == "item.rate":
return ActionToggleFavorite, true
case isEmbyFavoriteRemovedEvent(eventName):
return ActionRemoveFavorite, true
case isEmbyFavoriteAddedEvent(eventName):
return ActionAddFavorite, true
default:
return "", false
}
}
func isEmbyMarkPlayedEvent(eventName string) bool {
return strings.Contains(eventName, "markplayed")
}
func isEmbyMarkUnplayedEvent(eventName string) bool {
return strings.Contains(eventName, "markunplayed")
}
func isEmbyFavoriteAddedEvent(eventName string) bool {
return containsEmbyFavoriteToken(eventName) &&
(strings.Contains(eventName, "add") ||
strings.Contains(eventName, "favorited") ||
strings.Contains(eventName, "favourited"))
}
func isEmbyFavoriteRemovedEvent(eventName string) bool {
return containsEmbyFavoriteToken(eventName) &&
(strings.Contains(eventName, "remove") ||
strings.Contains(eventName, "unfavorite") ||
strings.Contains(eventName, "unfavourite"))
}
func containsEmbyFavoriteToken(eventName string) bool {
return strings.Contains(eventName, "favorite") || strings.Contains(eventName, "favourite")
}
func actionAllowsSeries(action string) bool {
switch action {
case ActionAddFavorite, ActionRemoveFavorite, ActionToggleFavorite:
return true
default:
return false
}
}
func shouldApplyPlexWebhookEvent(event string) bool {
switch event {
case "media.scrobble", "media.stop", "media.pause":
return true
default:
return false
}
}
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.ExternalUserID] = struct{}{}
}
filtered := make([]historyimport.ExternalUser, 0, len(accounts))
for _, account := range accounts {
if strings.TrimSpace(account.Name) == "" {
continue
}
if _, mapped := mappedIDs[account.ID]; mapped {
filtered = append(filtered, account)
continue
}
if account.Home || account.Guest || account.Restricted {
filtered = append(filtered, account)
}
}
if len(filtered) == 0 {
fallback := make([]historyimport.ExternalUser, 0, len(accounts))
for _, account := range accounts {
if strings.TrimSpace(account.Name) == "" {
continue
}
fallback = append(fallback, account)
}
return fallback
}
return filtered
}