package jellycompat import ( "context" "fmt" "time" "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/userstore" "github.com/Silo-Server/silo-server/internal/watchstate" ) // directUserDataService implements UserDataService using the user store directly. type directUserDataService struct { storeProvider userstore.UserStoreProvider itemRepo *catalog.ItemRepository detailSvc *catalog.DetailService watchState *watchstate.Service profileStaler profileStaler profileRefreshRequester profileRefreshRequester } func newDirectUserDataService( storeProvider userstore.UserStoreProvider, itemRepo *catalog.ItemRepository, episodeRepo *catalog.EpisodeRepository, providerIDRepo *catalog.ProviderIDRepository, detailSvc *catalog.DetailService, staler profileStaler, requester profileRefreshRequester, ) *directUserDataService { return &directUserDataService{ storeProvider: storeProvider, itemRepo: itemRepo, detailSvc: detailSvc, watchState: watchstate.NewService(storeProvider).WithStableIdentityResolver( watchstate.NewStableIdentityResolver(itemRepo, episodeRepo, providerIDRepo), ), profileStaler: staler, profileRefreshRequester: requester, } } func (s *directUserDataService) ListFavorites(ctx context.Context, session *Session, limit, offset int) ([]upstreamListItem, error) { store, err := s.storeProvider.ForUser(ctx, session.StreamAppUserID) if err != nil { return nil, fmt.Errorf("open user store: %w", err) } favorites, err := store.ListFavorites(ctx, session.ProfileID, limit, offset) if err != nil { return nil, fmt.Errorf("list favorites: %w", err) } contentIDs := make([]string, 0, len(favorites)) for _, fav := range favorites { contentIDs = append(contentIDs, fav.MediaItemID) } if len(contentIDs) == 0 { return []upstreamListItem{}, nil } items, err := s.itemRepo.GetByIDs(ctx, contentIDs) if err != nil { return nil, fmt.Errorf("get favorite items: %w", err) } // Build a map for ordering by the original favorites list order itemMap := make(map[string]*upstreamListItem, len(items)) for _, mi := range items { li := mediaItemToListItem(mi) li.PosterURL = compatPresignImage(s.detailSvc, ctx, li.PosterURL, "poster", compatCardImageSize) li.BackdropURL = compatPresignImage(s.detailSvc, ctx, li.BackdropURL, "backdrop", compatCardImageSize) li.LogoURL = compatPresignImage(s.detailSvc, ctx, li.LogoURL, "logo", compatCardImageSize) itemMap[mi.ContentID] = &li } result := make([]upstreamListItem, 0, len(contentIDs)) for _, id := range contentIDs { if li, ok := itemMap[id]; ok { result = append(result, *li) } } return result, nil } func (s *directUserDataService) ListFavoritesByMediaItems(ctx context.Context, session *Session, mediaItemIDs []string) (map[string]bool, error) { store, err := s.storeProvider.ForUser(ctx, session.StreamAppUserID) if err != nil { return nil, fmt.Errorf("open user store: %w", err) } result, err := store.ListFavoritesByMediaItems(ctx, session.ProfileID, mediaItemIDs) if err != nil { return nil, fmt.Errorf("list favorites by media items: %w", err) } if result == nil { return map[string]bool{}, nil } return result, nil } func (s *directUserDataService) IsFavorite(ctx context.Context, session *Session, contentID string) (bool, error) { store, err := s.storeProvider.ForUser(ctx, session.StreamAppUserID) if err != nil { return false, fmt.Errorf("open user store: %w", err) } favorite, err := store.IsFavorite(ctx, session.ProfileID, contentID) if err != nil { return false, fmt.Errorf("check favorite: %w", err) } return favorite, nil } func (s *directUserDataService) AddFavorite(ctx context.Context, session *Session, contentID string) error { store, err := s.storeProvider.ForUser(ctx, session.StreamAppUserID) if err != nil { return fmt.Errorf("open user store: %w", err) } if err := store.AddFavorite(ctx, session.ProfileID, contentID); err != nil { return err } triggerProfileRefresh(ctx, s.profileStaler, s.profileRefreshRequester, session.StreamAppUserID, session.ProfileID) return nil } func (s *directUserDataService) RemoveFavorite(ctx context.Context, session *Session, contentID string) error { store, err := s.storeProvider.ForUser(ctx, session.StreamAppUserID) if err != nil { return fmt.Errorf("open user store: %w", err) } if err := store.RemoveFavorite(ctx, session.ProfileID, contentID); err != nil { return err } triggerProfileRefresh(ctx, s.profileStaler, s.profileRefreshRequester, session.StreamAppUserID, session.ProfileID) return nil } func (s *directUserDataService) ListProgress(ctx context.Context, session *Session, status string, limit, offset int) ([]upstreamProgress, error) { store, err := s.storeProvider.ForUser(ctx, session.StreamAppUserID) if err != nil { return nil, fmt.Errorf("open user store: %w", err) } entries, err := store.ListProgress(ctx, session.ProfileID, status, limit, offset) if err != nil { return nil, fmt.Errorf("list progress: %w", err) } result := make([]upstreamProgress, 0, len(entries)) for _, entry := range entries { result = append(result, toUpstreamProgress(entry)) } return result, nil } func (s *directUserDataService) ListProgressByMediaItems(ctx context.Context, session *Session, mediaItemIDs []string) (map[string]*upstreamProgress, error) { store, err := s.storeProvider.ForUser(ctx, session.StreamAppUserID) if err != nil { return nil, fmt.Errorf("open user store: %w", err) } progressMap, err := store.ListProgressByMediaItems(ctx, session.ProfileID, mediaItemIDs) if err != nil { return nil, fmt.Errorf("list progress by media items: %w", err) } result := make(map[string]*upstreamProgress, len(progressMap)) for contentID, progress := range progressMap { entry := toUpstreamProgress(progress) result[contentID] = &entry } return result, nil } func (s *directUserDataService) GetProgress(ctx context.Context, session *Session, contentID string) (*upstreamProgress, error) { store, err := s.storeProvider.ForUser(ctx, session.StreamAppUserID) if err != nil { return nil, fmt.Errorf("open user store: %w", err) } progress, err := store.GetProgress(ctx, session.ProfileID, contentID) if err != nil { return nil, fmt.Errorf("get progress: %w", err) } if progress == nil { return nil, nil } entry := toUpstreamProgress(*progress) return &entry, nil } func (s *directUserDataService) MarkPlayed(ctx context.Context, session *Session, contentID string) error { if s.watchState == nil { return fmt.Errorf("watch state service is not configured") } if err := s.watchState.RecordJellycompatMarkPlayed(ctx, session.StreamAppUserID, session.ProfileID, contentID, time.Now().UTC()); err != nil { return err } triggerProfileRefresh(ctx, s.profileStaler, s.profileRefreshRequester, session.StreamAppUserID, session.ProfileID) return nil } func (s *directUserDataService) MarkPlayedBatch(ctx context.Context, session *Session, contentIDs []string) error { if s.watchState == nil { return fmt.Errorf("watch state service is not configured") } if len(contentIDs) == 0 { return nil } if err := s.watchState.RecordJellycompatMarkPlayedBatch(ctx, session.StreamAppUserID, session.ProfileID, contentIDs, time.Now().UTC()); err != nil { return err } triggerProfileRefresh(ctx, s.profileStaler, s.profileRefreshRequester, session.StreamAppUserID, session.ProfileID) return nil } func (s *directUserDataService) MarkUnplayed(ctx context.Context, session *Session, contentID string) error { if s.watchState == nil { return fmt.Errorf("watch state service is not configured") } if err := s.watchState.RecordJellycompatMarkUnplayed(ctx, session.StreamAppUserID, session.ProfileID, contentID); err != nil { return err } triggerProfileRefresh(ctx, s.profileStaler, s.profileRefreshRequester, session.StreamAppUserID, session.ProfileID) return nil } func (s *directUserDataService) MarkUnplayedBatch(ctx context.Context, session *Session, contentIDs []string) error { if s.watchState == nil { return fmt.Errorf("watch state service is not configured") } if len(contentIDs) == 0 { return nil } if err := s.watchState.RecordJellycompatMarkUnplayedBatch(ctx, session.StreamAppUserID, session.ProfileID, contentIDs); err != nil { return err } triggerProfileRefresh(ctx, s.profileStaler, s.profileRefreshRequester, session.StreamAppUserID, session.ProfileID) return nil } func toUpstreamProgress(entry userstore.WatchProgress) upstreamProgress { return upstreamProgress{ MediaItemID: entry.MediaItemID, PositionSeconds: entry.PositionSeconds, DurationSeconds: entry.DurationSeconds, Completed: entry.Completed, UpdatedAt: entry.UpdatedAt, } }