Files
silo-server/internal/watchstate/service.go
T

490 lines
15 KiB
Go

package watchstate
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/Silo-Server/silo-server/internal/userstore"
)
type LeafWatchTarget struct {
MediaItemID string
DurationSeconds float64
}
type Service struct {
storeProvider userstore.UserStoreProvider
identity *StableIdentityResolver
}
type PlaybackStopResult struct {
MediaItemID string
DurationSeconds float64
FinalPositionSeconds float64
Completed bool
SkippedBelowMinResume bool
HistoryID string
}
type ManualMarkResult struct {
Entries []userstore.WatchHistoryEntry
}
func NewService(storeProvider userstore.UserStoreProvider) *Service {
return &Service{storeProvider: storeProvider}
}
func (s *Service) WithStableIdentityResolver(identity *StableIdentityResolver) *Service {
if s == nil {
return nil
}
s.identity = identity
return s
}
func (s *Service) RecordManualMarkWatched(ctx context.Context, userID int, profileID string, targets []LeafWatchTarget, watchedAt time.Time) error {
_, err := s.RecordManualMarkWatchedWithResult(ctx, userID, profileID, targets, watchedAt)
return err
}
func (s *Service) RecordManualMarkWatchedWithResult(ctx context.Context, userID int, profileID string, targets []LeafWatchTarget, watchedAt time.Time) (ManualMarkResult, error) {
return s.recordMarkWatched(ctx, userID, profileID, targets, watchedAt, userstore.WatchHistorySourceManual)
}
func (s *Service) RecordManualMarkUnwatched(ctx context.Context, userID int, profileID string, targetIDs []string) error {
_, err := s.RecordManualMarkUnwatchedWithResult(ctx, userID, profileID, targetIDs)
return err
}
func (s *Service) RecordManualMarkUnwatchedWithResult(ctx context.Context, userID int, profileID string, targetIDs []string) (ManualMarkResult, error) {
return s.recordMarkUnwatched(ctx, userID, profileID, targetIDs, userstore.WatchHistorySourceManual)
}
func (s *Service) RecordPlaybackStop(
ctx context.Context,
userID int,
profileID, targetID string,
duration, position float64,
watchedAt time.Time,
hints userstore.VersionHints,
thresholds userstore.ProgressThresholds,
) (PlaybackStopResult, error) {
result := PlaybackStopResult{
MediaItemID: targetID,
DurationSeconds: duration,
FinalPositionSeconds: position,
}
// Below minimum resume threshold — skip both progress and history.
if duration > 0 && position > 0 && position/duration < userstore.MinResumeFraction(thresholds.MinResumePct) {
result.SkippedBelowMinResume = true
return result, nil
}
store, err := s.storeForUser(ctx, userID)
if err != nil {
return result, err
}
if err := store.SetProgress(ctx, profileID, targetID, position, duration, thresholds); err != nil {
return result, err
}
if hints.FileID > 0 {
if err := store.UpdateProgressHints(ctx, profileID, targetID, hints); err != nil {
return result, err
}
}
historyID := uuid.NewString()
entry := userstore.WatchHistoryEntry{
ID: historyID,
ProfileID: profileID,
MediaItemID: targetID,
WatchedAt: formatWatchedAt(watchedAt),
DurationSeconds: duration,
Completed: duration > 0 && position/duration > userstore.WatchedFraction(thresholds.WatchedPct),
Source: userstore.WatchHistorySourcePlayback,
}
s.applyStableIdentity(ctx, &entry)
if err := store.AddHistory(ctx, entry); err != nil {
return result, err
}
result.Completed = entry.Completed
result.HistoryID = historyID
return result, nil
}
func (s *Service) RecordImportedWatch(
ctx context.Context,
userID int,
profileID, targetID string,
duration, position float64,
completed bool,
updatedAt time.Time,
watchedAt *time.Time,
) (bool, error) {
return s.RecordImportedWatchWithSource(ctx, userID, profileID, targetID, duration, position, completed, updatedAt, watchedAt, userstore.WatchHistorySourceImport)
}
func (s *Service) RecordImportedWatchWithSource(
ctx context.Context,
userID int,
profileID, targetID string,
duration, position float64,
completed bool,
updatedAt time.Time,
watchedAt *time.Time,
source userstore.WatchHistorySource,
) (bool, error) {
store, err := s.storeForUser(ctx, userID)
if err != nil {
return false, err
}
if err := store.SetProgressAt(ctx, profileID, targetID, position, duration, completed, updatedAt); err != nil {
return false, err
}
return s.addImportedHistoryIfMissingWithSource(ctx, store, profileID, targetID, duration, completed, watchedAt, source)
}
func (s *Service) RecordImportedHistory(
ctx context.Context,
userID int,
profileID, targetID string,
duration float64,
completed bool,
watchedAt *time.Time,
) (bool, error) {
return s.RecordImportedHistoryWithSource(ctx, userID, profileID, targetID, duration, completed, watchedAt, userstore.WatchHistorySourceImport)
}
func (s *Service) RecordImportedHistoryWithSource(
ctx context.Context,
userID int,
profileID, targetID string,
duration float64,
completed bool,
watchedAt *time.Time,
source userstore.WatchHistorySource,
) (bool, error) {
store, err := s.storeForUser(ctx, userID)
if err != nil {
return false, err
}
return s.addImportedHistoryIfMissingWithSource(ctx, store, profileID, targetID, duration, completed, watchedAt, source)
}
func (s *Service) RecordImportedMarkUnplayed(
ctx context.Context,
userID int,
profileID, targetID string,
updatedAt time.Time,
) error {
store, err := s.storeForUser(ctx, userID)
if err != nil {
return err
}
if updatedAt.IsZero() {
updatedAt = time.Now().UTC()
}
return store.RemoveHistoryItems(ctx, profileID, []string{targetID}, updatedAt)
}
func (s *Service) SetFavorite(
ctx context.Context,
userID int,
profileID, targetID string,
favorite bool,
) error {
store, err := s.storeForUser(ctx, userID)
if err != nil {
return err
}
if favorite {
return store.AddFavorite(ctx, profileID, targetID)
}
return store.RemoveFavorite(ctx, profileID, targetID)
}
func (s *Service) ToggleFavorite(ctx context.Context, userID int, profileID, targetID string) (bool, error) {
store, err := s.storeForUser(ctx, userID)
if err != nil {
return false, err
}
current, err := store.IsFavorite(ctx, profileID, targetID)
if err != nil {
return false, err
}
next := !current
if next {
return next, store.AddFavorite(ctx, profileID, targetID)
}
return next, store.RemoveFavorite(ctx, profileID, targetID)
}
func (s *Service) RecordJellycompatMarkPlayed(ctx context.Context, userID int, profileID, targetID string, watchedAt time.Time) error {
_, err := s.recordMarkWatched(ctx, userID, profileID, []LeafWatchTarget{{MediaItemID: targetID}}, watchedAt, userstore.WatchHistorySourceJellycompat)
return err
}
func (s *Service) RecordJellycompatMarkUnplayed(ctx context.Context, userID int, profileID, targetID string) error {
_, err := s.recordMarkUnwatched(ctx, userID, profileID, []string{targetID}, userstore.WatchHistorySourceJellycompat)
return err
}
// RecordJellycompatMarkPlayedBatch marks all the given media items as played in
// a single batch upsert and writes corresponding history entries. Used by
// jellycompat's series-mark-played path to collapse a per-episode loop into
// one progress upsert plus per-episode history inserts (audit 2026-05-01 §2.7).
func (s *Service) RecordJellycompatMarkPlayedBatch(ctx context.Context, userID int, profileID string, targetIDs []string, watchedAt time.Time) error {
return s.recordMarkWatchedBatch(ctx, userID, profileID, targetIDs, watchedAt, userstore.WatchHistorySourceJellycompat)
}
// RecordJellycompatMarkUnplayedBatch clears progress and deletes
// jellycompat-sourced history entries for all targets in a single statement
// each (audit 2026-05-01 §2.7).
func (s *Service) RecordJellycompatMarkUnplayedBatch(ctx context.Context, userID int, profileID string, targetIDs []string) error {
return s.recordMarkUnwatchedBatch(ctx, userID, profileID, targetIDs, userstore.WatchHistorySourceJellycompat)
}
func (s *Service) storeForUser(ctx context.Context, userID int) (userstore.UserStore, error) {
if s == nil || s.storeProvider == nil {
return nil, fmt.Errorf("watch state store provider is not configured")
}
store, err := s.storeProvider.ForUser(ctx, userID)
if err != nil {
return nil, fmt.Errorf("open user store: %w", err)
}
if store == nil {
return nil, fmt.Errorf("user store not found")
}
return store, nil
}
func (s *Service) recordMarkWatched(
ctx context.Context,
userID int,
profileID string,
targets []LeafWatchTarget,
watchedAt time.Time,
source userstore.WatchHistorySource,
) (ManualMarkResult, error) {
store, err := s.storeForUser(ctx, userID)
if err != nil {
return ManualMarkResult{}, err
}
entryTime := formatWatchedAt(watchedAt)
result := ManualMarkResult{Entries: make([]userstore.WatchHistoryEntry, 0, len(targets))}
for _, target := range targets {
if err := store.MarkWatched(ctx, profileID, target.MediaItemID, target.DurationSeconds); err != nil {
return result, err
}
histEntry := userstore.WatchHistoryEntry{
ID: uuid.NewString(),
ProfileID: profileID,
MediaItemID: target.MediaItemID,
WatchedAt: entryTime,
DurationSeconds: target.DurationSeconds,
Completed: true,
Source: source,
}
s.applyStableIdentity(ctx, &histEntry)
if err := store.AddHistory(ctx, histEntry); err != nil {
return result, err
}
result.Entries = append(result.Entries, histEntry)
}
return result, nil
}
func (s *Service) recordMarkUnwatched(
ctx context.Context,
userID int,
profileID string,
targetIDs []string,
source userstore.WatchHistorySource,
) (ManualMarkResult, error) {
store, err := s.storeForUser(ctx, userID)
if err != nil {
return ManualMarkResult{}, err
}
result, err := s.completedHistoryForTargets(ctx, store, profileID, targetIDs, source)
if err != nil {
return ManualMarkResult{}, err
}
for _, targetID := range targetIDs {
if err := store.ClearProgress(ctx, profileID, targetID); err != nil {
return result, err
}
}
return result, store.DeleteHistoryBySource(ctx, profileID, targetIDs, source)
}
func (s *Service) completedHistoryForTargets(
ctx context.Context,
store userstore.UserStore,
profileID string,
targetIDs []string,
source userstore.WatchHistorySource,
) (ManualMarkResult, error) {
if len(targetIDs) == 0 {
return ManualMarkResult{}, nil
}
entries, err := store.ListCompletedHistory(ctx, userstore.CompletedHistoryQuery{
ProfileID: profileID,
MediaItemIDs: targetIDs,
IncludeSources: []userstore.WatchHistorySource{
source,
},
Limit: len(targetIDs) * 20,
})
if err != nil {
return ManualMarkResult{}, err
}
return ManualMarkResult{Entries: entries}, nil
}
func (s *Service) recordMarkWatchedBatch(
ctx context.Context,
userID int,
profileID string,
targetIDs []string,
watchedAt time.Time,
source userstore.WatchHistorySource,
) error {
if len(targetIDs) == 0 {
return nil
}
store, err := s.storeForUser(ctx, userID)
if err != nil {
return err
}
if watchedAt.IsZero() {
watchedAt = time.Now().UTC()
}
if err := store.MarkProgressBatch(ctx, profileID, targetIDs, watchedAt); err != nil {
return err
}
// Strategy A (audit 2026-05-01 §2.7): batch the progress upsert because it
// powers hot Continue-Watching queries. History inserts stay per-target so
// per-episode stable-identity resolution still applies.
entryTime := formatWatchedAt(watchedAt)
for _, targetID := range targetIDs {
histEntry := userstore.WatchHistoryEntry{
ProfileID: profileID,
MediaItemID: targetID,
WatchedAt: entryTime,
Completed: true,
Source: source,
}
s.applyStableIdentity(ctx, &histEntry)
if err := store.AddHistory(ctx, histEntry); err != nil {
return err
}
}
return nil
}
func (s *Service) recordMarkUnwatchedBatch(
ctx context.Context,
userID int,
profileID string,
targetIDs []string,
source userstore.WatchHistorySource,
) error {
if len(targetIDs) == 0 {
return nil
}
store, err := s.storeForUser(ctx, userID)
if err != nil {
return err
}
if err := store.ClearProgressBatch(ctx, profileID, targetIDs, time.Now().UTC()); err != nil {
return err
}
return store.DeleteHistoryBySource(ctx, profileID, targetIDs, source)
}
// buildMarkPlayedBatchSQL returns the upsert that marks every media_item_id in
// the unnest($3) array as completed for a given (user, profile). Extracted into
// a helper so a SQL-shape unit test can pin the structure without standing up
// Postgres.
func buildMarkPlayedBatchSQL() (string, []any) {
return `
INSERT INTO user_watch_progress
(user_id, profile_id, media_item_id, completed, position_seconds, duration_seconds, updated_at)
SELECT $1, $2, mid, TRUE, 0, 0, $4
FROM unnest($3::text[]) AS mid
ON CONFLICT (user_id, profile_id, media_item_id) DO UPDATE
SET completed = TRUE,
updated_at = EXCLUDED.updated_at
WHERE user_watch_progress.completed IS DISTINCT FROM TRUE
OR user_watch_progress.updated_at < EXCLUDED.updated_at`, nil
}
// buildMarkUnplayedBatchSQL returns the update that clears the completed flag
// and resets position to 0 for every media_item_id in $3 for a given
// (user, profile). Pairs with the jellycompat unplayed-batch path; the matching
// history-row deletion uses DeleteHistoryBySource which already takes a slice.
//
// The `completed = TRUE OR position_seconds <> 0` predicate clears partially-
// watched rows in addition to fully-completed ones — the prior single-item
// ClearProgress path DELETE-d unconditionally, so any non-default state must
// be cleared (otherwise "mark unplayed" leaves resume position untouched).
// Skip rows already in the target state to avoid pointless writes.
func buildMarkUnplayedBatchSQL() (string, []any) {
return `
UPDATE user_watch_progress
SET completed = FALSE, position_seconds = 0, updated_at = $4
WHERE user_id = $1 AND profile_id = $2
AND media_item_id = ANY($3::text[])
AND (completed = TRUE OR position_seconds <> 0)`, nil
}
func (s *Service) addImportedHistoryIfMissing(
ctx context.Context,
store userstore.UserStore,
profileID, targetID string,
duration float64,
completed bool,
watchedAt *time.Time,
) (bool, error) {
return s.addImportedHistoryIfMissingWithSource(ctx, store, profileID, targetID, duration, completed, watchedAt, userstore.WatchHistorySourceImport)
}
func (s *Service) addImportedHistoryIfMissingWithSource(
ctx context.Context,
store userstore.UserStore,
profileID, targetID string,
duration float64,
completed bool,
watchedAt *time.Time,
source userstore.WatchHistorySource,
) (bool, error) {
if watchedAt == nil || watchedAt.IsZero() {
return false, nil
}
entry := userstore.WatchHistoryEntry{
ProfileID: profileID,
MediaItemID: targetID,
WatchedAt: watchedAt.UTC().Format(time.RFC3339),
DurationSeconds: duration,
Completed: completed,
Source: source,
}
s.applyStableIdentity(ctx, &entry)
return store.AddHistoryIfMissing(ctx, entry)
}
func (s *Service) applyStableIdentity(ctx context.Context, entry *userstore.WatchHistoryEntry) {
if s == nil || s.identity == nil || entry == nil {
return
}
entry.Identity = s.identity.ResolveHistoryIdentity(ctx, entry.MediaItemID)
}
func formatWatchedAt(watchedAt time.Time) string {
if watchedAt.IsZero() {
return ""
}
return watchedAt.UTC().Format(time.RFC3339)
}