2148 lines
63 KiB
Go
2148 lines
63 KiB
Go
package watchsync
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/historyimport"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
"github.com/Silo-Server/silo-server/internal/watchstate"
|
|
)
|
|
|
|
type Service struct {
|
|
repo Repository
|
|
registry *Registry
|
|
now func() time.Time
|
|
matcher mediaMatcher
|
|
watchState watchStateImporter
|
|
storeProvider userstore.UserStoreProvider
|
|
locks sync.Map
|
|
scrobbleQueues sync.Map
|
|
}
|
|
|
|
type scrobbleQueue struct {
|
|
mu sync.Mutex
|
|
tail chan struct{}
|
|
}
|
|
|
|
type mediaMatcher interface {
|
|
Match(ctx context.Context, record historyimport.Record) (*historyimport.Match, string, error)
|
|
}
|
|
|
|
type watchStateImporter interface {
|
|
RecordImportedHistoryWithSource(ctx context.Context, userID int, profileID, targetID string, duration float64, completed bool, watchedAt *time.Time, source userstore.WatchHistorySource) (bool, error)
|
|
}
|
|
|
|
const (
|
|
manualSyncCooldown = time.Hour
|
|
manualSyncTimeout = 10 * time.Minute
|
|
)
|
|
|
|
func NewService(repo Repository, registry *Registry) *Service {
|
|
return &Service{
|
|
repo: repo,
|
|
registry: registry,
|
|
now: func() time.Time { return time.Now().UTC() },
|
|
}
|
|
}
|
|
|
|
func (s *Service) WithMatcher(matcher mediaMatcher) *Service {
|
|
if s != nil {
|
|
s.matcher = matcher
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (s *Service) WithWatchState(watchState watchStateImporter) *Service {
|
|
if s != nil {
|
|
s.watchState = watchState
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (s *Service) WithUserStoreProvider(provider userstore.UserStoreProvider) *Service {
|
|
if s != nil {
|
|
s.storeProvider = provider
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (s *Service) WithDefaultWatchState(provider userstore.UserStoreProvider) *Service {
|
|
return s.WithUserStoreProvider(provider).WithWatchState(watchstate.NewService(provider))
|
|
}
|
|
|
|
func (s *Service) ListProviders() []ProviderSummary {
|
|
return s.registry.List()
|
|
}
|
|
|
|
func (s *Service) GetConnectionStatus(ctx context.Context, userID int, profileID string, providerKey string) (ConnectionStatus, error) {
|
|
provider, ok := s.registry.Get(providerKey)
|
|
if !ok {
|
|
return ConnectionStatus{}, fmt.Errorf("unknown provider %q", providerKey)
|
|
}
|
|
authMethod := authMethodOf(provider)
|
|
credentialsConfigured := authMethod == AuthMethodAPIKey
|
|
if !credentialsConfigured {
|
|
cfg, _ := s.serverConfig(ctx, providerKey)
|
|
credentialsConfigured = cfg.Configured()
|
|
}
|
|
conn, connected, err := s.repo.GetConnection(ctx, providerKey, userID, profileID)
|
|
if err != nil {
|
|
return ConnectionStatus{}, err
|
|
}
|
|
missingAccessToken := connected && strings.TrimSpace(conn.AccessToken) == ""
|
|
status := ConnectionStatus{
|
|
Provider: providerKey,
|
|
DisplayName: provider.DisplayName(),
|
|
Capabilities: provider.Capabilities(),
|
|
AuthMethod: authMethod,
|
|
Connected: connected && !missingAccessToken,
|
|
CredentialsConfigured: credentialsConfigured,
|
|
ImportWatchedEnabled: true,
|
|
ImportProgressEnabled: true,
|
|
ExportWatchedEnabled: true,
|
|
ExportUnwatchedEnabled: false,
|
|
ImportFavoritesEnabled: true,
|
|
ExportFavoritesEnabled: true,
|
|
SyncFavoriteRemovalsEnabled: false,
|
|
ScrobbleEnabled: true,
|
|
}
|
|
if connected {
|
|
status.ProviderUsername = conn.ProviderUsername
|
|
status.ImportWatchedEnabled = conn.ImportWatchedEnabled
|
|
status.ImportProgressEnabled = conn.ImportProgressEnabled
|
|
status.ExportWatchedEnabled = conn.ExportWatchedEnabled
|
|
status.ExportUnwatchedEnabled = conn.ExportUnwatchedEnabled
|
|
status.ImportFavoritesEnabled = conn.ImportFavoritesEnabled
|
|
status.ExportFavoritesEnabled = conn.ExportFavoritesEnabled
|
|
status.SyncFavoriteRemovalsEnabled = conn.SyncFavoriteRemovalsEnabled
|
|
status.ScrobbleEnabled = conn.ScrobbleEnabled
|
|
status.LastInboundSyncAt = conn.LastInboundSyncAt
|
|
status.LastProgressSyncAt = conn.LastProgressSyncAt
|
|
status.LastOutboundSyncAt = conn.LastOutboundSyncAt
|
|
status.LastFavoritesSyncAt = conn.LastFavoritesSyncAt
|
|
status.LastScrobbleErrorAt = conn.LastScrobbleErrorAt
|
|
status.LastError = conn.LastError
|
|
}
|
|
if missingAccessToken {
|
|
status.LastError = fmt.Sprintf("%s connection is missing an access token; reconnect the provider", providerKey)
|
|
}
|
|
return status, nil
|
|
}
|
|
|
|
func (s *Service) UpdateConnection(ctx context.Context, userID int, profileID string, providerKey string, update ConnectionUpdate) (ConnectionStatus, error) {
|
|
conn, ok, err := s.repo.GetConnection(ctx, providerKey, userID, profileID)
|
|
if err != nil {
|
|
return ConnectionStatus{}, err
|
|
}
|
|
if !ok {
|
|
return ConnectionStatus{}, fmt.Errorf("watch provider connection not found")
|
|
}
|
|
if update.ImportWatchedEnabled != nil {
|
|
conn.ImportWatchedEnabled = *update.ImportWatchedEnabled
|
|
}
|
|
if update.ImportProgressEnabled != nil {
|
|
conn.ImportProgressEnabled = *update.ImportProgressEnabled
|
|
}
|
|
if update.ExportWatchedEnabled != nil {
|
|
conn.ExportWatchedEnabled = *update.ExportWatchedEnabled
|
|
}
|
|
if update.ExportUnwatchedEnabled != nil {
|
|
conn.ExportUnwatchedEnabled = *update.ExportUnwatchedEnabled
|
|
}
|
|
if update.ImportFavoritesEnabled != nil {
|
|
conn.ImportFavoritesEnabled = *update.ImportFavoritesEnabled
|
|
}
|
|
if update.ExportFavoritesEnabled != nil {
|
|
conn.ExportFavoritesEnabled = *update.ExportFavoritesEnabled
|
|
}
|
|
if update.SyncFavoriteRemovalsEnabled != nil {
|
|
conn.SyncFavoriteRemovalsEnabled = *update.SyncFavoriteRemovalsEnabled
|
|
}
|
|
if update.ScrobbleEnabled != nil {
|
|
conn.ScrobbleEnabled = *update.ScrobbleEnabled
|
|
}
|
|
if _, err := s.repo.UpsertConnection(ctx, conn); err != nil {
|
|
return ConnectionStatus{}, err
|
|
}
|
|
return s.GetConnectionStatus(ctx, userID, profileID, providerKey)
|
|
}
|
|
|
|
func (s *Service) DeleteConnection(ctx context.Context, userID int, profileID string, providerKey string) error {
|
|
return s.repo.DeleteConnection(ctx, providerKey, userID, profileID)
|
|
}
|
|
|
|
func (s *Service) RequestManualSync(ctx context.Context, userID int, profileID string, providerKey string) (ManualSyncResult, error) {
|
|
conn, ok, err := s.repo.GetConnection(ctx, providerKey, userID, profileID)
|
|
if err != nil {
|
|
return ManualSyncResult{}, err
|
|
}
|
|
if !ok {
|
|
return ManualSyncResult{}, fmt.Errorf("watch provider connection not found")
|
|
}
|
|
|
|
if active, ok, err := s.repo.GetActiveSyncRun(ctx, conn.ID); err != nil {
|
|
return ManualSyncResult{}, err
|
|
} else if ok {
|
|
return ManualSyncResult{Run: active}, nil
|
|
}
|
|
|
|
if latest, ok, err := s.repo.GetLatestSyncRun(ctx, conn.ID); err != nil {
|
|
return ManualSyncResult{}, err
|
|
} else if ok {
|
|
reference := latest.StartedAt
|
|
if latest.CompletedAt != nil {
|
|
reference = *latest.CompletedAt
|
|
}
|
|
if retryAfter := retryAfterSeconds(s.now(), reference, manualSyncCooldown); retryAfter > 0 {
|
|
return ManualSyncResult{}, SyncCooldownError{RetryAfterSeconds: retryAfter}
|
|
}
|
|
}
|
|
|
|
run, err := s.repo.CreateSyncRun(ctx, SyncRun{
|
|
ConnectionID: conn.ID,
|
|
Trigger: "manual",
|
|
Status: string(SyncRunStatusRunning),
|
|
Provider: conn.Provider,
|
|
StartedAt: s.now(),
|
|
})
|
|
if err != nil {
|
|
return ManualSyncResult{}, err
|
|
}
|
|
|
|
go func() {
|
|
runCtx, cancel := context.WithTimeout(context.Background(), manualSyncTimeout)
|
|
defer cancel()
|
|
if _, err := s.syncConnectionWithRun(runCtx, conn, run); err != nil {
|
|
slog.Warn("manual watch provider sync failed", "provider", conn.Provider, "user_id", conn.UserID, "profile_id", conn.ProfileID, "error", err)
|
|
}
|
|
}()
|
|
|
|
return ManualSyncResult{Run: run}, nil
|
|
}
|
|
|
|
func (s *Service) ListSyncRuns(ctx context.Context, userID int, profileID string, providerKey string, limit int) ([]SyncRun, error) {
|
|
conn, ok, err := s.repo.GetConnection(ctx, providerKey, userID, profileID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
return nil, fmt.Errorf("watch provider connection not found")
|
|
}
|
|
return s.repo.ListSyncRuns(ctx, conn.ID, limit)
|
|
}
|
|
|
|
func (s *Service) HandleLocalWatchEvent(ctx context.Context, event LocalWatchEvent) error {
|
|
if event.UserID == 0 || event.ProfileID == "" || len(event.Plays) == 0 {
|
|
return nil
|
|
}
|
|
plays := make([]LocalPlay, 0, len(event.Plays))
|
|
for _, play := range event.Plays {
|
|
if play.ProviderItemKey == "" {
|
|
play.ProviderItemKey = providerItemKeyForLocalPlay(play)
|
|
}
|
|
if play.ProviderItemKey == "" {
|
|
continue
|
|
}
|
|
plays = append(plays, play)
|
|
}
|
|
if len(plays) == 0 {
|
|
return nil
|
|
}
|
|
go func() {
|
|
bg, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
if err := s.processLocalWatchEvent(bg, LocalWatchEvent{
|
|
Kind: event.Kind,
|
|
UserID: event.UserID,
|
|
ProfileID: event.ProfileID,
|
|
Plays: plays,
|
|
}); err != nil {
|
|
slog.Warn("failed to dispatch local watch provider event", "kind", event.Kind, "user_id", event.UserID, "profile_id", event.ProfileID, "error", err)
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) processLocalWatchEvent(ctx context.Context, event LocalWatchEvent) error {
|
|
conns, err := s.repo.ListLocalWatchEventConnections(ctx, event.UserID, event.ProfileID, event.Kind)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, conn := range conns {
|
|
provider, ok := s.registry.Get(conn.Provider)
|
|
if !ok {
|
|
continue
|
|
}
|
|
cfg, err := s.serverConfig(ctx, conn.Provider)
|
|
if err != nil {
|
|
s.recordLocalWatchEventError(ctx, conn, err)
|
|
continue
|
|
}
|
|
switch event.Kind {
|
|
case LocalWatchEventMarkedWatched:
|
|
if !provider.Capabilities().ExportWatched {
|
|
continue
|
|
}
|
|
exporter, ok := provider.(WatchedExporter)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if err := s.exportLocalPlays(ctx, conn, cfg, exporter, event.Plays); err != nil {
|
|
s.recordLocalWatchEventError(ctx, conn, err)
|
|
}
|
|
case LocalWatchEventMarkedUnwatched:
|
|
if !provider.Capabilities().ExportUnwatched {
|
|
continue
|
|
}
|
|
remover, ok := provider.(UnwatchedExporter)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, err := remover.RemoveHistory(ctx, cfg, conn, event.Plays); err != nil {
|
|
s.recordLocalWatchEventError(ctx, conn, err)
|
|
continue
|
|
}
|
|
now := s.now()
|
|
conn.LastOutboundSyncAt = &now
|
|
conn.LastError = ""
|
|
if _, err := s.repo.UpsertConnection(ctx, conn); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) HandleLocalFavoriteEvent(ctx context.Context, event LocalFavoriteEvent) error {
|
|
if event.UserID == 0 || event.ProfileID == "" || len(event.Favorites) == 0 {
|
|
return nil
|
|
}
|
|
go func() {
|
|
bg, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
if err := s.processLocalFavoriteEvent(bg, event); err != nil {
|
|
slog.Warn("failed to dispatch local favorite provider event", "kind", event.Kind, "user_id", event.UserID, "profile_id", event.ProfileID, "error", err)
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) processLocalFavoriteEvent(ctx context.Context, event LocalFavoriteEvent) error {
|
|
conns, err := s.repo.ListFavoriteEventConnections(ctx, event.UserID, event.ProfileID, event.Kind)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, conn := range conns {
|
|
provider, ok := s.registry.Get(conn.Provider)
|
|
if !ok {
|
|
continue
|
|
}
|
|
cfg, err := s.serverConfig(ctx, conn.Provider)
|
|
if err != nil {
|
|
s.recordLocalWatchEventError(ctx, conn, err)
|
|
continue
|
|
}
|
|
conn, err = s.refreshConnectionIfNeeded(ctx, provider, cfg, conn)
|
|
if err != nil {
|
|
s.recordLocalWatchEventError(ctx, conn, err)
|
|
continue
|
|
}
|
|
switch event.Kind {
|
|
case LocalFavoriteEventAdded:
|
|
exporter, ok := provider.(FavoriteExporter)
|
|
if !ok || !provider.Capabilities().ExportFavorites {
|
|
continue
|
|
}
|
|
if err := s.exportLocalFavorites(ctx, conn, cfg, exporter, event.Favorites); err != nil {
|
|
s.recordLocalWatchEventError(ctx, conn, err)
|
|
}
|
|
case LocalFavoriteEventRemoved:
|
|
now := s.now()
|
|
for _, favorite := range event.Favorites {
|
|
if err := s.repo.MarkFavoriteLocalRemoved(ctx, conn.ID, favorite.MediaItemID, now); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if !conn.SyncFavoriteRemovalsEnabled || !provider.Capabilities().RemoveFavorites {
|
|
continue
|
|
}
|
|
remover, ok := provider.(FavoriteRemover)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, err := remover.RemoveFavorites(ctx, cfg, conn, event.Favorites); err != nil {
|
|
s.recordLocalWatchEventError(ctx, conn, err)
|
|
continue
|
|
}
|
|
for _, favorite := range event.Favorites {
|
|
if err := s.repo.MarkFavoriteRemoteRemoved(ctx, conn.ID, favorite.MediaItemID, now); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) exportLocalFavorites(ctx context.Context, conn Connection, cfg ServerConfig, exporter FavoriteExporter, favorites []LocalFavorite) error {
|
|
states := make([]FavoriteState, 0, len(favorites))
|
|
for _, favorite := range favorites {
|
|
if favorite.ProviderItemKey == "" {
|
|
favorite.ProviderItemKey = providerItemKeyForLocalFavorite(favorite)
|
|
}
|
|
if favorite.ProviderItemKey == "" {
|
|
continue
|
|
}
|
|
favoritedAt := favorite.FavoritedAt
|
|
if favoritedAt.IsZero() {
|
|
favoritedAt = s.now()
|
|
}
|
|
states = append(states, FavoriteState{
|
|
ConnectionID: conn.ID,
|
|
MediaItemID: favorite.MediaItemID,
|
|
ProviderItemKey: favorite.ProviderItemKey,
|
|
Kind: favorite.Kind,
|
|
Title: favorite.Title,
|
|
Year: favorite.Year,
|
|
RemotePresent: false,
|
|
LocalPresent: true,
|
|
LastSeenLocalAt: &favoritedAt,
|
|
})
|
|
}
|
|
if err := s.repo.UpsertFavoriteStates(ctx, states); err != nil {
|
|
return err
|
|
}
|
|
result, err := exporter.ExportFavorites(ctx, cfg, conn, favorites)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := s.now()
|
|
sent := exportResultSentSet(result)
|
|
for _, favorite := range favorites {
|
|
if sent[favorite.MediaItemID] || sent[favorite.ProviderItemKey] {
|
|
if err := s.repo.MarkFavoriteExported(ctx, conn.ID, favorite.MediaItemID, now); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
conn.LastFavoritesSyncAt = &now
|
|
conn.LastError = ""
|
|
_, err = s.repo.UpsertConnection(ctx, conn)
|
|
return err
|
|
}
|
|
|
|
func (s *Service) recordLocalWatchEventError(ctx context.Context, conn Connection, err error) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
conn.LastError = err.Error()
|
|
if _, updateErr := s.repo.UpsertConnection(ctx, conn); updateErr != nil {
|
|
slog.Warn("failed to record local watch provider event error", "provider", conn.Provider, "connection_id", conn.ID, "error", updateErr)
|
|
}
|
|
}
|
|
|
|
func (s *Service) StartDeviceAuth(
|
|
ctx context.Context,
|
|
userID int,
|
|
profileID string,
|
|
providerKey string,
|
|
) (DeviceAuthSession, error) {
|
|
if userID <= 0 {
|
|
return DeviceAuthSession{}, fmt.Errorf("user id is required")
|
|
}
|
|
if profileID == "" {
|
|
return DeviceAuthSession{}, fmt.Errorf("profile id is required")
|
|
}
|
|
provider, ok := s.registry.Get(providerKey)
|
|
if !ok {
|
|
return DeviceAuthSession{}, fmt.Errorf("unknown provider %q", providerKey)
|
|
}
|
|
authProvider, ok := provider.(AuthProvider)
|
|
if !ok {
|
|
return DeviceAuthSession{}, fmt.Errorf("provider %q does not support auth", providerKey)
|
|
}
|
|
|
|
cfg, err := s.serverConfig(ctx, providerKey)
|
|
if err != nil {
|
|
return DeviceAuthSession{}, err
|
|
}
|
|
session, err := authProvider.StartDeviceAuth(ctx, cfg)
|
|
if err != nil {
|
|
return DeviceAuthSession{}, err
|
|
}
|
|
|
|
session.Provider = providerKey
|
|
session.UserID = userID
|
|
session.ProfileID = profileID
|
|
return s.repo.UpsertAuthSession(ctx, session)
|
|
}
|
|
|
|
func (s *Service) PollDeviceAuth(
|
|
ctx context.Context,
|
|
userID int,
|
|
profileID string,
|
|
providerKey string,
|
|
sessionID string,
|
|
) (Connection, error) {
|
|
if userID <= 0 {
|
|
return Connection{}, fmt.Errorf("user id is required")
|
|
}
|
|
if profileID == "" {
|
|
return Connection{}, fmt.Errorf("profile id is required")
|
|
}
|
|
if sessionID == "" {
|
|
return Connection{}, fmt.Errorf("auth session id is required")
|
|
}
|
|
provider, ok := s.registry.Get(providerKey)
|
|
if !ok {
|
|
return Connection{}, fmt.Errorf("unknown provider %q", providerKey)
|
|
}
|
|
authProvider, ok := provider.(AuthProvider)
|
|
if !ok {
|
|
return Connection{}, fmt.Errorf("provider %q does not support auth", providerKey)
|
|
}
|
|
|
|
session, err := s.repo.GetAuthSession(ctx, sessionID)
|
|
if err != nil {
|
|
return Connection{}, err
|
|
}
|
|
if session.UserID != userID || session.ProfileID != profileID || session.Provider != providerKey {
|
|
return Connection{}, fmt.Errorf("auth session does not match active profile")
|
|
}
|
|
if session.CompletedAt != nil {
|
|
return Connection{}, fmt.Errorf("auth session is already completed")
|
|
}
|
|
if !session.ExpiresAt.IsZero() && !session.ExpiresAt.After(s.now()) {
|
|
return Connection{}, fmt.Errorf("auth session has expired")
|
|
}
|
|
|
|
cfg, err := s.serverConfig(ctx, providerKey)
|
|
if err != nil {
|
|
return Connection{}, err
|
|
}
|
|
tokens, err := authProvider.PollDeviceAuth(ctx, cfg, session)
|
|
if err != nil {
|
|
return Connection{}, err
|
|
}
|
|
|
|
account, err := authProvider.LookupAccount(ctx, cfg, Connection{AccessToken: tokens.AccessToken})
|
|
if err != nil {
|
|
return Connection{}, err
|
|
}
|
|
conn, err := s.persistConnection(ctx, providerKey, userID, profileID, tokens, account)
|
|
if err != nil {
|
|
return Connection{}, err
|
|
}
|
|
|
|
completedAt := s.now()
|
|
session.CompletedAt = &completedAt
|
|
if _, err := s.repo.UpsertAuthSession(ctx, session); err != nil {
|
|
return Connection{}, err
|
|
}
|
|
|
|
return conn, nil
|
|
}
|
|
|
|
func (s *Service) ConnectAPIKey(
|
|
ctx context.Context,
|
|
userID int,
|
|
profileID string,
|
|
providerKey string,
|
|
apiKey string,
|
|
) (Connection, error) {
|
|
if userID <= 0 {
|
|
return Connection{}, fmt.Errorf("user id is required")
|
|
}
|
|
if profileID == "" {
|
|
return Connection{}, fmt.Errorf("profile id is required")
|
|
}
|
|
apiKey = strings.TrimSpace(apiKey)
|
|
if apiKey == "" {
|
|
return Connection{}, fmt.Errorf("api key is required")
|
|
}
|
|
provider, ok := s.registry.Get(providerKey)
|
|
if !ok {
|
|
return Connection{}, fmt.Errorf("unknown provider %q", providerKey)
|
|
}
|
|
authProvider, ok := provider.(APIKeyAuthProvider)
|
|
if !ok {
|
|
return Connection{}, fmt.Errorf("provider %q does not support api-key auth", providerKey)
|
|
}
|
|
|
|
tokens, account, err := authProvider.ConnectWithAPIKey(ctx, apiKey)
|
|
if err != nil {
|
|
return Connection{}, err
|
|
}
|
|
if strings.TrimSpace(tokens.AccessToken) == "" {
|
|
tokens.AccessToken = apiKey
|
|
}
|
|
return s.persistConnection(ctx, providerKey, userID, profileID, tokens, account)
|
|
}
|
|
|
|
func (s *Service) persistConnection(
|
|
ctx context.Context,
|
|
providerKey string,
|
|
userID int,
|
|
profileID string,
|
|
tokens TokenSet,
|
|
account ProviderAccount,
|
|
) (Connection, error) {
|
|
conn, ok, err := s.repo.GetConnection(ctx, providerKey, userID, profileID)
|
|
if err != nil {
|
|
return Connection{}, err
|
|
}
|
|
if !ok {
|
|
conn = Connection{
|
|
ImportWatchedEnabled: true,
|
|
ImportProgressEnabled: true,
|
|
ExportWatchedEnabled: true,
|
|
ImportFavoritesEnabled: true,
|
|
ExportFavoritesEnabled: true,
|
|
ScrobbleEnabled: true,
|
|
}
|
|
}
|
|
conn.Provider = providerKey
|
|
conn.UserID = userID
|
|
conn.ProfileID = profileID
|
|
conn.AccessToken = tokens.AccessToken
|
|
conn.RefreshToken = tokens.RefreshToken
|
|
conn.TokenExpiresAt = tokens.TokenExpiresAt
|
|
conn.ProviderAccountID = account.ID
|
|
conn.ProviderUsername = account.Username
|
|
|
|
return s.repo.UpsertConnection(ctx, conn)
|
|
}
|
|
|
|
func (s *Service) SyncDueConnections(ctx context.Context) error {
|
|
conns, err := s.repo.ListConnectionsDueForSync(ctx, s.now())
|
|
if err != nil {
|
|
return fmt.Errorf("list due watch provider connections: %w", err)
|
|
}
|
|
for _, conn := range conns {
|
|
if err := s.SyncConnection(ctx, conn, "scheduled"); err != nil {
|
|
slog.Warn("watch provider connection sync failed", "provider", conn.Provider, "user_id", conn.UserID, "profile_id", conn.ProfileID, "error", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) SyncConnection(ctx context.Context, conn Connection, trigger string) (err error) {
|
|
run := SyncRun{
|
|
ConnectionID: conn.ID,
|
|
Trigger: trigger,
|
|
Status: string(SyncRunStatusRunning),
|
|
Provider: conn.Provider,
|
|
StartedAt: s.now(),
|
|
}
|
|
if conn.ID == "" {
|
|
return fmt.Errorf("connection id is required")
|
|
}
|
|
unlock, ok := s.tryLock(conn.ID)
|
|
if !ok {
|
|
return fmt.Errorf("watch provider sync already running for connection %s", conn.ID)
|
|
}
|
|
defer unlock()
|
|
|
|
run, err = s.repo.CreateSyncRun(ctx, run)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.executeSyncRun(ctx, conn, run)
|
|
return err
|
|
}
|
|
|
|
func (s *Service) syncConnectionWithRun(ctx context.Context, conn Connection, run SyncRun) (SyncRun, error) {
|
|
if conn.ID == "" {
|
|
return SyncRun{}, fmt.Errorf("connection id is required")
|
|
}
|
|
unlock, ok := s.tryLock(conn.ID)
|
|
if !ok {
|
|
run.Status = string(SyncRunStatusWarning)
|
|
run.Warning = fmt.Sprintf("watch provider sync already running for connection %s", conn.ID)
|
|
completed, err := s.completeSyncRun(ctx, run)
|
|
if err != nil {
|
|
return SyncRun{}, err
|
|
}
|
|
return completed, nil
|
|
}
|
|
defer unlock()
|
|
|
|
return s.executeSyncRun(ctx, conn, run)
|
|
}
|
|
|
|
func (s *Service) tryLock(connectionID string) (func(), bool) {
|
|
value, _ := s.locks.LoadOrStore(connectionID, &sync.Mutex{})
|
|
mu := value.(*sync.Mutex)
|
|
if !mu.TryLock() {
|
|
return nil, false
|
|
}
|
|
return mu.Unlock, true
|
|
}
|
|
|
|
func (s *Service) executeSyncRun(ctx context.Context, conn Connection, run SyncRun) (SyncRun, error) {
|
|
provider, ok := s.registry.Get(conn.Provider)
|
|
if !ok {
|
|
run.Status = string(SyncRunStatusFailed)
|
|
run.Error = fmt.Sprintf("unknown provider %q", conn.Provider)
|
|
completed, completeErr := s.completeSyncRun(ctx, run)
|
|
if completeErr != nil {
|
|
return SyncRun{}, completeErr
|
|
}
|
|
return completed, fmt.Errorf("%s", run.Error)
|
|
}
|
|
cfg, err := s.serverConfig(ctx, conn.Provider)
|
|
if err != nil {
|
|
run.Status = string(SyncRunStatusFailed)
|
|
run.Error = err.Error()
|
|
completed, completeErr := s.completeSyncRun(ctx, run)
|
|
if completeErr != nil {
|
|
return SyncRun{}, completeErr
|
|
}
|
|
return completed, err
|
|
}
|
|
caps := provider.Capabilities()
|
|
if providerSyncNeedsAccessToken(caps) && strings.TrimSpace(conn.AccessToken) == "" {
|
|
err := fmt.Errorf("%s connection is missing an access token; reconnect the provider", conn.Provider)
|
|
run.Status = string(SyncRunStatusFailed)
|
|
run.Error = err.Error()
|
|
completed, completeErr := s.completeSyncRun(ctx, run)
|
|
if completeErr != nil {
|
|
return SyncRun{}, completeErr
|
|
}
|
|
return completed, err
|
|
}
|
|
conn, err = s.refreshConnectionIfNeeded(ctx, provider, cfg, conn)
|
|
if err != nil {
|
|
run.Status = string(SyncRunStatusFailed)
|
|
run.Error = err.Error()
|
|
completed, completeErr := s.completeSyncRun(ctx, run)
|
|
if completeErr != nil {
|
|
return SyncRun{}, completeErr
|
|
}
|
|
return completed, err
|
|
}
|
|
|
|
var flowErrors []string
|
|
if conn.ImportWatchedEnabled && provider.Capabilities().ImportWatched {
|
|
importer, ok := provider.(WatchedImporter)
|
|
if !ok {
|
|
flowErrors = append(flowErrors, fmt.Sprintf("provider %q does not implement watched import", conn.Provider))
|
|
} else {
|
|
result, err := s.ImportWatched(ctx, conn, cfg, importer)
|
|
run.InboundWatchedFound = result.Found
|
|
run.InboundWatchedImported = result.Imported
|
|
run.Warning = appendWarning(run.Warning, result.Warnings)
|
|
if err != nil {
|
|
flowErrors = append(flowErrors, "watched import: "+err.Error())
|
|
} else if refreshed, refreshErr := s.reloadConnection(ctx, conn); refreshErr != nil {
|
|
flowErrors = append(flowErrors, "watched import connection refresh: "+refreshErr.Error())
|
|
} else {
|
|
conn = refreshed
|
|
}
|
|
}
|
|
}
|
|
if conn.ImportProgressEnabled && provider.Capabilities().ImportProgress {
|
|
importer, ok := provider.(ProgressImporter)
|
|
if !ok {
|
|
flowErrors = append(flowErrors, fmt.Sprintf("provider %q does not implement progress import", conn.Provider))
|
|
} else {
|
|
result, err := s.ImportProgress(ctx, conn, cfg, importer)
|
|
run.InboundProgressFound = result.Found
|
|
run.InboundProgressImported = result.Imported
|
|
run.Warning = appendWarning(run.Warning, result.Warnings)
|
|
if err != nil {
|
|
flowErrors = append(flowErrors, "progress import: "+err.Error())
|
|
} else if refreshed, refreshErr := s.reloadConnection(ctx, conn); refreshErr != nil {
|
|
flowErrors = append(flowErrors, "progress import connection refresh: "+refreshErr.Error())
|
|
} else {
|
|
conn = refreshed
|
|
}
|
|
}
|
|
}
|
|
if conn.ImportFavoritesEnabled && provider.Capabilities().ImportFavorites {
|
|
importer, ok := provider.(FavoriteImporter)
|
|
if !ok {
|
|
flowErrors = append(flowErrors, fmt.Sprintf("provider %q does not implement favorites import", conn.Provider))
|
|
} else {
|
|
result, err := s.ImportFavorites(ctx, conn, cfg, importer)
|
|
run.InboundFavoritesFound = result.Found
|
|
run.InboundFavoritesImported = result.Imported
|
|
run.Warning = appendWarning(run.Warning, result.Warnings)
|
|
if err != nil {
|
|
flowErrors = append(flowErrors, "favorites import: "+err.Error())
|
|
} else if refreshed, refreshErr := s.reloadConnection(ctx, conn); refreshErr != nil {
|
|
flowErrors = append(flowErrors, "favorites import connection refresh: "+refreshErr.Error())
|
|
} else {
|
|
conn = refreshed
|
|
}
|
|
}
|
|
}
|
|
if conn.ExportWatchedEnabled && provider.Capabilities().ExportWatched {
|
|
exporter, ok := provider.(WatchedExporter)
|
|
if !ok {
|
|
flowErrors = append(flowErrors, fmt.Sprintf("provider %q does not implement watched export", conn.Provider))
|
|
} else {
|
|
result, err := s.ExportWatched(ctx, conn, cfg, exporter)
|
|
run.OutboundFound = result.LocalFound
|
|
run.OutboundSent = result.Sent
|
|
if err != nil {
|
|
flowErrors = append(flowErrors, "watched export: "+err.Error())
|
|
} else if refreshed, refreshErr := s.reloadConnection(ctx, conn); refreshErr != nil {
|
|
flowErrors = append(flowErrors, "watched export connection refresh: "+refreshErr.Error())
|
|
} else {
|
|
conn = refreshed
|
|
}
|
|
}
|
|
}
|
|
if conn.ExportFavoritesEnabled && provider.Capabilities().ExportFavorites {
|
|
exporter, ok := provider.(FavoriteExporter)
|
|
if !ok {
|
|
flowErrors = append(flowErrors, fmt.Sprintf("provider %q does not implement favorites export", conn.Provider))
|
|
} else {
|
|
result, err := s.ExportFavorites(ctx, conn, cfg, exporter)
|
|
run.OutboundFavoritesFound = result.LocalFound
|
|
run.OutboundFavoritesSent = result.Sent
|
|
run.Warning = appendWarning(run.Warning, result.Warnings)
|
|
if err != nil {
|
|
flowErrors = append(flowErrors, "favorites export: "+err.Error())
|
|
} else if refreshed, refreshErr := s.reloadConnection(ctx, conn); refreshErr != nil {
|
|
flowErrors = append(flowErrors, "favorites export connection refresh: "+refreshErr.Error())
|
|
} else {
|
|
conn = refreshed
|
|
}
|
|
}
|
|
}
|
|
if conn.SyncFavoriteRemovalsEnabled && provider.Capabilities().RemoveFavorites {
|
|
remover, ok := provider.(FavoriteRemover)
|
|
if !ok {
|
|
flowErrors = append(flowErrors, fmt.Sprintf("provider %q does not implement favorites removal", conn.Provider))
|
|
} else {
|
|
removed, err := s.RemovePendingFavorites(ctx, conn, cfg, remover)
|
|
run.FavoriteRemovalsSent = removed
|
|
if err != nil {
|
|
flowErrors = append(flowErrors, "favorites removal: "+err.Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(flowErrors) > 0 {
|
|
run.Status = string(SyncRunStatusFailed)
|
|
run.Error = strings.Join(flowErrors, "; ")
|
|
completed, completeErr := s.completeSyncRun(ctx, run)
|
|
if completeErr != nil {
|
|
return SyncRun{}, completeErr
|
|
}
|
|
return completed, fmt.Errorf("%s", run.Error)
|
|
}
|
|
run.Status = string(SyncRunStatusSuccess)
|
|
return s.completeSyncRun(ctx, run)
|
|
}
|
|
|
|
func (s *Service) reloadConnection(ctx context.Context, conn Connection) (Connection, error) {
|
|
if conn.ID == "" {
|
|
return conn, nil
|
|
}
|
|
refreshed, ok, err := s.repo.GetConnectionByID(ctx, conn.ID)
|
|
if err != nil {
|
|
return Connection{}, err
|
|
}
|
|
if !ok {
|
|
return Connection{}, fmt.Errorf("watch provider connection %s not found", conn.ID)
|
|
}
|
|
return refreshed, nil
|
|
}
|
|
|
|
func providerSyncNeedsAccessToken(caps Capabilities) bool {
|
|
return caps.ImportWatched ||
|
|
caps.ImportProgress ||
|
|
caps.ExportWatched ||
|
|
caps.ExportUnwatched ||
|
|
caps.ImportFavorites ||
|
|
caps.ExportFavorites ||
|
|
caps.RemoveFavorites ||
|
|
caps.ScrobblePlayback
|
|
}
|
|
|
|
func (s *Service) completeSyncRun(ctx context.Context, run SyncRun) (SyncRun, error) {
|
|
completedAt := s.now()
|
|
run.CompletedAt = &completedAt
|
|
return s.repo.CompleteSyncRun(ctx, run)
|
|
}
|
|
|
|
func retryAfterSeconds(now time.Time, reference time.Time, cooldown time.Duration) int {
|
|
if reference.IsZero() {
|
|
return 0
|
|
}
|
|
remaining := cooldown - now.Sub(reference)
|
|
if remaining <= 0 {
|
|
return 0
|
|
}
|
|
seconds := int(remaining / time.Second)
|
|
if remaining%time.Second != 0 {
|
|
seconds++
|
|
}
|
|
if seconds < 1 {
|
|
return 1
|
|
}
|
|
return seconds
|
|
}
|
|
|
|
func appendWarning(existing string, warnings []string) string {
|
|
if len(warnings) == 0 {
|
|
return existing
|
|
}
|
|
parts := make([]string, 0, len(warnings)+1)
|
|
if existing != "" {
|
|
parts = append(parts, existing)
|
|
}
|
|
parts = append(parts, summarizeWarnings(warnings)...)
|
|
return strings.Join(parts, "; ")
|
|
}
|
|
|
|
const maxSyncWarningReasons = 20
|
|
|
|
func summarizeWarnings(warnings []string) []string {
|
|
counts := make(map[string]int)
|
|
for _, warning := range warnings {
|
|
warning = strings.TrimSpace(warning)
|
|
if warning == "" {
|
|
continue
|
|
}
|
|
counts[warning]++
|
|
}
|
|
if len(counts) == 0 {
|
|
return nil
|
|
}
|
|
type warningCount struct {
|
|
reason string
|
|
count int
|
|
}
|
|
items := make([]warningCount, 0, len(counts))
|
|
for reason, count := range counts {
|
|
items = append(items, warningCount{reason: reason, count: count})
|
|
}
|
|
sort.Slice(items, func(i, j int) bool {
|
|
if items[i].count != items[j].count {
|
|
return items[i].count > items[j].count
|
|
}
|
|
return items[i].reason < items[j].reason
|
|
})
|
|
limit := len(items)
|
|
if limit > maxSyncWarningReasons {
|
|
limit = maxSyncWarningReasons
|
|
}
|
|
summary := make([]string, 0, limit+1)
|
|
for _, item := range items[:limit] {
|
|
if item.count == 1 {
|
|
summary = append(summary, item.reason)
|
|
continue
|
|
}
|
|
summary = append(summary, fmt.Sprintf("%s (%d items)", item.reason, item.count))
|
|
}
|
|
if remaining := len(items) - limit; remaining > 0 {
|
|
summary = append(summary, fmt.Sprintf("%d more unmatched reasons omitted", remaining))
|
|
}
|
|
return summary
|
|
}
|
|
|
|
type ImportWatchedResult struct {
|
|
Found int
|
|
Imported int
|
|
Unmatched int
|
|
Warnings []string
|
|
}
|
|
|
|
func (s *Service) ImportWatched(
|
|
ctx context.Context,
|
|
conn Connection,
|
|
cfg ServerConfig,
|
|
importer WatchedImporter,
|
|
) (ImportWatchedResult, error) {
|
|
if s.matcher == nil {
|
|
return ImportWatchedResult{}, fmt.Errorf("watch provider matcher is not configured")
|
|
}
|
|
if s.watchState == nil {
|
|
return ImportWatchedResult{}, fmt.Errorf("watch state service is not configured")
|
|
}
|
|
batch, err := fetchWatchedImportBatch(ctx, cfg, conn, importer)
|
|
if err != nil {
|
|
return ImportWatchedResult{}, err
|
|
}
|
|
rows := batch.Rows
|
|
result := ImportWatchedResult{Found: len(rows), Warnings: append([]string{}, batch.Warnings...)}
|
|
for _, row := range rows {
|
|
match, reason, err := s.matcher.Match(ctx, row.HistoryRecord())
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if match == nil {
|
|
result.Unmatched++
|
|
if reason != "" {
|
|
result.Warnings = append(result.Warnings, reason)
|
|
}
|
|
continue
|
|
}
|
|
if row.LastWatchedAt == nil {
|
|
continue
|
|
}
|
|
duration, _ := s.mediaDuration(ctx, match.MediaItemID)
|
|
created, err := s.watchState.RecordImportedHistoryWithSource(
|
|
ctx,
|
|
conn.UserID,
|
|
conn.ProfileID,
|
|
match.MediaItemID,
|
|
duration,
|
|
true,
|
|
row.LastWatchedAt,
|
|
historySourceForProvider(importer),
|
|
)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if created {
|
|
result.Imported++
|
|
}
|
|
}
|
|
now := s.now()
|
|
conn.LastInboundSyncAt = &now
|
|
conn.LastError = ""
|
|
conn.SyncCursors = mergeSyncCursors(conn.SyncCursors, batch.UpdatedCursors)
|
|
if _, err := s.repo.UpsertConnection(ctx, conn); err != nil {
|
|
return result, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func fetchWatchedImportBatch(
|
|
ctx context.Context,
|
|
cfg ServerConfig,
|
|
conn Connection,
|
|
importer WatchedImporter,
|
|
) (WatchedImportBatch, error) {
|
|
if batchImporter, ok := importer.(WatchedBatchImporter); ok {
|
|
return batchImporter.FetchWatchedBatch(ctx, cfg, conn)
|
|
}
|
|
rows, err := importer.FetchWatched(ctx, cfg, conn)
|
|
if err != nil {
|
|
return WatchedImportBatch{}, err
|
|
}
|
|
return WatchedImportBatch{Rows: rows}, nil
|
|
}
|
|
|
|
func (s *Service) mediaDuration(ctx context.Context, mediaItemID string) (float64, error) {
|
|
type durationResolver interface {
|
|
GetMediaDuration(ctx context.Context, mediaItemID string) (float64, error)
|
|
}
|
|
resolver, ok := s.repo.(durationResolver)
|
|
if !ok {
|
|
return 0, nil
|
|
}
|
|
return resolver.GetMediaDuration(ctx, mediaItemID)
|
|
}
|
|
|
|
type ImportProgressResult struct {
|
|
Found int
|
|
Imported int
|
|
Skipped int
|
|
Unmatched int
|
|
Warnings []string
|
|
}
|
|
|
|
func (s *Service) ImportProgress(
|
|
ctx context.Context,
|
|
conn Connection,
|
|
cfg ServerConfig,
|
|
importer ProgressImporter,
|
|
) (ImportProgressResult, error) {
|
|
if s.matcher == nil {
|
|
return ImportProgressResult{}, fmt.Errorf("watch provider matcher is not configured")
|
|
}
|
|
if s.storeProvider == nil {
|
|
return ImportProgressResult{}, fmt.Errorf("user store provider is not configured")
|
|
}
|
|
store, err := s.storeProvider.ForUser(ctx, conn.UserID)
|
|
if err != nil {
|
|
return ImportProgressResult{}, fmt.Errorf("open user store: %w", err)
|
|
}
|
|
batch, err := fetchProgressImportBatch(ctx, cfg, conn, importer)
|
|
if err != nil {
|
|
return ImportProgressResult{}, err
|
|
}
|
|
rows := batch.Rows
|
|
result := ImportProgressResult{Found: len(rows), Warnings: append([]string{}, batch.Warnings...)}
|
|
for _, row := range rows {
|
|
match, reason, err := s.matcher.Match(ctx, row.HistoryRecord())
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if match == nil {
|
|
result.Unmatched++
|
|
if reason != "" {
|
|
result.Warnings = append(result.Warnings, reason)
|
|
}
|
|
continue
|
|
}
|
|
duration, err := s.mediaDuration(ctx, match.MediaItemID)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if duration <= 0 {
|
|
result.Skipped++
|
|
continue
|
|
}
|
|
if newerHistory, err := hasVisibleCompletedHistoryAtOrAfter(ctx, store, conn.ProfileID, match.MediaItemID, row.PausedAt); err != nil {
|
|
return result, err
|
|
} else if newerHistory {
|
|
result.Skipped++
|
|
continue
|
|
}
|
|
position := duration * row.ProgressPercent / 100
|
|
wrote, err := store.SetProgressIfNewer(ctx, conn.ProfileID, match.MediaItemID, position, duration, false, row.PausedAt)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if wrote {
|
|
result.Imported++
|
|
} else {
|
|
result.Skipped++
|
|
}
|
|
}
|
|
now := s.now()
|
|
conn.LastProgressSyncAt = &now
|
|
conn.LastError = ""
|
|
conn.SyncCursors = mergeSyncCursors(conn.SyncCursors, batch.UpdatedCursors)
|
|
if _, err := s.repo.UpsertConnection(ctx, conn); err != nil {
|
|
return result, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func fetchProgressImportBatch(
|
|
ctx context.Context,
|
|
cfg ServerConfig,
|
|
conn Connection,
|
|
importer ProgressImporter,
|
|
) (ProgressImportBatch, error) {
|
|
if batchImporter, ok := importer.(ProgressBatchImporter); ok {
|
|
return batchImporter.FetchProgressBatch(ctx, cfg, conn)
|
|
}
|
|
rows, err := importer.FetchProgress(ctx, cfg, conn)
|
|
if err != nil {
|
|
return ProgressImportBatch{}, err
|
|
}
|
|
return ProgressImportBatch{Rows: rows}, nil
|
|
}
|
|
|
|
type ImportFavoritesResult struct {
|
|
Found int
|
|
Imported int
|
|
Unmatched int
|
|
Removed int
|
|
Warnings []string
|
|
}
|
|
|
|
func (s *Service) ImportFavorites(
|
|
ctx context.Context,
|
|
conn Connection,
|
|
cfg ServerConfig,
|
|
importer FavoriteImporter,
|
|
) (ImportFavoritesResult, error) {
|
|
if s.matcher == nil {
|
|
return ImportFavoritesResult{}, fmt.Errorf("watch provider matcher is not configured")
|
|
}
|
|
if s.storeProvider == nil {
|
|
return ImportFavoritesResult{}, fmt.Errorf("user store provider is not configured")
|
|
}
|
|
store, err := s.storeProvider.ForUser(ctx, conn.UserID)
|
|
if err != nil {
|
|
return ImportFavoritesResult{}, fmt.Errorf("open user store: %w", err)
|
|
}
|
|
batch, err := fetchFavoriteImportBatch(ctx, cfg, conn, importer)
|
|
if err != nil {
|
|
return ImportFavoritesResult{}, err
|
|
}
|
|
rows := batch.Rows
|
|
result := ImportFavoritesResult{Found: len(rows), Warnings: append([]string{}, batch.Warnings...)}
|
|
seenRemoteKeys := make(map[string]bool, len(rows))
|
|
states := make([]FavoriteState, 0, len(rows))
|
|
for _, row := range rows {
|
|
match, reason, err := s.matcher.Match(ctx, row.HistoryRecord())
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if match == nil {
|
|
result.Unmatched++
|
|
if reason != "" {
|
|
result.Warnings = append(result.Warnings, reason)
|
|
}
|
|
continue
|
|
}
|
|
if err := store.AddFavoriteAt(ctx, conn.ProfileID, match.MediaItemID, row.FavoritedAt); err != nil {
|
|
return result, err
|
|
}
|
|
result.Imported++
|
|
key := row.ProviderItemKey
|
|
if key == "" {
|
|
key = providerItemKeyForRemoteFavorite(row)
|
|
}
|
|
if key != "" {
|
|
seenRemoteKeys[key] = true
|
|
}
|
|
favoritedAt := row.FavoritedAt
|
|
states = append(states, FavoriteState{
|
|
ConnectionID: conn.ID,
|
|
MediaItemID: match.MediaItemID,
|
|
ProviderItemKey: key,
|
|
Kind: row.Kind,
|
|
Title: row.Title,
|
|
Year: row.Year,
|
|
RemotePresent: true,
|
|
LocalPresent: true,
|
|
LastSeenRemoteAt: &favoritedAt,
|
|
LastSeenLocalAt: &favoritedAt,
|
|
})
|
|
}
|
|
if err := s.repo.UpsertFavoriteStates(ctx, states); err != nil {
|
|
return result, err
|
|
}
|
|
if err := s.reconcileMissingRemoteFavorites(ctx, conn, store, seenRemoteKeys, &result); err != nil {
|
|
return result, err
|
|
}
|
|
now := s.now()
|
|
conn.LastFavoritesSyncAt = &now
|
|
conn.LastError = ""
|
|
conn.SyncCursors = mergeSyncCursors(conn.SyncCursors, batch.UpdatedCursors)
|
|
if _, err := s.repo.UpsertConnection(ctx, conn); err != nil {
|
|
return result, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func fetchFavoriteImportBatch(
|
|
ctx context.Context,
|
|
cfg ServerConfig,
|
|
conn Connection,
|
|
importer FavoriteImporter,
|
|
) (FavoriteImportBatch, error) {
|
|
if batchImporter, ok := importer.(FavoriteBatchImporter); ok {
|
|
return batchImporter.FetchFavoritesBatch(ctx, cfg, conn)
|
|
}
|
|
rows, err := importer.FetchFavorites(ctx, cfg, conn)
|
|
if err != nil {
|
|
return FavoriteImportBatch{}, err
|
|
}
|
|
return FavoriteImportBatch{Rows: rows}, nil
|
|
}
|
|
|
|
func (s *Service) reconcileMissingRemoteFavorites(ctx context.Context, conn Connection, store userstore.UserStore, seenRemoteKeys map[string]bool, result *ImportFavoritesResult) error {
|
|
states, err := s.repo.ListFavoriteStates(ctx, conn.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := s.now()
|
|
for _, state := range states {
|
|
if !state.RemotePresent || state.ProviderItemKey == "" || seenRemoteKeys[state.ProviderItemKey] {
|
|
continue
|
|
}
|
|
if conn.SyncFavoriteRemovalsEnabled && state.LocalPresent {
|
|
if err := store.RemoveFavorite(ctx, conn.ProfileID, state.MediaItemID); err != nil {
|
|
return err
|
|
}
|
|
if err := s.repo.MarkFavoriteLocalRemoved(ctx, conn.ID, state.MediaItemID, now); err != nil {
|
|
return err
|
|
}
|
|
result.Removed++
|
|
}
|
|
if err := s.repo.MarkFavoriteRemoteRemoved(ctx, conn.ID, state.MediaItemID, now); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func mergeSyncCursors(existing map[string]string, updates map[string]string) map[string]string {
|
|
merged := make(map[string]string, len(existing)+len(updates))
|
|
for key, value := range existing {
|
|
merged[key] = value
|
|
}
|
|
for key, value := range updates {
|
|
if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" {
|
|
continue
|
|
}
|
|
merged[key] = value
|
|
}
|
|
return merged
|
|
}
|
|
|
|
type completedHistoryLister interface {
|
|
ListCompletedHistory(ctx context.Context, query userstore.CompletedHistoryQuery) ([]userstore.WatchHistoryEntry, error)
|
|
}
|
|
|
|
const completedHistoryPageSize = 500
|
|
|
|
func listAllCompletedHistory(ctx context.Context, store completedHistoryLister, query userstore.CompletedHistoryQuery) ([]userstore.WatchHistoryEntry, error) {
|
|
var all []userstore.WatchHistoryEntry
|
|
for offset := 0; ; offset += completedHistoryPageSize {
|
|
query.Limit = completedHistoryPageSize
|
|
query.Offset = offset
|
|
rows, err := store.ListCompletedHistory(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
all = append(all, rows...)
|
|
if len(rows) < completedHistoryPageSize {
|
|
return all, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func hasVisibleCompletedHistoryAtOrAfter(ctx context.Context, store completedHistoryLister, profileID, mediaItemID string, at time.Time) (bool, error) {
|
|
for offset := 0; ; offset += completedHistoryPageSize {
|
|
rows, err := store.ListCompletedHistory(ctx, userstore.CompletedHistoryQuery{
|
|
ProfileID: profileID,
|
|
MediaItemIDs: []string{mediaItemID},
|
|
Limit: completedHistoryPageSize,
|
|
Offset: offset,
|
|
})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, row := range rows {
|
|
watchedAt, err := time.Parse(time.RFC3339, row.WatchedAt)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if !watchedAt.Before(at) {
|
|
return true, nil
|
|
}
|
|
}
|
|
if len(rows) < completedHistoryPageSize {
|
|
return false, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
const tokenRefreshSkew = 5 * time.Minute
|
|
|
|
func (s *Service) refreshConnectionIfNeeded(ctx context.Context, provider Provider, cfg ServerConfig, conn Connection) (Connection, error) {
|
|
if conn.TokenExpiresAt == nil || conn.TokenExpiresAt.After(s.now().Add(tokenRefreshSkew)) {
|
|
return conn, nil
|
|
}
|
|
if conn.RefreshToken == "" {
|
|
return Connection{}, fmt.Errorf("watch provider token expired and refresh token is missing")
|
|
}
|
|
authProvider, ok := provider.(AuthProvider)
|
|
if !ok {
|
|
return Connection{}, fmt.Errorf("provider %q does not support token refresh", conn.Provider)
|
|
}
|
|
tokens, err := authProvider.RefreshToken(ctx, cfg, conn)
|
|
if err != nil {
|
|
return Connection{}, fmt.Errorf("refresh %s token: %w", conn.Provider, err)
|
|
}
|
|
if tokens.AccessToken != "" {
|
|
conn.AccessToken = tokens.AccessToken
|
|
}
|
|
if tokens.RefreshToken != "" {
|
|
conn.RefreshToken = tokens.RefreshToken
|
|
}
|
|
if tokens.TokenExpiresAt != nil {
|
|
conn.TokenExpiresAt = tokens.TokenExpiresAt
|
|
}
|
|
conn.LastError = ""
|
|
return s.repo.UpsertConnection(ctx, conn)
|
|
}
|
|
|
|
type ExportWatchedResult struct {
|
|
LocalFound int
|
|
RemoteFound int
|
|
Queued int
|
|
RemotePresent int
|
|
Sent int
|
|
Failed int
|
|
}
|
|
|
|
type ExportFavoritesResult struct {
|
|
LocalFound int
|
|
Queued int
|
|
Sent int
|
|
Failed int
|
|
Warnings []string
|
|
}
|
|
|
|
func (s *Service) ExportWatched(
|
|
ctx context.Context,
|
|
conn Connection,
|
|
cfg ServerConfig,
|
|
exporter WatchedExporter,
|
|
) (ExportWatchedResult, error) {
|
|
if s.storeProvider == nil {
|
|
return ExportWatchedResult{}, fmt.Errorf("user store provider is not configured")
|
|
}
|
|
store, err := s.storeProvider.ForUser(ctx, conn.UserID)
|
|
if err != nil {
|
|
return ExportWatchedResult{}, fmt.Errorf("open user store: %w", err)
|
|
}
|
|
remote, err := exporter.FetchHistory(ctx, cfg, conn)
|
|
if err != nil {
|
|
return ExportWatchedResult{}, err
|
|
}
|
|
result := ExportWatchedResult{RemoteFound: len(remote)}
|
|
|
|
historyRows, err := listAllCompletedHistory(ctx, store, userstore.CompletedHistoryQuery{
|
|
ProfileID: conn.ProfileID,
|
|
ExcludeSources: []userstore.WatchHistorySource{historySourceForProvider(exporter)},
|
|
})
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
local := make([]LocalPlay, 0, len(historyRows))
|
|
for _, row := range historyRows {
|
|
play, ok := localPlayFromHistory(row)
|
|
if !ok {
|
|
continue
|
|
}
|
|
local = append(local, play)
|
|
}
|
|
result.LocalFound = len(local)
|
|
|
|
exports := reconcileHistoryExports(conn.ID, local, remote)
|
|
for _, export := range exports {
|
|
if export.Status == "remote_present" {
|
|
result.RemotePresent++
|
|
} else if export.Status == "pending" {
|
|
result.Queued++
|
|
}
|
|
}
|
|
if err := s.repo.UpsertHistoryExports(ctx, exports); err != nil {
|
|
return result, err
|
|
}
|
|
|
|
localByHistoryID := make(map[string]LocalPlay, len(local))
|
|
for _, play := range local {
|
|
localByHistoryID[play.HistoryID] = play
|
|
}
|
|
for {
|
|
pending, err := s.repo.ListPendingHistoryExports(ctx, conn.ID, 100)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if len(pending) == 0 {
|
|
break
|
|
}
|
|
pendingPlays := make([]LocalPlay, 0, len(pending))
|
|
exportByHistoryID := make(map[string]HistoryExport, len(pending))
|
|
progressed := false
|
|
for _, export := range pending {
|
|
play, ok := localByHistoryID[export.HistoryID]
|
|
if !ok {
|
|
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, "not_found", "local history entry not found"); err != nil {
|
|
return result, err
|
|
}
|
|
progressed = true
|
|
continue
|
|
}
|
|
pendingPlays = append(pendingPlays, play)
|
|
exportByHistoryID[export.HistoryID] = export
|
|
}
|
|
if len(pendingPlays) == 0 {
|
|
continue
|
|
}
|
|
exportResult, err := exporter.ExportHistory(ctx, cfg, conn, pendingPlays)
|
|
if err != nil {
|
|
for _, export := range pending {
|
|
_ = s.repo.MarkHistoryExportStatus(ctx, export.ID, "failed", err.Error())
|
|
}
|
|
result.Failed += len(pending)
|
|
return result, err
|
|
}
|
|
for _, historyID := range exportResult.Sent {
|
|
export := exportByHistoryID[historyID]
|
|
if export.ID == "" {
|
|
continue
|
|
}
|
|
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, "sent", ""); err != nil {
|
|
return result, err
|
|
}
|
|
result.Sent++
|
|
progressed = true
|
|
}
|
|
for _, historyID := range exportResult.NotFound {
|
|
export := exportByHistoryID[historyID]
|
|
if export.ID == "" {
|
|
continue
|
|
}
|
|
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, "not_found", "provider item not found"); err != nil {
|
|
return result, err
|
|
}
|
|
progressed = true
|
|
}
|
|
for historyID, message := range exportResult.Failed {
|
|
export := exportByHistoryID[historyID]
|
|
if export.ID == "" {
|
|
continue
|
|
}
|
|
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, "failed", message); err != nil {
|
|
return result, err
|
|
}
|
|
result.Failed++
|
|
progressed = true
|
|
}
|
|
if !progressed {
|
|
break
|
|
}
|
|
}
|
|
|
|
now := s.now()
|
|
conn.LastOutboundSyncAt = &now
|
|
conn.LastError = ""
|
|
if _, err := s.repo.UpsertConnection(ctx, conn); err != nil {
|
|
return result, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) ExportFavorites(
|
|
ctx context.Context,
|
|
conn Connection,
|
|
cfg ServerConfig,
|
|
exporter FavoriteExporter,
|
|
) (ExportFavoritesResult, error) {
|
|
if s.storeProvider == nil {
|
|
return ExportFavoritesResult{}, fmt.Errorf("user store provider is not configured")
|
|
}
|
|
store, err := s.storeProvider.ForUser(ctx, conn.UserID)
|
|
if err != nil {
|
|
return ExportFavoritesResult{}, fmt.Errorf("open user store: %w", err)
|
|
}
|
|
rows, err := store.ListFavorites(ctx, conn.ProfileID, 10000, 0)
|
|
if err != nil {
|
|
return ExportFavoritesResult{}, err
|
|
}
|
|
result := ExportFavoritesResult{LocalFound: len(rows)}
|
|
favorites, states, warnings, err := s.localFavoritesFromRows(ctx, conn, rows)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
result.Warnings = append(result.Warnings, warnings...)
|
|
if err := s.repo.UpsertFavoriteStates(ctx, states); err != nil {
|
|
return result, err
|
|
}
|
|
for {
|
|
pending, err := s.repo.ListPendingFavoriteExports(ctx, conn.ID, 100)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if len(pending) == 0 {
|
|
break
|
|
}
|
|
byMedia := make(map[string]LocalFavorite, len(favorites))
|
|
for _, favorite := range favorites {
|
|
byMedia[favorite.MediaItemID] = favorite
|
|
}
|
|
toSend := make([]LocalFavorite, 0, len(pending))
|
|
for _, state := range pending {
|
|
favorite, ok := byMedia[state.MediaItemID]
|
|
if !ok {
|
|
if err := s.repo.MarkFavoriteLocalRemoved(ctx, conn.ID, state.MediaItemID, s.now()); err != nil {
|
|
return result, err
|
|
}
|
|
continue
|
|
}
|
|
toSend = append(toSend, favorite)
|
|
}
|
|
if len(toSend) == 0 {
|
|
continue
|
|
}
|
|
result.Queued += len(toSend)
|
|
exportResult, err := exporter.ExportFavorites(ctx, cfg, conn, toSend)
|
|
if err != nil {
|
|
for _, favorite := range toSend {
|
|
_ = s.repo.MarkFavoriteError(ctx, conn.ID, favorite.MediaItemID, err.Error())
|
|
}
|
|
result.Failed += len(toSend)
|
|
return result, err
|
|
}
|
|
now := s.now()
|
|
sent := exportResultSentSet(exportResult)
|
|
for _, favorite := range toSend {
|
|
if sent[favorite.MediaItemID] || sent[favorite.ProviderItemKey] {
|
|
if err := s.repo.MarkFavoriteExported(ctx, conn.ID, favorite.MediaItemID, now); err != nil {
|
|
return result, err
|
|
}
|
|
result.Sent++
|
|
continue
|
|
}
|
|
if containsString(exportResult.NotFound, favorite.MediaItemID) || containsString(exportResult.NotFound, favorite.ProviderItemKey) {
|
|
msg := "favorite not found by provider"
|
|
if err := s.repo.MarkFavoriteError(ctx, conn.ID, favorite.MediaItemID, msg); err != nil {
|
|
return result, err
|
|
}
|
|
result.Warnings = append(result.Warnings, msg+": "+favorite.MediaItemID)
|
|
}
|
|
}
|
|
}
|
|
now := s.now()
|
|
conn.LastFavoritesSyncAt = &now
|
|
conn.LastError = ""
|
|
if _, err := s.repo.UpsertConnection(ctx, conn); err != nil {
|
|
return result, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) RemovePendingFavorites(ctx context.Context, conn Connection, cfg ServerConfig, remover FavoriteRemover) (int, error) {
|
|
removed := 0
|
|
for {
|
|
pending, err := s.repo.ListPendingFavoriteRemovals(ctx, conn.ID, 100)
|
|
if err != nil {
|
|
return removed, err
|
|
}
|
|
if len(pending) == 0 {
|
|
return removed, nil
|
|
}
|
|
favorites := make([]LocalFavorite, 0, len(pending))
|
|
for _, state := range pending {
|
|
favorites = append(favorites, LocalFavorite{
|
|
MediaItemID: state.MediaItemID,
|
|
ProviderItemKey: state.ProviderItemKey,
|
|
Kind: state.Kind,
|
|
Title: state.Title,
|
|
Year: state.Year,
|
|
})
|
|
}
|
|
result, err := remover.RemoveFavorites(ctx, cfg, conn, favorites)
|
|
if err != nil {
|
|
for _, favorite := range favorites {
|
|
_ = s.repo.MarkFavoriteError(ctx, conn.ID, favorite.MediaItemID, err.Error())
|
|
}
|
|
return removed, err
|
|
}
|
|
now := s.now()
|
|
sent := exportResultSentSet(result)
|
|
for _, favorite := range favorites {
|
|
if sent[favorite.MediaItemID] || sent[favorite.ProviderItemKey] {
|
|
if err := s.repo.MarkFavoriteRemoteRemoved(ctx, conn.ID, favorite.MediaItemID, now); err != nil {
|
|
return removed, err
|
|
}
|
|
removed++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) localFavoritesFromRows(ctx context.Context, conn Connection, rows []userstore.Favorite) ([]LocalFavorite, []FavoriteState, []string, error) {
|
|
ids := make([]string, 0, len(rows))
|
|
addedAtByID := make(map[string]time.Time, len(rows))
|
|
for _, row := range rows {
|
|
ids = append(ids, row.MediaItemID)
|
|
if addedAt, err := time.Parse(time.RFC3339, row.AddedAt); err == nil {
|
|
addedAtByID[row.MediaItemID] = addedAt
|
|
}
|
|
}
|
|
type favoriteMediaResolver interface {
|
|
GetFavoriteMediaItems(ctx context.Context, mediaItemIDs []string) (map[string]LocalFavorite, error)
|
|
}
|
|
resolver, ok := s.repo.(favoriteMediaResolver)
|
|
if !ok {
|
|
return nil, nil, nil, fmt.Errorf("favorite media resolver is not configured")
|
|
}
|
|
items, err := resolver.GetFavoriteMediaItems(ctx, ids)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
favorites := make([]LocalFavorite, 0, len(rows))
|
|
states := make([]FavoriteState, 0, len(rows))
|
|
var warnings []string
|
|
for _, row := range rows {
|
|
favorite, ok := items[row.MediaItemID]
|
|
if !ok {
|
|
warnings = append(warnings, "favorite media item not found: "+row.MediaItemID)
|
|
continue
|
|
}
|
|
favorite.FavoritedAt = addedAtByID[row.MediaItemID]
|
|
if favorite.FavoritedAt.IsZero() {
|
|
favorite.FavoritedAt = s.now()
|
|
}
|
|
if favorite.Kind != historyimport.KindMovie && favorite.Kind != historyimport.KindSeries {
|
|
warnings = append(warnings, "favorite kind is not supported by provider: "+row.MediaItemID)
|
|
continue
|
|
}
|
|
if favorite.ProviderItemKey == "" {
|
|
warnings = append(warnings, "favorite has no provider ids: "+row.MediaItemID)
|
|
continue
|
|
}
|
|
favorites = append(favorites, favorite)
|
|
favoritedAt := favorite.FavoritedAt
|
|
states = append(states, FavoriteState{
|
|
ConnectionID: conn.ID,
|
|
MediaItemID: favorite.MediaItemID,
|
|
ProviderItemKey: favorite.ProviderItemKey,
|
|
Kind: favorite.Kind,
|
|
Title: favorite.Title,
|
|
Year: favorite.Year,
|
|
RemotePresent: false,
|
|
LocalPresent: true,
|
|
LastSeenLocalAt: &favoritedAt,
|
|
})
|
|
}
|
|
return favorites, states, warnings, nil
|
|
}
|
|
|
|
func (s *Service) exportLocalPlays(
|
|
ctx context.Context,
|
|
conn Connection,
|
|
cfg ServerConfig,
|
|
exporter WatchedExporter,
|
|
local []LocalPlay,
|
|
) error {
|
|
exports := make([]HistoryExport, 0, len(local))
|
|
for _, play := range local {
|
|
if play.HistoryID == "" || play.ProviderItemKey == "" {
|
|
continue
|
|
}
|
|
exports = append(exports, HistoryExport{
|
|
ConnectionID: conn.ID,
|
|
HistoryID: play.HistoryID,
|
|
MediaItemID: play.MediaItemID,
|
|
WatchedAt: play.WatchedAt,
|
|
ProviderItemKey: play.ProviderItemKey,
|
|
Status: "pending",
|
|
})
|
|
}
|
|
if len(exports) == 0 {
|
|
return nil
|
|
}
|
|
if err := s.repo.UpsertHistoryExports(ctx, exports); err != nil {
|
|
return err
|
|
}
|
|
pending, err := s.repo.ListPendingHistoryExports(ctx, conn.ID, 100)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
localByHistoryID := make(map[string]LocalPlay, len(local))
|
|
for _, play := range local {
|
|
localByHistoryID[play.HistoryID] = play
|
|
}
|
|
exportByHistoryID := make(map[string]HistoryExport, len(pending))
|
|
pendingPlays := make([]LocalPlay, 0, len(pending))
|
|
for _, export := range pending {
|
|
play, ok := localByHistoryID[export.HistoryID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
pendingPlays = append(pendingPlays, play)
|
|
exportByHistoryID[export.HistoryID] = export
|
|
}
|
|
if len(pendingPlays) == 0 {
|
|
return nil
|
|
}
|
|
exportResult, err := exporter.ExportHistory(ctx, cfg, conn, pendingPlays)
|
|
if err != nil {
|
|
for _, export := range exportByHistoryID {
|
|
_ = s.repo.MarkHistoryExportStatus(ctx, export.ID, "failed", err.Error())
|
|
}
|
|
return err
|
|
}
|
|
for _, historyID := range exportResult.Sent {
|
|
export := exportByHistoryID[historyID]
|
|
if export.ID == "" {
|
|
continue
|
|
}
|
|
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, "sent", ""); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, historyID := range exportResult.NotFound {
|
|
export := exportByHistoryID[historyID]
|
|
if export.ID == "" {
|
|
continue
|
|
}
|
|
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, "not_found", "provider item not found"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for historyID, message := range exportResult.Failed {
|
|
export := exportByHistoryID[historyID]
|
|
if export.ID == "" {
|
|
continue
|
|
}
|
|
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, "failed", message); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
now := s.now()
|
|
conn.LastOutboundSyncAt = &now
|
|
conn.LastError = ""
|
|
if _, err := s.repo.UpsertConnection(ctx, conn); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func reconcileHistoryExports(connectionID string, local []LocalPlay, remote []RemotePlay) []HistoryExport {
|
|
remoteExact := make(map[string]struct{}, len(remote))
|
|
for _, play := range remote {
|
|
remoteExact[remotePlayKey(play.ProviderItemKey, play.WatchedAt)] = struct{}{}
|
|
}
|
|
exports := make([]HistoryExport, 0, len(local))
|
|
for _, play := range local {
|
|
status := "pending"
|
|
if _, ok := remoteExact[remotePlayKey(play.ProviderItemKey, play.WatchedAt)]; ok {
|
|
status = "remote_present"
|
|
}
|
|
exports = append(exports, HistoryExport{
|
|
ConnectionID: connectionID,
|
|
HistoryID: play.HistoryID,
|
|
MediaItemID: play.MediaItemID,
|
|
WatchedAt: play.WatchedAt,
|
|
ProviderItemKey: play.ProviderItemKey,
|
|
Status: status,
|
|
})
|
|
}
|
|
return exports
|
|
}
|
|
|
|
func remotePlayKey(providerItemKey string, watchedAt time.Time) string {
|
|
return providerItemKey + "|" + watchedAt.UTC().Truncate(time.Second).Format(time.RFC3339)
|
|
}
|
|
|
|
func localPlayFromHistory(row userstore.WatchHistoryEntry) (LocalPlay, bool) {
|
|
watchedAt, err := time.Parse(time.RFC3339, row.WatchedAt)
|
|
if err != nil || row.ID == "" {
|
|
return LocalPlay{}, false
|
|
}
|
|
play := LocalPlay{
|
|
HistoryID: row.ID,
|
|
MediaItemID: row.MediaItemID,
|
|
WatchedAt: watchedAt,
|
|
DurationSeconds: row.DurationSeconds,
|
|
Source: row.Source,
|
|
Kind: row.Identity.StableType,
|
|
SeasonNumber: intValue(row.Identity.Season),
|
|
EpisodeNumber: intValue(row.Identity.Episode),
|
|
}
|
|
if row.Identity.ProviderIDs != nil {
|
|
play.IMDbID = row.Identity.ProviderIDs["imdb"]
|
|
play.TMDBID = row.Identity.ProviderIDs["tmdb"]
|
|
play.TVDBID = row.Identity.ProviderIDs["tvdb"]
|
|
}
|
|
if row.Identity.SeriesProviderIDs != nil {
|
|
play.SeriesIMDbID = row.Identity.SeriesProviderIDs["imdb"]
|
|
play.SeriesTMDBID = row.Identity.SeriesProviderIDs["tmdb"]
|
|
play.SeriesTVDBID = row.Identity.SeriesProviderIDs["tvdb"]
|
|
}
|
|
play.ProviderItemKey = providerItemKeyForLocalPlay(play)
|
|
return play, play.ProviderItemKey != ""
|
|
}
|
|
|
|
func LocalPlaysFromHistory(entries []userstore.WatchHistoryEntry) []LocalPlay {
|
|
plays := make([]LocalPlay, 0, len(entries))
|
|
for _, entry := range entries {
|
|
play, ok := localPlayFromHistory(entry)
|
|
if !ok {
|
|
continue
|
|
}
|
|
plays = append(plays, play)
|
|
}
|
|
return plays
|
|
}
|
|
|
|
func providerItemKeyForLocalPlay(play LocalPlay) string {
|
|
if play.Kind == historyimport.KindEpisode {
|
|
switch {
|
|
case play.TVDBID != "":
|
|
return "tvdb:" + play.TVDBID
|
|
case play.TMDBID != "":
|
|
return "tmdb:" + play.TMDBID
|
|
case play.SeriesTVDBID != "":
|
|
return fmt.Sprintf("show:tvdb:%s:s%d:e%d", play.SeriesTVDBID, play.SeasonNumber, play.EpisodeNumber)
|
|
case play.SeriesTMDBID != "":
|
|
return fmt.Sprintf("show:tmdb:%s:s%d:e%d", play.SeriesTMDBID, play.SeasonNumber, play.EpisodeNumber)
|
|
case play.SeriesIMDbID != "":
|
|
return fmt.Sprintf("show:imdb:%s:s%d:e%d", play.SeriesIMDbID, play.SeasonNumber, play.EpisodeNumber)
|
|
}
|
|
}
|
|
switch {
|
|
case play.IMDbID != "":
|
|
return "imdb:" + play.IMDbID
|
|
case play.TMDBID != "":
|
|
return "tmdb:" + play.TMDBID
|
|
case play.TVDBID != "":
|
|
return "tvdb:" + play.TVDBID
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func providerItemKeyForLocalFavorite(favorite LocalFavorite) string {
|
|
switch {
|
|
case favorite.IMDbID != "":
|
|
return "imdb:" + favorite.IMDbID
|
|
case favorite.TMDBID != "":
|
|
return "tmdb:" + favorite.TMDBID
|
|
case favorite.TVDBID != "":
|
|
return "tvdb:" + favorite.TVDBID
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func providerItemKeyForRemoteFavorite(favorite RemoteFavorite) string {
|
|
switch {
|
|
case favorite.IMDbID != "":
|
|
return "imdb:" + favorite.IMDbID
|
|
case favorite.TMDBID != "":
|
|
return "tmdb:" + favorite.TMDBID
|
|
case favorite.TVDBID != "":
|
|
return "tvdb:" + favorite.TVDBID
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func exportResultSentSet(result ExportResult) map[string]bool {
|
|
sent := make(map[string]bool, len(result.Sent))
|
|
for _, value := range result.Sent {
|
|
if value != "" {
|
|
sent[value] = true
|
|
}
|
|
}
|
|
return sent
|
|
}
|
|
|
|
func containsString(values []string, candidate string) bool {
|
|
if candidate == "" {
|
|
return false
|
|
}
|
|
for _, value := range values {
|
|
if value == candidate {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func historySourceForProvider(provider any) userstore.WatchHistorySource {
|
|
if sourceProvider, ok := provider.(HistorySourceProvider); ok {
|
|
if source := sourceProvider.HistorySource(); source != "" {
|
|
return source
|
|
}
|
|
}
|
|
return userstore.WatchHistorySourceImport
|
|
}
|
|
|
|
func intValue(value *int) int {
|
|
if value == nil {
|
|
return 0
|
|
}
|
|
return *value
|
|
}
|
|
|
|
func parseInt(value string) int {
|
|
parsed, _ := strconv.Atoi(value)
|
|
return parsed
|
|
}
|
|
|
|
func (s *Service) ScrobbleStart(ctx context.Context, event ScrobbleEvent) error {
|
|
return s.scrobble(ctx, event, "start")
|
|
}
|
|
|
|
func (s *Service) ScrobblePause(ctx context.Context, event ScrobbleEvent) error {
|
|
return s.scrobble(ctx, event, "pause")
|
|
}
|
|
|
|
func (s *Service) ScrobbleStop(ctx context.Context, event ScrobbleEvent) error {
|
|
return s.scrobble(ctx, event, "stop")
|
|
}
|
|
|
|
func (s *Service) scrobble(ctx context.Context, event ScrobbleEvent, action string) error {
|
|
if event.PlaybackSessionID == "" || event.UserID == 0 || event.ProfileID == "" {
|
|
return nil
|
|
}
|
|
conns, err := s.repo.ListScrobbleConnections(ctx, event.UserID, event.ProfileID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, conn := range conns {
|
|
provider, ok := s.registry.Get(conn.Provider)
|
|
if !ok || !provider.Capabilities().ScrobblePlayback {
|
|
continue
|
|
}
|
|
scrobbler, ok := provider.(Scrobbler)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if action == "start" {
|
|
if err := s.repo.UpsertScrobbleSession(ctx, event, conn.ID, action); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if err := s.repo.UpdateScrobbleSession(ctx, event.PlaybackSessionID, conn.ID, action, event.PositionSeconds, event.HistoryID, "", nil); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
cfg, err := s.serverConfig(ctx, conn.Provider)
|
|
if err != nil {
|
|
_ = s.repo.UpdateScrobbleSession(ctx, event.PlaybackSessionID, conn.ID, action, event.PositionSeconds, event.HistoryID, err.Error(), nil)
|
|
continue
|
|
}
|
|
conn, err = s.refreshConnectionIfNeeded(ctx, provider, cfg, conn)
|
|
if err != nil {
|
|
_ = s.repo.UpdateScrobbleSession(ctx, event.PlaybackSessionID, conn.ID, action, event.PositionSeconds, event.HistoryID, err.Error(), nil)
|
|
continue
|
|
}
|
|
s.dispatchScrobbleAsync(scrobbler, cfg, conn, event, action)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) dispatchScrobbleAsync(scrobbler Scrobbler, cfg ServerConfig, conn Connection, event ScrobbleEvent, action string) {
|
|
if ordered, ok := scrobbler.(OrderedScrobbler); ok {
|
|
key := ordered.ScrobbleOrderingKey(conn, event)
|
|
if strings.TrimSpace(key) != "" {
|
|
s.enqueueOrderedScrobble(key, func() {
|
|
s.dispatchScrobble(context.Background(), scrobbler, cfg, conn, event, action)
|
|
})
|
|
return
|
|
}
|
|
}
|
|
go s.dispatchScrobble(context.Background(), scrobbler, cfg, conn, event, action)
|
|
}
|
|
|
|
func (s *Service) enqueueOrderedScrobble(key string, dispatch func()) {
|
|
value, _ := s.scrobbleQueues.LoadOrStore(key, &scrobbleQueue{})
|
|
queue := value.(*scrobbleQueue)
|
|
|
|
queue.mu.Lock()
|
|
previous := queue.tail
|
|
current := make(chan struct{})
|
|
queue.tail = current
|
|
queue.mu.Unlock()
|
|
|
|
go func() {
|
|
if previous != nil {
|
|
<-previous
|
|
}
|
|
defer close(current)
|
|
dispatch()
|
|
}()
|
|
}
|
|
|
|
func (s *Service) dispatchScrobble(ctx context.Context, scrobbler Scrobbler, cfg ServerConfig, conn Connection, event ScrobbleEvent, action string) {
|
|
var err error
|
|
switch action {
|
|
case "pause":
|
|
err = scrobbler.Pause(ctx, cfg, conn, event)
|
|
case "stop":
|
|
err = scrobbler.Stop(ctx, cfg, conn, event)
|
|
default:
|
|
err = scrobbler.Start(ctx, cfg, conn, event)
|
|
}
|
|
if err != nil {
|
|
_ = s.repo.UpdateScrobbleSession(ctx, event.PlaybackSessionID, conn.ID, action, event.PositionSeconds, event.HistoryID, err.Error(), nil)
|
|
return
|
|
}
|
|
if action == "stop" {
|
|
stopSentAt := s.now()
|
|
_ = s.repo.UpdateScrobbleSession(ctx, event.PlaybackSessionID, conn.ID, action, event.PositionSeconds, event.HistoryID, "", &stopSentAt)
|
|
}
|
|
}
|
|
|
|
func (s *Service) SweepOpenScrobbles(ctx context.Context) error {
|
|
sessions, err := s.repo.ListOpenScrobbleSessions(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, session := range sessions {
|
|
conn, ok, err := s.repo.GetConnectionByID(ctx, session.ConnectionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
provider, ok := s.registry.Get(conn.Provider)
|
|
if !ok || !provider.Capabilities().ScrobblePlayback {
|
|
continue
|
|
}
|
|
scrobbler, ok := provider.(Scrobbler)
|
|
if !ok {
|
|
continue
|
|
}
|
|
cfg, err := s.serverConfig(ctx, conn.Provider)
|
|
if err != nil {
|
|
_ = s.repo.UpdateScrobbleSession(ctx, session.PlaybackSessionID, session.ConnectionID, "stop", session.LastProgress, session.HistoryID, err.Error(), nil)
|
|
continue
|
|
}
|
|
conn, err = s.refreshConnectionIfNeeded(ctx, provider, cfg, conn)
|
|
if err != nil {
|
|
_ = s.repo.UpdateScrobbleSession(ctx, session.PlaybackSessionID, session.ConnectionID, "stop", session.LastProgress, session.HistoryID, err.Error(), nil)
|
|
continue
|
|
}
|
|
s.dispatchScrobble(ctx, scrobbler, cfg, conn, scrobbleEventFromSession(session, conn, s.now()), "stop")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func scrobbleEventFromSession(session ScrobbleSession, conn Connection, occurredAt time.Time) ScrobbleEvent {
|
|
return ScrobbleEvent{
|
|
PlaybackSessionID: session.PlaybackSessionID,
|
|
UserID: conn.UserID,
|
|
ProfileID: conn.ProfileID,
|
|
MediaItemID: session.MediaItemID,
|
|
ProviderItemKey: session.ProviderItemKey,
|
|
Kind: session.Kind,
|
|
IMDbID: session.IMDbID,
|
|
TMDBID: session.TMDBID,
|
|
TVDBID: session.TVDBID,
|
|
SeriesIMDbID: session.SeriesIMDbID,
|
|
SeriesTMDBID: session.SeriesTMDBID,
|
|
SeriesTVDBID: session.SeriesTVDBID,
|
|
SeasonNumber: session.SeasonNumber,
|
|
EpisodeNumber: session.EpisodeNumber,
|
|
HistoryID: session.HistoryID,
|
|
PositionSeconds: session.LastProgress,
|
|
DurationSeconds: session.DurationSeconds,
|
|
Completed: session.Completed,
|
|
OccurredAt: occurredAt,
|
|
}
|
|
}
|
|
|
|
func authMethodOf(provider Provider) string {
|
|
if _, ok := provider.(APIKeyAuthProvider); ok {
|
|
return AuthMethodAPIKey
|
|
}
|
|
return AuthMethodDeviceCode
|
|
}
|
|
|
|
func (s *Service) serverConfig(ctx context.Context, providerKey string) (ServerConfig, error) {
|
|
if provider, ok := s.registry.Get(providerKey); ok && authMethodOf(provider) == AuthMethodAPIKey {
|
|
// API-key providers carry their credential on the connection itself
|
|
// and don't consult server settings. Return a zero config so sync
|
|
// callers that pass cfg through to provider methods keep working.
|
|
return ServerConfig{}, nil
|
|
}
|
|
|
|
clientID, err := s.repo.GetServerSetting(ctx, "watchsync."+providerKey+".client_id")
|
|
if err != nil {
|
|
return ServerConfig{}, err
|
|
}
|
|
clientSecret, err := s.repo.GetServerSetting(ctx, "watchsync."+providerKey+".client_secret")
|
|
if err != nil {
|
|
return ServerConfig{}, err
|
|
}
|
|
|
|
cfg := ServerConfig{ClientID: clientID, ClientSecret: clientSecret}
|
|
if !cfg.Configured() {
|
|
return ServerConfig{}, fmt.Errorf("%s credentials are not configured", providerKey)
|
|
}
|
|
return cfg, nil
|
|
}
|