diff --git a/graphql/documents/data/scene-slim.graphql b/graphql/documents/data/scene-slim.graphql index cd5cfd556..3e0749dd8 100644 --- a/graphql/documents/data/scene-slim.graphql +++ b/graphql/documents/data/scene-slim.graphql @@ -11,6 +11,9 @@ fragment SlimSceneData on Scene { organized interactive interactive_speed + resume_time + play_duration + play_count files { ...VideoFileData diff --git a/graphql/documents/data/scene.graphql b/graphql/documents/data/scene.graphql index 790b2f2c1..8b0a664d5 100644 --- a/graphql/documents/data/scene.graphql +++ b/graphql/documents/data/scene.graphql @@ -17,6 +17,10 @@ fragment SceneData on Scene { } created_at updated_at + resume_time + last_played_at + play_duration + play_count files { ...VideoFileData diff --git a/graphql/documents/mutations/scene.graphql b/graphql/documents/mutations/scene.graphql index 2fd6d9daf..8da4b3bd9 100644 --- a/graphql/documents/mutations/scene.graphql +++ b/graphql/documents/mutations/scene.graphql @@ -28,6 +28,14 @@ mutation ScenesUpdate($input : [SceneUpdateInput!]!) { } } +mutation SceneSaveActivity($id: ID!, $resume_time: Float, $playDuration: Float) { + sceneSaveActivity(id: $id, resume_time: $resume_time, playDuration: $playDuration) +} + +mutation SceneIncrementPlayCount($id: ID!) { + sceneIncrementPlayCount(id: $id) +} + mutation SceneIncrementO($id: ID!) { sceneIncrementO(id: $id) } diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 6f2f37d96..959e52b99 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -177,6 +177,12 @@ type Mutation { """Resets the o-counter for a scene to 0. Returns the new value""" sceneResetO(id: ID!): Int! + """Sets the resume time point (if provided) and adds the provided duration to the scene's play duration""" + sceneSaveActivity(id: ID!, resume_time: Float, playDuration: Float): Boolean! + + """Increments the play count for the scene. Returns the new play count value.""" + sceneIncrementPlayCount(id: ID!): Int! + """Generates screenshot at specified time in seconds. Leave empty to generate default screenshot""" sceneGenerateScreenshot(id: ID!, at: Float): String! diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 874e16823..b391ef085 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -217,6 +217,12 @@ input SceneFilterType { interactive_speed: IntCriterionInput """Filter by captions""" captions: StringCriterionInput + """Filter by resume time""" + resume_time: IntCriterionInput + """Filter by play count""" + play_count: IntCriterionInput + """Filter by play duration (in seconds)""" + play_duration: IntCriterionInput """Filter by date""" date: DateCriterionInput """Filter by creation time""" diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index 7295a9530..7ec2134c9 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -56,6 +56,14 @@ type Scene { created_at: Time! updated_at: Time! file_mod_time: Time + """The last time play count was updated""" + last_played_at: Time + """The time index a scene was left at""" + resume_time: Float + """The total time a scene has spent playing""" + play_duration: Float + """The number ot times a scene has been played""" + play_count: Int file: SceneFileType! @deprecated(reason: "Use files") files: [VideoFile!]! @@ -128,6 +136,13 @@ input SceneUpdateInput { cover_image: String stash_ids: [StashIDInput!] + """The time index a scene was left at""" + resume_time: Float + """The total time a scene has spent playing""" + play_duration: Float + """The number ot times a scene has been played""" + play_count: Int + primary_file_id: ID } diff --git a/internal/api/changeset_translator.go b/internal/api/changeset_translator.go index eee74bd56..3e768a136 100644 --- a/internal/api/changeset_translator.go +++ b/internal/api/changeset_translator.go @@ -296,3 +296,11 @@ func (t changesetTranslator) optionalBool(value *bool, field string) models.Opti return models.NewOptionalBoolPtr(value) } + +func (t changesetTranslator) optionalFloat64(value *float64, field string) models.OptionalFloat64 { + if !t.hasField(field) { + return models.OptionalFloat64{} + } + + return models.NewOptionalFloat64Ptr(value) +} diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index c7ba87db3..301bd3b8e 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -174,6 +174,8 @@ func scenePartialFromInput(input models.SceneUpdateInput, translator changesetTr updatedScene.Date = translator.optionalDate(input.Date, "date") updatedScene.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100) updatedScene.OCounter = translator.optionalInt(input.OCounter, "o_counter") + updatedScene.PlayCount = translator.optionalInt(input.PlayCount, "play_count") + updatedScene.PlayDuration = translator.optionalFloat64(input.PlayDuration, "play_duration") var err error updatedScene.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") if err != nil { @@ -856,6 +858,42 @@ func (r *mutationResolver) changeMarker(ctx context.Context, changeType int, cha return sceneMarker, nil } +func (r *mutationResolver) SceneSaveActivity(ctx context.Context, id string, resumeTime *float64, playDuration *float64) (ret bool, err error) { + sceneID, err := strconv.Atoi(id) + if err != nil { + return false, err + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Scene + + ret, err = qb.SaveActivity(ctx, sceneID, resumeTime, playDuration) + return err + }); err != nil { + return false, err + } + + return ret, nil +} + +func (r *mutationResolver) SceneIncrementPlayCount(ctx context.Context, id string) (ret int, err error) { + sceneID, err := strconv.Atoi(id) + if err != nil { + return 0, err + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Scene + + ret, err = qb.IncrementWatchCount(ctx, sceneID) + return err + }); err != nil { + return 0, err + } + + return ret, nil +} + func (r *mutationResolver) SceneIncrementO(ctx context.Context, id string) (ret int, err error) { sceneID, err := strconv.Atoi(id) if err != nil { diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index 6bb597a07..d75ad2eed 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -530,6 +530,10 @@ func exportScene(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models newSceneJSON.Galleries = gallery.GetRefs(galleries) + newSceneJSON.ResumeTime = s.ResumeTime + newSceneJSON.PlayCount = s.PlayCount + newSceneJSON.PlayDuration = s.PlayDuration + performers, err := performerReader.FindBySceneID(ctx, s.ID) if err != nil { logger.Errorf("[scenes] <%s> error getting scene performer names: %s", sceneHash, err.Error()) diff --git a/pkg/models/jsonschema/scene.go b/pkg/models/jsonschema/scene.go index a2ac5695b..fbfdad010 100644 --- a/pkg/models/jsonschema/scene.go +++ b/pkg/models/jsonschema/scene.go @@ -39,26 +39,30 @@ type SceneMovie struct { } type Scene struct { - Title string `json:"title,omitempty"` - Code string `json:"code,omitempty"` - Studio string `json:"studio,omitempty"` - URL string `json:"url,omitempty"` - Date string `json:"date,omitempty"` - Rating int `json:"rating,omitempty"` - Organized bool `json:"organized,omitempty"` - OCounter int `json:"o_counter,omitempty"` - Details string `json:"details,omitempty"` - Director string `json:"director,omitempty"` - Galleries []GalleryRef `json:"galleries,omitempty"` - Performers []string `json:"performers,omitempty"` - Movies []SceneMovie `json:"movies,omitempty"` - Tags []string `json:"tags,omitempty"` - Markers []SceneMarker `json:"markers,omitempty"` - Files []string `json:"files,omitempty"` - Cover string `json:"cover,omitempty"` - CreatedAt json.JSONTime `json:"created_at,omitempty"` - UpdatedAt json.JSONTime `json:"updated_at,omitempty"` - StashIDs []models.StashID `json:"stash_ids,omitempty"` + Title string `json:"title,omitempty"` + Code string `json:"code,omitempty"` + Studio string `json:"studio,omitempty"` + URL string `json:"url,omitempty"` + Date string `json:"date,omitempty"` + Rating int `json:"rating,omitempty"` + Organized bool `json:"organized,omitempty"` + OCounter int `json:"o_counter,omitempty"` + Details string `json:"details,omitempty"` + Director string `json:"director,omitempty"` + Galleries []GalleryRef `json:"galleries,omitempty"` + Performers []string `json:"performers,omitempty"` + Movies []SceneMovie `json:"movies,omitempty"` + Tags []string `json:"tags,omitempty"` + Markers []SceneMarker `json:"markers,omitempty"` + Files []string `json:"files,omitempty"` + Cover string `json:"cover,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + LastPlayedAt json.JSONTime `json:"last_played_at,omitempty"` + ResumeTime float64 `json:"resume_time,omitempty"` + PlayCount int `json:"play_count,omitempty"` + PlayDuration float64 `json:"play_duration,omitempty"` + StashIDs []models.StashID `json:"stash_ids,omitempty"` } func (s Scene) Filename(id int, basename string, hash string) string { diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index 87b253686..74ad7dc4b 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -638,6 +638,48 @@ func (_m *SceneReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]in return r0, r1 } +// SaveActivity provides a mock function with given fields: ctx, id, resumeTime, playDuration +func (_m *SceneReaderWriter) SaveActivity(ctx context.Context, id int, resumeTime *float64, playDuration *float64) (bool, error) { + ret := _m.Called(ctx, id, resumeTime, playDuration) + + var r0 bool + if rf, ok := ret.Get(0).(func(context.Context, int, *float64, *float64) bool); ok { + r0 = rf(ctx, id, resumeTime, playDuration) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int, *float64, *float64) error); ok { + r1 = rf(ctx, id, resumeTime, playDuration) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// IncrementWatchCount provides a mock function with given fields: ctx, id +func (_m *SceneReaderWriter) IncrementWatchCount(ctx context.Context, id int) (int, error) { + ret := _m.Called(ctx, id) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // IncrementOCounter provides a mock function with given fields: ctx, id func (_m *SceneReaderWriter) IncrementOCounter(ctx context.Context, id int) (int, error) { ret := _m.Called(ctx, id) diff --git a/pkg/models/model_scene.go b/pkg/models/model_scene.go index 16df1a67f..79c865ed2 100644 --- a/pkg/models/model_scene.go +++ b/pkg/models/model_scene.go @@ -38,6 +38,11 @@ type Scene struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + LastPlayedAt *time.Time `json:"last_played_at"` + ResumeTime float64 `json:"resume_time"` + PlayDuration float64 `json:"play_duration"` + PlayCount int `json:"play_count"` + GalleryIDs RelatedIDs `json:"gallery_ids"` TagIDs RelatedIDs `json:"tag_ids"` PerformerIDs RelatedIDs `json:"performer_ids"` @@ -142,12 +147,16 @@ type ScenePartial struct { URL OptionalString Date OptionalDate // Rating expressed in 1-100 scale - Rating OptionalInt - Organized OptionalBool - OCounter OptionalInt - StudioID OptionalInt - CreatedAt OptionalTime - UpdatedAt OptionalTime + Rating OptionalInt + Organized OptionalBool + OCounter OptionalInt + StudioID OptionalInt + CreatedAt OptionalTime + UpdatedAt OptionalTime + ResumeTime OptionalFloat64 + PlayDuration OptionalFloat64 + PlayCount OptionalInt + LastPlayedAt OptionalTime GalleryIDs *UpdateIDs TagIDs *UpdateIDs @@ -192,6 +201,9 @@ type SceneUpdateInput struct { // This should be a URL or a base64 encoded data URL CoverImage *string `json:"cover_image"` StashIds []StashID `json:"stash_ids"` + ResumeTime *float64 `json:"resume_time"` + PlayDuration *float64 `json:"play_duration"` + PlayCount *int `json:"play_count"` PrimaryFileID *string `json:"primary_file_id"` } diff --git a/pkg/models/scene.go b/pkg/models/scene.go index 3e6350658..3d6842943 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -77,8 +77,14 @@ type SceneFilterType struct { Interactive *bool `json:"interactive"` // Filter by InteractiveSpeed InteractiveSpeed *IntCriterionInput `json:"interactive_speed"` - + // Filter by captions Captions *StringCriterionInput `json:"captions"` + // Filter by resume time + ResumeTime *IntCriterionInput `json:"resume_time"` + // Filter by play count + PlayCount *IntCriterionInput `json:"play_count"` + // Filter by play duration (in seconds) + PlayDuration *IntCriterionInput `json:"play_duration"` // Filter by date Date *DateCriterionInput `json:"date"` // Filter by created at @@ -179,6 +185,8 @@ type SceneWriter interface { IncrementOCounter(ctx context.Context, id int) (int, error) DecrementOCounter(ctx context.Context, id int) (int, error) ResetOCounter(ctx context.Context, id int) (int, error) + SaveActivity(ctx context.Context, id int, resumeTime *float64, playDuration *float64) (bool, error) + IncrementWatchCount(ctx context.Context, id int) (int, error) Destroy(ctx context.Context, id int) error UpdateCover(ctx context.Context, sceneID int, cover []byte) error DestroyCover(ctx context.Context, sceneID int) error diff --git a/pkg/models/value.go b/pkg/models/value.go index 7b99a83d1..356d65293 100644 --- a/pkg/models/value.go +++ b/pkg/models/value.go @@ -199,6 +199,18 @@ func NewOptionalFloat64(v float64) OptionalFloat64 { return OptionalFloat64{v, false, true} } +// NewOptionalFloat64 returns a new OptionalFloat64 with the given value. +func NewOptionalFloat64Ptr(v *float64) OptionalFloat64 { + if v == nil { + return OptionalFloat64{ + Null: true, + Set: true, + } + } + + return OptionalFloat64{*v, false, true} +} + // OptionalDate represents an optional date argument that may be null. See OptionalString. type OptionalDate struct { Value Date diff --git a/pkg/scene/import.go b/pkg/scene/import.go index 3896f5d25..05575a848 100644 --- a/pkg/scene/import.go +++ b/pkg/scene/import.go @@ -105,6 +105,13 @@ func (i *Importer) sceneJSONToScene(sceneJSON jsonschema.Scene) models.Scene { newScene.OCounter = sceneJSON.OCounter newScene.CreatedAt = sceneJSON.CreatedAt.GetTime() newScene.UpdatedAt = sceneJSON.UpdatedAt.GetTime() + if !sceneJSON.LastPlayedAt.IsZero() { + t := sceneJSON.LastPlayedAt.GetTime() + newScene.LastPlayedAt = &t + } + newScene.ResumeTime = sceneJSON.ResumeTime + newScene.PlayDuration = sceneJSON.PlayDuration + newScene.PlayCount = sceneJSON.PlayCount return newScene } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 93d7f09db..b2c333024 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -21,7 +21,7 @@ import ( "github.com/stashapp/stash/pkg/logger" ) -var appSchemaVersion uint = 40 +var appSchemaVersion uint = 41 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/41_scene_activity.up.sql b/pkg/sqlite/migrations/41_scene_activity.up.sql new file mode 100644 index 000000000..2e7c70a16 --- /dev/null +++ b/pkg/sqlite/migrations/41_scene_activity.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE `scenes` ADD COLUMN `resume_time` float not null default 0; +ALTER TABLE `scenes` ADD COLUMN `last_played_at` datetime default null; +ALTER TABLE `scenes` ADD COLUMN `play_count` tinyint not null default 0; +ALTER TABLE `scenes` ADD COLUMN `play_duration` float not null default 0; \ No newline at end of file diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index d39a45472..a3b0e2f2b 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -150,7 +150,7 @@ func (qb *movieQueryBuilder) makeFilter(ctx context.Context, movieFilter *models query.handleCriterion(ctx, intCriterionHandler(movieFilter.Rating100, "movies.rating", nil)) // legacy rating handler query.handleCriterion(ctx, rating5CriterionHandler(movieFilter.Rating, "movies.rating", nil)) - query.handleCriterion(ctx, durationCriterionHandler(movieFilter.Duration, "movies.duration", nil)) + query.handleCriterion(ctx, floatIntCriterionHandler(movieFilter.Duration, "movies.duration", nil)) query.handleCriterion(ctx, movieIsMissingCriterionHandler(qb, movieFilter.IsMissing)) query.handleCriterion(ctx, stringCriterionHandler(movieFilter.URL, "movies.url")) query.handleCriterion(ctx, movieStudioCriterionHandler(qb, movieFilter.Studios)) diff --git a/pkg/sqlite/record.go b/pkg/sqlite/record.go index a0fb789ef..7e79dc721 100644 --- a/pkg/sqlite/record.go +++ b/pkg/sqlite/record.go @@ -68,14 +68,14 @@ func (r *updateRecord) setNullInt(destField string, v models.OptionalInt) { // } // } -// func (r *updateRecord) setFloat64(destField string, v models.OptionalFloat64) { -// if v.Set { -// if v.Null { -// panic("null value not allowed in optional float64") -// } -// r.set(destField, v.Value) -// } -// } +func (r *updateRecord) setFloat64(destField string, v models.OptionalFloat64) { + if v.Set { + if v.Null { + panic("null value not allowed in optional float64") + } + r.set(destField, v.Value) + } +} // func (r *updateRecord) setNullFloat64(destField string, v models.OptionalFloat64) { // if v.Set { diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index b26df92cb..341e9ee26 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strconv" "strings" + "time" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" @@ -60,12 +61,16 @@ type sceneRow struct { URL zero.String `db:"url"` Date models.SQLiteDate `db:"date"` // expressed as 1-100 - Rating null.Int `db:"rating"` - Organized bool `db:"organized"` - OCounter int `db:"o_counter"` - StudioID null.Int `db:"studio_id,omitempty"` - CreatedAt models.SQLiteTimestamp `db:"created_at"` - UpdatedAt models.SQLiteTimestamp `db:"updated_at"` + Rating null.Int `db:"rating"` + Organized bool `db:"organized"` + OCounter int `db:"o_counter"` + StudioID null.Int `db:"studio_id,omitempty"` + CreatedAt models.SQLiteTimestamp `db:"created_at"` + UpdatedAt models.SQLiteTimestamp `db:"updated_at"` + LastPlayedAt models.NullSQLiteTimestamp `db:"last_played_at"` + ResumeTime float64 `db:"resume_time"` + PlayDuration float64 `db:"play_duration"` + PlayCount int `db:"play_count"` } func (r *sceneRow) fromScene(o models.Scene) { @@ -84,6 +89,15 @@ func (r *sceneRow) fromScene(o models.Scene) { r.StudioID = intFromPtr(o.StudioID) r.CreatedAt = models.SQLiteTimestamp{Timestamp: o.CreatedAt} r.UpdatedAt = models.SQLiteTimestamp{Timestamp: o.UpdatedAt} + if o.LastPlayedAt != nil { + r.LastPlayedAt = models.NullSQLiteTimestamp{ + Timestamp: *o.LastPlayedAt, + Valid: true, + } + } + r.ResumeTime = o.ResumeTime + r.PlayDuration = o.PlayDuration + r.PlayCount = o.PlayCount } type sceneQueryRow struct { @@ -115,12 +129,20 @@ func (r *sceneQueryRow) resolve() *models.Scene { CreatedAt: r.CreatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp, + + ResumeTime: r.ResumeTime, + PlayDuration: r.PlayDuration, + PlayCount: r.PlayCount, } if r.PrimaryFileFolderPath.Valid && r.PrimaryFileBasename.Valid { ret.Path = filepath.Join(r.PrimaryFileFolderPath.String, r.PrimaryFileBasename.String) } + if r.LastPlayedAt.Valid { + ret.LastPlayedAt = &r.LastPlayedAt.Timestamp + } + return ret } @@ -141,6 +163,10 @@ func (r *sceneRowRecord) fromPartial(o models.ScenePartial) { r.setNullInt("studio_id", o.StudioID) r.setSQLiteTimestamp("created_at", o.CreatedAt) r.setSQLiteTimestamp("updated_at", o.UpdatedAt) + r.setSQLiteTimestamp("last_played_at", o.LastPlayedAt) + r.setFloat64("resume_time", o.ResumeTime) + r.setFloat64("play_duration", o.PlayDuration) + r.setInt("play_count", o.PlayCount) } type SceneStore struct { @@ -851,7 +877,7 @@ func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneF query.handleCriterion(ctx, intCriterionHandler(sceneFilter.OCounter, "scenes.o_counter", nil)) query.handleCriterion(ctx, boolCriterionHandler(sceneFilter.Organized, "scenes.organized", nil)) - query.handleCriterion(ctx, durationCriterionHandler(sceneFilter.Duration, "video_files.duration", qb.addVideoFilesTable)) + query.handleCriterion(ctx, floatIntCriterionHandler(sceneFilter.Duration, "video_files.duration", qb.addVideoFilesTable)) query.handleCriterion(ctx, resolutionCriterionHandler(sceneFilter.Resolution, "video_files.height", "video_files.width", qb.addVideoFilesTable)) query.handleCriterion(ctx, hasMarkersCriterionHandler(sceneFilter.HasMarkers)) @@ -876,6 +902,10 @@ func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneF query.handleCriterion(ctx, sceneCaptionCriterionHandler(qb, sceneFilter.Captions)) + query.handleCriterion(ctx, floatIntCriterionHandler(sceneFilter.ResumeTime, "scenes.resume_time", nil)) + query.handleCriterion(ctx, floatIntCriterionHandler(sceneFilter.PlayDuration, "scenes.play_duration", nil)) + query.handleCriterion(ctx, intCriterionHandler(sceneFilter.PlayCount, "scenes.play_count", nil)) + query.handleCriterion(ctx, sceneTagsCriterionHandler(qb, sceneFilter.Tags)) query.handleCriterion(ctx, sceneTagCountCriterionHandler(qb, sceneFilter.TagCount)) query.handleCriterion(ctx, scenePerformersCriterionHandler(qb, sceneFilter.Performers)) @@ -1070,7 +1100,7 @@ func scenePhashDuplicatedCriterionHandler(duplicatedFilter *models.PHashDuplicat } } -func durationCriterionHandler(durationFilter *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { +func floatIntCriterionHandler(durationFilter *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if durationFilter != nil { if addJoinFn != nil { @@ -1417,6 +1447,9 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF addFileTable() addFolderTable() query.sortAndPagination += " ORDER BY scenes.title COLLATE NATURAL_CS " + direction + ", folders.path " + direction + ", files.basename COLLATE NATURAL_CS " + direction + case "play_count": + // handle here since getSort has special handling for _count suffix + query.sortAndPagination += " ORDER BY scenes.play_count " + direction default: query.sortAndPagination += getSort(sort, direction, "scenes") } @@ -1433,6 +1466,62 @@ func (qb *SceneStore) imageRepository() *imageRepository { } } +func (qb *SceneStore) getPlayCount(ctx context.Context, id int) (int, error) { + q := dialect.From(qb.tableMgr.table).Select("play_count").Where(goqu.Ex{"id": id}) + + const single = true + var ret int + if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { + if err := rows.Scan(&ret); err != nil { + return err + } + return nil + }); err != nil { + return 0, err + } + + return ret, nil +} + +func (qb *SceneStore) SaveActivity(ctx context.Context, id int, resumeTime *float64, playDuration *float64) (bool, error) { + if err := qb.tableMgr.checkIDExists(ctx, id); err != nil { + return false, err + } + + record := goqu.Record{} + + if resumeTime != nil { + record["resume_time"] = resumeTime + } + + if playDuration != nil { + record["play_duration"] = goqu.L("play_duration + ?", playDuration) + } + + if len(record) > 0 { + if err := qb.tableMgr.updateByID(ctx, id, record); err != nil { + return false, err + } + } + + return true, nil +} + +func (qb *SceneStore) IncrementWatchCount(ctx context.Context, id int) (int, error) { + if err := qb.tableMgr.checkIDExists(ctx, id); err != nil { + return 0, err + } + + if err := qb.tableMgr.updateByID(ctx, id, goqu.Record{ + "play_count": goqu.L("play_count + 1"), + "last_played_at": time.Now(), + }); err != nil { + return 0, err + } + + return qb.getPlayCount(ctx, id) +} + func (qb *SceneStore) GetCover(ctx context.Context, sceneID int) ([]byte, error) { return qb.imageRepository().get(ctx, sceneID) } diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index 72de32f70..697dba113 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -72,21 +72,25 @@ func loadSceneRelationships(ctx context.Context, expected models.Scene, actual * func Test_sceneQueryBuilder_Create(t *testing.T) { var ( - title = "title" - code = "1337" - details = "details" - director = "director" - url = "url" - rating = 60 - ocounter = 5 - createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) - updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) - sceneIndex = 123 - sceneIndex2 = 234 - endpoint1 = "endpoint1" - endpoint2 = "endpoint2" - stashID1 = "stashid1" - stashID2 = "stashid2" + title = "title" + code = "1337" + details = "details" + director = "director" + url = "url" + rating = 60 + ocounter = 5 + lastPlayedAt = time.Date(2002, 1, 1, 0, 0, 0, 0, time.UTC) + resumeTime = 10.0 + playCount = 3 + playDuration = 34.0 + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + sceneIndex = 123 + sceneIndex2 = 234 + endpoint1 = "endpoint1" + endpoint2 = "endpoint2" + stashID1 = "stashid1" + stashID2 = "stashid2" date = models.NewDate("2003-02-01") @@ -136,6 +140,10 @@ func Test_sceneQueryBuilder_Create(t *testing.T) { Endpoint: endpoint2, }, }), + LastPlayedAt: &lastPlayedAt, + ResumeTime: float64(resumeTime), + PlayCount: playCount, + PlayDuration: playDuration, }, false, }, @@ -180,6 +188,10 @@ func Test_sceneQueryBuilder_Create(t *testing.T) { Endpoint: endpoint2, }, }), + LastPlayedAt: &lastPlayedAt, + ResumeTime: resumeTime, + PlayCount: playCount, + PlayDuration: playDuration, }, false, }, @@ -299,21 +311,25 @@ func makeSceneFileWithID(i int) *file.VideoFile { func Test_sceneQueryBuilder_Update(t *testing.T) { var ( - title = "title" - code = "1337" - details = "details" - director = "director" - url = "url" - rating = 60 - ocounter = 5 - createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) - updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) - sceneIndex = 123 - sceneIndex2 = 234 - endpoint1 = "endpoint1" - endpoint2 = "endpoint2" - stashID1 = "stashid1" - stashID2 = "stashid2" + title = "title" + code = "1337" + details = "details" + director = "director" + url = "url" + rating = 60 + ocounter = 5 + lastPlayedAt = time.Date(2002, 1, 1, 0, 0, 0, 0, time.UTC) + resumeTime = 10.0 + playCount = 3 + playDuration = 34.0 + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + sceneIndex = 123 + sceneIndex2 = 234 + endpoint1 = "endpoint1" + endpoint2 = "endpoint2" + stashID1 = "stashid1" + stashID2 = "stashid2" date = models.NewDate("2003-02-01") ) @@ -362,6 +378,10 @@ func Test_sceneQueryBuilder_Update(t *testing.T) { Endpoint: endpoint2, }, }), + LastPlayedAt: &lastPlayedAt, + ResumeTime: resumeTime, + PlayCount: playCount, + PlayDuration: playDuration, }, false, }, @@ -507,21 +527,25 @@ func clearScenePartial() models.ScenePartial { func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) { var ( - title = "title" - code = "1337" - details = "details" - director = "director" - url = "url" - rating = 60 - ocounter = 5 - createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) - updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) - sceneIndex = 123 - sceneIndex2 = 234 - endpoint1 = "endpoint1" - endpoint2 = "endpoint2" - stashID1 = "stashid1" - stashID2 = "stashid2" + title = "title" + code = "1337" + details = "details" + director = "director" + url = "url" + rating = 60 + ocounter = 5 + lastPlayedAt = time.Date(2002, 1, 1, 0, 0, 0, 0, time.UTC) + resumeTime = 10.0 + playCount = 3 + playDuration = 34.0 + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + sceneIndex = 123 + sceneIndex2 = 234 + endpoint1 = "endpoint1" + endpoint2 = "endpoint2" + stashID1 = "stashid1" + stashID2 = "stashid2" date = models.NewDate("2003-02-01") ) @@ -587,6 +611,10 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) { }, Mode: models.RelationshipUpdateModeSet, }, + LastPlayedAt: models.NewOptionalTime(lastPlayedAt), + ResumeTime: models.NewOptionalFloat64(resumeTime), + PlayCount: models.NewOptionalInt(playCount), + PlayDuration: models.NewOptionalFloat64(playDuration), }, models.Scene{ ID: sceneIDs[sceneIdxWithSpacedName], @@ -628,6 +656,10 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) { Endpoint: endpoint2, }, }), + LastPlayedAt: &lastPlayedAt, + ResumeTime: resumeTime, + PlayCount: playCount, + PlayDuration: playDuration, }, false, }, @@ -2088,6 +2120,45 @@ func TestSceneQuery(t *testing.T) { excludeIdxs []int wantErr bool }{ + { + "specific resume time", + nil, + &models.SceneFilterType{ + ResumeTime: &models.IntCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: int(getSceneResumeTime(sceneIdxWithGallery)), + }, + }, + []int{sceneIdxWithGallery}, + []int{sceneIdxWithMovie}, + false, + }, + { + "specific play duration", + nil, + &models.SceneFilterType{ + PlayDuration: &models.IntCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: int(getScenePlayDuration(sceneIdxWithGallery)), + }, + }, + []int{sceneIdxWithGallery}, + []int{sceneIdxWithMovie}, + false, + }, + { + "specific play count", + nil, + &models.SceneFilterType{ + PlayCount: &models.IntCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: getScenePlayCount(sceneIdxWithGallery), + }, + }, + []int{sceneIdxWithGallery}, + []int{sceneIdxWithMovie}, + false, + }, { "stash id with endpoint", nil, @@ -3697,6 +3768,34 @@ func TestSceneQuerySorting(t *testing.T) { -1, -1, }, + { + "play_count", + "play_count", + models.SortDirectionEnumDesc, + sceneIDs[sceneIdx1WithPerformer], + -1, + }, + { + "last_played_at", + "last_played_at", + models.SortDirectionEnumDesc, + sceneIDs[sceneIdx1WithPerformer], + -1, + }, + { + "resume_time", + "resume_time", + models.SortDirectionEnumDesc, + sceneIDs[sceneIdx1WithPerformer], + -1, + }, + { + "play_duration", + "play_duration", + models.SortDirectionEnumDesc, + sceneIDs[sceneIdx1WithPerformer], + -1, + }, } qb := db.Scene @@ -4245,5 +4344,154 @@ func TestSceneStore_AssignFiles(t *testing.T) { } } +func TestSceneStore_IncrementWatchCount(t *testing.T) { + tests := []struct { + name string + sceneID int + expectedCount int + wantErr bool + }{ + { + "valid", + sceneIDs[sceneIdx1WithPerformer], + getScenePlayCount(sceneIdx1WithPerformer) + 1, + false, + }, + { + "invalid scene id", + invalidID, + 0, + true, + }, + } + + qb := db.Scene + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withRollbackTxn(func(ctx context.Context) error { + newVal, err := qb.IncrementWatchCount(ctx, tt.sceneID) + if (err != nil) != tt.wantErr { + t.Errorf("SceneStore.IncrementWatchCount() error = %v, wantErr %v", err, tt.wantErr) + } + + if err != nil { + return nil + } + + assert := assert.New(t) + assert.Equal(tt.expectedCount, newVal) + + // find the scene and check the count + scene, err := qb.Find(ctx, tt.sceneID) + if err != nil { + t.Errorf("SceneStore.Find() error = %v", err) + } + + assert.Equal(tt.expectedCount, scene.PlayCount) + assert.True(scene.LastPlayedAt.After(time.Now().Add(-1 * time.Minute))) + + return nil + }) + }) + } +} + +func TestSceneStore_SaveActivity(t *testing.T) { + var ( + resumeTime = 111.2 + playDuration = 98.7 + ) + + tests := []struct { + name string + sceneIdx int + resumeTime *float64 + playDuration *float64 + wantErr bool + }{ + { + "both", + sceneIdx1WithPerformer, + &resumeTime, + &playDuration, + false, + }, + { + "resumeTime only", + sceneIdx1WithPerformer, + &resumeTime, + nil, + false, + }, + { + "playDuration only", + sceneIdx1WithPerformer, + nil, + &playDuration, + false, + }, + { + "none", + sceneIdx1WithPerformer, + nil, + nil, + false, + }, + { + "invalid scene id", + -1, + &resumeTime, + &playDuration, + true, + }, + } + + qb := db.Scene + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withRollbackTxn(func(ctx context.Context) error { + id := -1 + if tt.sceneIdx != -1 { + id = sceneIDs[tt.sceneIdx] + } + + _, err := qb.SaveActivity(ctx, id, tt.resumeTime, tt.playDuration) + if (err != nil) != tt.wantErr { + t.Errorf("SceneStore.SaveActivity() error = %v, wantErr %v", err, tt.wantErr) + } + + if err != nil { + return nil + } + + assert := assert.New(t) + + // find the scene and check the values + scene, err := qb.Find(ctx, id) + if err != nil { + t.Errorf("SceneStore.Find() error = %v", err) + } + + expectedResumeTime := getSceneResumeTime(tt.sceneIdx) + expectedPlayDuration := getScenePlayDuration(tt.sceneIdx) + + if tt.resumeTime != nil { + expectedResumeTime = *tt.resumeTime + } + if tt.playDuration != nil { + expectedPlayDuration += *tt.playDuration + } + + assert.Equal(expectedResumeTime, scene.ResumeTime) + assert.Equal(expectedPlayDuration, scene.PlayDuration) + + return nil + }) + }) + } +} + // TODO Count // TODO SizeCount diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 86169dcf2..75d3360d0 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -944,6 +944,35 @@ func makeSceneFile(i int) *file.VideoFile { } } +func getScenePlayCount(index int) int { + return index % 5 +} + +func getScenePlayDuration(index int) float64 { + if index%5 == 0 { + return 0 + } + + return float64(index%5) * 123.4 +} + +func getSceneResumeTime(index int) float64 { + if index%5 == 0 { + return 0 + } + + return float64(index%5) * 1.2 +} + +func getSceneLastPlayed(index int) *time.Time { + if index%5 == 0 { + return nil + } + + t := time.Date(2020, 1, index%5, 1, 2, 3, 0, time.UTC) + return &t +} + func makeScene(i int) *models.Scene { title := getSceneTitle(i) details := getSceneStringValue(i, "Details") @@ -984,6 +1013,10 @@ func makeScene(i int) *models.Scene { StashIDs: models.NewRelatedStashIDs([]models.StashID{ sceneStashID(i), }), + PlayCount: getScenePlayCount(i), + PlayDuration: getScenePlayDuration(i), + LastPlayedAt: getSceneLastPlayed(i), + ResumeTime: getSceneResumeTime(i), } } diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 527985766..d4ff22229 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -16,7 +16,12 @@ import "./persist-volume"; import "./markers"; import "./vtt-thumbnails"; import "./big-buttons"; +import "./track-activity"; import cx from "classnames"; +import { + useSceneSaveActivity, + useSceneIncrementPlayCount, +} from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { ScenePlayerScrubber } from "./ScenePlayerScrubber"; @@ -28,6 +33,7 @@ import { import { SceneInteractiveStatus } from "src/hooks/Interactive/status"; import { languageMap } from "src/utils/caption"; import { VIDEO_PLAYER_ID } from "./util"; +import { IUIConfig } from "src/core/config"; function handleHotkeys(player: VideoJsPlayer, event: videojs.KeyboardEvent) { function seekPercent(percent: number) { @@ -156,13 +162,17 @@ export const ScenePlayer: React.FC = ({ onPrevious, }) => { const { configuration } = useContext(ConfigurationContext); - const config = configuration?.interface; + const interfaceConfig = configuration?.interface; + const uiConfig = configuration?.ui as IUIConfig | undefined; const videoRef = useRef(null); const playerRef = useRef(); const sceneId = useRef(); + const [sceneSaveActivity] = useSceneSaveActivity(); + const [sceneIncrementPlayCount] = useSceneIncrementPlayCount(); const [time, setTime] = useState(0); const [ready, setReady] = useState(false); + const [sessionInitialised, setSessionInitialised] = useState(false); // tracks play session. This is reset whenever ScenePlayer page is exited const { interactive: interactiveClient, @@ -180,12 +190,15 @@ export const ScenePlayer: React.FC = ({ const auto = useRef(false); const interactiveReady = useRef(false); + const minimumPlayPercent = uiConfig?.minimumPlayPercent ?? 0; + const trackActivity = uiConfig?.trackActivity ?? false; + const file = useMemo( () => ((scene?.files.length ?? 0) > 0 ? scene?.files[0] : undefined), [scene] ); - const maxLoopDuration = config?.maximumLoopDuration ?? 0; + const maxLoopDuration = interfaceConfig?.maximumLoopDuration ?? 0; const looping = useMemo( () => !!file?.duration && @@ -256,6 +269,7 @@ export const ScenePlayer: React.FC = ({ back: 10, }, skipButtons: {}, + trackActivity: {}, }, }; @@ -341,6 +355,7 @@ export const ScenePlayer: React.FC = ({ player.off("fullscreenchange", fullscreenchange); }; }, []); + useEffect(() => { function onplay(this: VideoJsPlayer) { this.persistVolume().enabled = true; @@ -390,6 +405,14 @@ export const ScenePlayer: React.FC = ({ // don't re-initialise the player unless the scene has changed if (!scene || !file || scene.id === sceneId.current) return; + + // if new scene was picked from playlist + if (playerRef.current && sceneId.current) { + if (trackActivity) { + playerRef.current.trackActivity().reset(); + } + } + sceneId.current = scene.id; setReady(false); @@ -398,6 +421,8 @@ export const ScenePlayer: React.FC = ({ interactiveClient.pause(); interactiveReady.current = false; + const alwaysStartFromBeginning = + uiConfig?.alwaysStartFromBeginning ?? false; const isLandscape = file.height && file.width && file.width > file.height; if (isLandscape) { @@ -489,10 +514,21 @@ export const ScenePlayer: React.FC = ({ } auto.current = - autoplay || (config?.autostartVideo ?? false) || _initialTimestamp > 0; + autoplay || + (interfaceConfig?.autostartVideo ?? false) || + _initialTimestamp > 0; - initialTimestamp.current = _initialTimestamp; - setTime(_initialTimestamp); + var startPositition = _initialTimestamp; + if ( + !(alwaysStartFromBeginning || sessionInitialised) && + file.duration > scene.resume_time! + ) { + startPositition = scene.resume_time!; + } + + initialTimestamp.current = startPositition; + setTime(startPositition); + setSessionInitialised(true); player.load(); player.focus(); @@ -510,12 +546,54 @@ export const ScenePlayer: React.FC = ({ }, [ file, scene, + trackActivity, interactiveClient, + sessionInitialised, autoplay, - config?.autostartVideo, + interfaceConfig?.autostartVideo, + uiConfig?.alwaysStartFromBeginning, _initialTimestamp, ]); + useEffect(() => { + const player = playerRef.current; + if (!player) return; + + async function saveActivity(resumeTime: number, playDuration: number) { + if (!scene?.id) return; + + await sceneSaveActivity({ + variables: { + id: scene.id, + playDuration, + resume_time: resumeTime, + }, + }); + } + + async function incrementPlayCount() { + if (!scene?.id) return; + + await sceneIncrementPlayCount({ + variables: { + id: scene.id, + }, + }); + } + + const activity = player.trackActivity(); + activity.saveActivity = saveActivity; + activity.incrementPlayCount = incrementPlayCount; + activity.minimumPlayPercent = minimumPlayPercent; + activity.setEnabled(trackActivity); + }, [ + scene, + trackActivity, + minimumPlayPercent, + sceneIncrementPlayCount, + sceneSaveActivity, + ]); + useEffect(() => { const player = playerRef.current; if (!player) return; diff --git a/ui/v2.5/src/components/ScenePlayer/track-activity.ts b/ui/v2.5/src/components/ScenePlayer/track-activity.ts new file mode 100644 index 000000000..f4ed2eb49 --- /dev/null +++ b/ui/v2.5/src/components/ScenePlayer/track-activity.ts @@ -0,0 +1,131 @@ +import videojs, { VideoJsPlayer } from "video.js"; + +const intervalSeconds = 1; // check every second +const sendInterval = 10; // send every 10 seconds + +class TrackActivityPlugin extends videojs.getPlugin("plugin") { + totalPlayDuration = 0; + currentPlayDuration = 0; + minimumPlayPercent = 0; + incrementPlayCount: () => Promise = () => { + return Promise.resolve(); + }; + saveActivity: ( + resumeTime: number, + playDuration: number + ) => Promise = () => { + return Promise.resolve(); + }; + + private enabled = false; + private playCountIncremented = false; + private intervalID: number | undefined; + + private lastResumeTime = 0; + private lastDuration = 0; + + constructor(player: VideoJsPlayer) { + super(player); + + player.on("play", () => { + this.start(); + }); + + player.on("pause", () => { + this.stop(); + }); + + player.on("dispose", () => { + this.stop(); + }); + } + + private start() { + if (this.enabled && !this.intervalID) { + this.intervalID = window.setInterval(() => { + this.intervalHandler(); + }, intervalSeconds * 1000); + this.lastResumeTime = this.player.currentTime(); + this.lastDuration = this.player.duration(); + } + } + + private stop() { + if (this.intervalID) { + window.clearInterval(this.intervalID); + this.intervalID = undefined; + this.sendActivity(); + } + } + + reset() { + this.stop(); + this.totalPlayDuration = 0; + this.currentPlayDuration = 0; + this.playCountIncremented = false; + } + + setEnabled(enabled: boolean) { + this.enabled = enabled; + if (!enabled) { + this.stop(); + } else if (!this.player.paused()) { + this.start(); + } + } + + private intervalHandler() { + if (!this.enabled || !this.player) return; + + this.lastResumeTime = this.player.currentTime(); + this.lastDuration = this.player.duration(); + + this.totalPlayDuration += intervalSeconds; + this.currentPlayDuration += intervalSeconds; + if (this.totalPlayDuration % sendInterval === 0) { + this.sendActivity(); + } + } + + private sendActivity() { + if (!this.enabled) return; + + if (this.totalPlayDuration > 0) { + let resumeTime = this.player?.currentTime() ?? this.lastResumeTime; + const videoDuration = this.player?.duration() ?? this.lastDuration; + const percentCompleted = (100 / videoDuration) * resumeTime; + const percentPlayed = (100 / videoDuration) * this.totalPlayDuration; + + if ( + !this.playCountIncremented && + percentPlayed >= this.minimumPlayPercent + ) { + this.incrementPlayCount(); + this.playCountIncremented = true; + } + + // if video is 98% or more complete then reset resume_time + if (percentCompleted >= 98) { + resumeTime = 0; + } + + this.saveActivity(resumeTime, this.currentPlayDuration); + this.currentPlayDuration = 0; + } + } +} + +// Register the plugin with video.js. +videojs.registerPlugin("trackActivity", TrackActivityPlugin); + +/* eslint-disable @typescript-eslint/naming-convention */ +declare module "video.js" { + interface VideoJsPlayer { + trackActivity: () => TrackActivityPlugin; + } + interface VideoJsPlayerPluginOptions { + trackActivity?: {}; + } +} + +export default TrackActivityPlugin; diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index c63cfd7d3..b7b79c0e7 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -388,6 +388,8 @@ export const SceneCard: React.FC = ( title={objectTitle(props.scene)} linkClassName="scene-card-link" thumbnailSectionClassName="video-section" + resumeTime={props.scene.resume_time ?? undefined} + duration={file?.duration ?? undefined} interactiveHeatmap={ props.scene.interactive_speed ? props.scene.paths.interactive_heatmap ?? undefined diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx index d45e9df02..966a44e77 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx @@ -309,7 +309,23 @@ export const SceneFileInfoPanel: React.FC = ( value={props.scene.url} truncate /> + {renderStashIDs()} + + {filesPanel} diff --git a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx index 71d444916..dfbeacb74 100644 --- a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx @@ -9,7 +9,7 @@ import { SceneSelect, StringListSelect, } from "src/components/Shared"; -import { FormUtils, ImageUtils } from "src/utils"; +import { FormUtils, ImageUtils, TextUtils } from "src/utils"; import { mutateSceneMerge, queryFindScenesByID } from "src/core/StashService"; import { FormattedMessage, useIntl } from "react-intl"; import { useToast } from "src/hooks"; @@ -72,6 +72,13 @@ const SceneMergeDetails: React.FC = ({ const [oCounter, setOCounter] = useState( new ScrapeResult(dest.o_counter) ); + const [playCount, setPlayCount] = useState( + new ScrapeResult(dest.play_count) + ); + const [playDuration, setPlayDuration] = useState( + new ScrapeResult(dest.play_duration) + ); + const [studio, setStudio] = useState>( new ScrapeResult(dest.studio?.id) ); @@ -209,6 +216,20 @@ const SceneMergeDetails: React.FC = ({ ) ); + setPlayCount( + new ScrapeResult( + dest.play_count ?? 0, + all.map((s) => s.play_count ?? 0).reduce((pv, cv) => pv + cv, 0) + ) + ); + + setPlayDuration( + new ScrapeResult( + dest.play_duration ?? 0, + all.map((s) => s.play_duration ?? 0).reduce((pv, cv) => pv + cv, 0) + ) + ); + setStashIDs( new ScrapeResult( dest.stash_ids, @@ -352,7 +373,51 @@ const SceneMergeDetails: React.FC = ({ className="bg-secondary text-white border-secondary" /> )} - onChange={(value) => setRating(value)} + onChange={(value) => setOCounter(value)} + /> + ( + {}} + className="bg-secondary text-white border-secondary" + /> + )} + renderNewField={() => ( + {}} + className="bg-secondary text-white border-secondary" + /> + )} + onChange={(value) => setPlayCount(value)} + /> + ( + {}} + className="bg-secondary text-white border-secondary" + /> + )} + renderNewField={() => ( + {}} + className="bg-secondary text-white border-secondary" + /> + )} + onChange={(value) => setPlayDuration(value)} /> = ({ date: date.getNewValue(), rating100: rating.getNewValue(), o_counter: oCounter.getNewValue(), + play_count: playCount.getNewValue(), + play_duration: playDuration.getNewValue(), gallery_ids: galleries.getNewValue(), studio_id: studio.getNewValue(), performer_ids: performers.getNewValue(), diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 4a52d6a2b..2940e48e5 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -1,7 +1,11 @@ import React from "react"; import { Button, Form } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; -import { DurationInput, LoadingIndicator } from "src/components/Shared"; +import { + DurationInput, + PercentInput, + LoadingIndicator, +} from "src/components/Shared"; import { CheckboxGroup } from "./CheckboxGroup"; import { SettingSection } from "../SettingSection"; import { @@ -243,6 +247,41 @@ export const SettingsInterfacePanel: React.FC = () => { checked={iface.showScrubber ?? undefined} onChange={(v) => saveInterface({ showScrubber: v })} /> + saveUI({ alwaysStartFromBeginning: v })} + /> + saveUI({ trackActivity: v })} + /> + + id="ignore-interval" + headingID="config.ui.minimum_play_percent.heading" + subHeadingID="config.ui.minimum_play_percent.description" + value={ui.minimumPlayPercent ?? 0} + onChange={(v) => saveUI({ minimumPlayPercent: v })} + disabled={!ui.trackActivity} + renderField={(value, setValue) => ( + setValue(interval ?? 0)} + /> + )} + renderValue={(v) => { + return {v}%; + }} + /> + saveLightboxSettings({ slideshowDelay: v })} + /> void; + resumeTime?: number; + duration?: number; interactiveHeatmap?: string; } @@ -91,6 +93,22 @@ export const GridCard: React.FC = (props: ICardProps) => { } } + function maybeRenderProgressBar() { + if ( + props.resumeTime && + props.duration && + props.duration > props.resumeTime + ) { + const percentValue = (100 / props.duration) * props.resumeTime; + const percentStr = percentValue + "%"; + return ( +
+
+
+ ); + } + } + return ( = (props: ICardProps) => { {props.image} {props.overlays} + {maybeRenderProgressBar()}
{maybeRenderInteractiveHeatmap()}
diff --git a/ui/v2.5/src/components/Shared/PercentInput.tsx b/ui/v2.5/src/components/Shared/PercentInput.tsx new file mode 100644 index 000000000..783ead755 --- /dev/null +++ b/ui/v2.5/src/components/Shared/PercentInput.tsx @@ -0,0 +1,140 @@ +import { + faChevronDown, + faChevronUp, + faClock, +} from "@fortawesome/free-solid-svg-icons"; +import React, { useState, useEffect } from "react"; +import { Button, ButtonGroup, InputGroup, Form } from "react-bootstrap"; +import Icon from "src/components/Shared/Icon"; +import { PercentUtils } from "src/utils"; + +interface IProps { + disabled?: boolean; + numericValue: number | undefined; + mandatory?: boolean; + onValueChange( + valueAsNumber: number | undefined, + valueAsString?: string + ): void; + onReset?(): void; + className?: string; + placeholder?: string; +} + +export const PercentInput: React.FC = (props: IProps) => { + const [value, setValue] = useState( + props.numericValue !== undefined + ? PercentUtils.numberToString(props.numericValue) + : undefined + ); + + useEffect(() => { + if (props.numericValue !== undefined || props.mandatory) { + setValue(PercentUtils.numberToString(props.numericValue ?? 0)); + } else { + setValue(undefined); + } + }, [props.numericValue, props.mandatory]); + + function increment() { + if (value === undefined) { + return; + } + + let percent = PercentUtils.stringToNumber(value); + if (percent >= 100) { + percent = 0; + } else { + percent += 1; + } + props.onValueChange(percent, PercentUtils.numberToString(percent)); + } + + function decrement() { + if (value === undefined) { + return; + } + + let percent = PercentUtils.stringToNumber(value); + if (percent <= 0) { + percent = 100; + } else { + percent -= 1; + } + props.onValueChange(percent, PercentUtils.numberToString(percent)); + } + + function renderButtons() { + if (!props.disabled) { + return ( + + + + + ); + } + } + + function onReset() { + if (props.onReset) { + props.onReset(); + } + } + + function maybeRenderReset() { + if (props.onReset) { + return ( + + ); + } + } + + return ( +
+ + ) => + setValue(e.currentTarget.value) + } + onBlur={() => { + if (props.mandatory || (value !== undefined && value !== "")) { + props.onValueChange(PercentUtils.stringToNumber(value), value); + } else { + props.onValueChange(undefined); + } + }} + placeholder={ + !props.disabled + ? props.placeholder + ? `${props.placeholder} (%)` + : "%" + : undefined + } + /> + + {maybeRenderReset()} + {renderButtons()} + + +
+ ); +}; diff --git a/ui/v2.5/src/components/Shared/index.ts b/ui/v2.5/src/components/Shared/index.ts index 80a25bb2d..24ad5a8f7 100644 --- a/ui/v2.5/src/components/Shared/index.ts +++ b/ui/v2.5/src/components/Shared/index.ts @@ -4,6 +4,7 @@ export { default as Modal } from "./Modal"; export { CollapseButton } from "./CollapseButton"; export { DetailsEditNavbar } from "./DetailsEditNavbar"; export { DurationInput } from "./DurationInput"; +export { PercentInput } from "./PercentInput"; export { TagLink } from "./TagLink"; export { HoverPopover } from "./HoverPopover"; export { default as LoadingIndicator } from "./LoadingIndicator"; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 9632de0c5..83758cb11 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -59,12 +59,15 @@ } } -.duration-input { - .duration-control { +.duration-input, +.percent-input { + .duration-control, + .percent-control { min-width: 3rem; } - .duration-button { + .duration-button, + .percent-button { border-bottom-left-radius: 0; border-top-left-radius: 0; line-height: 10px; @@ -167,6 +170,20 @@ button.collapse-button.btn-primary:not(:disabled):not(.disabled):active { text-decoration: none; } + .progress-bar { + background-color: #73859f80; + bottom: 5px; + display: block; + height: 5px; + position: absolute; + width: 100%; + } + + .progress-indicator { + background-color: #137cbd; + height: 5px; + } + .card-check { left: 0.5rem; margin-top: -12px; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 1226a14d4..b48233a89 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -887,6 +887,16 @@ export const useTagsDestroy = (input: GQL.TagsDestroyMutationVariables) => update: deleteCache(tagMutationImpactedQueries), }); +export const useSceneSaveActivity = () => + GQL.useSceneSaveActivityMutation({ + update: deleteCache([GQL.FindScenesDocument]), + }); + +export const useSceneIncrementPlayCount = () => + GQL.useSceneIncrementPlayCountMutation({ + update: deleteCache([GQL.FindScenesDocument]), + }); + export const savedFilterMutationImpactedQueries = [ GQL.FindSavedFiltersDocument, ]; diff --git a/ui/v2.5/src/core/config.ts b/ui/v2.5/src/core/config.ts index d7b87ceaf..c43780735 100644 --- a/ui/v2.5/src/core/config.ts +++ b/ui/v2.5/src/core/config.ts @@ -28,13 +28,24 @@ export type FrontPageContent = ISavedFilterRow | ICustomFilter; export interface IUIConfig { frontPageContent?: FrontPageContent[]; - lastNoteSeen?: number; + showChildTagContent?: boolean; showChildStudioContent?: boolean; showTagCardOnHover?: boolean; + abbreviateCounters?: boolean; ratingSystemOptions?: RatingSystemOptions; + + // if true continue scene will always play from the beginning + alwaysStartFromBeginning?: boolean; + // if true enable activity tracking + trackActivity?: boolean; + // the minimum percentage of scene duration which a scene must be played + // before the play count is incremented + minimumPlayPercent?: number; + + lastNoteSeen?: number; } function recentlyReleased( diff --git a/ui/v2.5/src/docs/en/Changelog/v0180.md b/ui/v2.5/src/docs/en/Changelog/v0180.md index 740e0caa8..94bb2faef 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0180.md +++ b/ui/v2.5/src/docs/en/Changelog/v0180.md @@ -1,4 +1,6 @@ ### ✨ New Features +* Added ability to track play count and duration for scenes. ([#3055](https://github.com/stashapp/stash/pull/3055)) +* Scenes now optionally show the last point watched, and can be resumed from that point. ([#3055](https://github.com/stashapp/stash/pull/3055)) * Added support for filtering stash ids by endpoint. ([#3005](https://github.com/stashapp/stash/pull/3005)) * Added custom javascript option. ([#3132](https://github.com/stashapp/stash/pull/3132)) * Added ability to select rating system in the Interface settings, allowing 5 stars with full-, half- or quarter-stars, or numeric score out of 10 with one decimal point. ([#2830](https://github.com/stashapp/stash/pull/2830)) diff --git a/ui/v2.5/src/docs/en/Manual/Interface.md b/ui/v2.5/src/docs/en/Manual/Interface.md index d1d22208a..c948dd438 100644 --- a/ui/v2.5/src/docs/en/Manual/Interface.md +++ b/ui/v2.5/src/docs/en/Manual/Interface.md @@ -20,6 +20,14 @@ By default, scene videos do not automatically start when navigating to the scene The maximum loop duration option allows looping of shorter videos. Set this value to the maximum scene duration that scene videos should loop. Setting this to 0 disables this functionality. +### Activity tracking + +The "Track Activity" option allows tracking of scene play count and duration, and sets the resume point when a scene video is not finished. + +The "Minimum Play Percent" gives the minimum proportion of a video that must be played before the play count of the scene is incremented. + +By default, when a scene has a resume point, the scene player will automatically seek to this point when the scene is played. Setting "Always start video from beginning" to true disables this behaviour. + ## Custom CSS The stash UI can be customised using custom CSS. See [here](https://github.com/stashapp/stash/wiki/Custom-CSS-snippets) for a community-curated set of CSS snippets to customise your UI. diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 5e222fb70..c17b39e96 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -531,6 +531,10 @@ "description": "Show or hide different types of content on the navigation bar", "heading": "Menu Items" }, + "minimum_play_percent": { + "description": "The percentage of time in which a scene must be played before its play count is incremented.", + "heading": "Minumum Play Percent" + }, "show_tag_card_on_hover": { "description": "Show tag card when hovering tag badges", "heading": "Tag card tooltips" @@ -561,6 +565,7 @@ "scene_player": { "heading": "Scene Player", "options": { + "always_start_from_beginning": "Always start video from beginning", "auto_start_video": "Auto-start video", "auto_start_video_on_play_selected": { "description": "Auto-start scene videos when playing from queue, or playing selected or random from Scenes page", @@ -570,7 +575,8 @@ "description": "Play next scene in queue when video finishes", "heading": "Continue playlist by default" }, - "show_scrubber": "Show Scrubber" + "show_scrubber": "Show Scrubber", + "track_activity": "Track Activity" } }, "scene_wall": { @@ -610,6 +616,7 @@ } }, "configuration": "Configuration", + "resume_time": "Resume Time", "countables": { "files": "{count, plural, one {File} other {Files}}", "galleries": "{count, plural, one {Gallery} other {Galleries}}", @@ -856,6 +863,7 @@ "interactive": "Interactive", "interactive_speed": "Interactive speed", "isMissing": "Is Missing", + "last_played_at": "Last Played At", "library": "Library", "loading": { "generic": "Loading…" @@ -873,6 +881,8 @@ "age": "{age} {years_old}", "age_context": "{age} {years_old} in this scene" }, + "play_count": "Play Count", + "play_duration": "Play Duration", "phash": "PHash", "stream": "Stream", "video_codec": "Video Codec" @@ -1107,6 +1117,8 @@ "url": "URL", "videos": "Videos", "view_all": "View All", + "play_count": "Play Count", + "play_duration": "Play Duration", "weight": "Weight", "weight_kg": "Weight (kg)", "years_old": "years old", diff --git a/ui/v2.5/src/models/list-filter/criteria/factory.ts b/ui/v2.5/src/models/list-filter/criteria/factory.ts index 53f50eec4..bbc870543 100644 --- a/ui/v2.5/src/models/list-filter/criteria/factory.ts +++ b/ui/v2.5/src/models/list-filter/criteria/factory.ts @@ -87,6 +87,7 @@ export function makeCriteria( case "performer_age": case "tag_count": case "file_count": + case "play_count": return new NumberCriterion( new MandatoryNumberCriterionOption(type, type) ); @@ -102,7 +103,9 @@ export function makeCriteria( return new ResolutionCriterion(); case "average_resolution": return new AverageResolutionCriterion(); + case "resume_time": case "duration": + case "play_duration": return new DurationCriterion(new NumberCriterionOption(type, type)); case "favorite": return new FavoriteCriterion(); diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index 5fd3142bf..b894628d8 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -38,6 +38,10 @@ const sortByOptions = [ "duration", "framerate", "bitrate", + "last_played_at", + "resume_time", + "play_duration", + "play_count", "movie_scene_number", "interactive", "interactive_speed", @@ -71,6 +75,9 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("o_counter"), ResolutionCriterionOption, createMandatoryNumberCriterionOption("duration"), + createMandatoryNumberCriterionOption("resume_time"), + createMandatoryNumberCriterionOption("play_duration"), + createMandatoryNumberCriterionOption("play_count"), HasMarkersCriterionOption, SceneIsMissingCriterionOption, TagsCriterionOption, diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 21b81ef23..eb671d37e 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -146,6 +146,9 @@ export type CriterionType = | "interactive" | "interactive_speed" | "captions" + | "resume_time" + | "play_count" + | "play_duration" | "name" | "details" | "title" diff --git a/ui/v2.5/src/utils/index.ts b/ui/v2.5/src/utils/index.ts index d9bdc6c24..4ed80cc3e 100644 --- a/ui/v2.5/src/utils/index.ts +++ b/ui/v2.5/src/utils/index.ts @@ -6,6 +6,7 @@ export { default as TextUtils } from "./text"; export { default as EditableTextUtils } from "./editabletext"; export { default as FormUtils } from "./form"; export { default as DurationUtils } from "./duration"; +export { default as PercentUtils } from "./percent"; export { default as SessionUtils } from "./session"; export { default as flattenMessages } from "./flattenMessages"; export * from "./country"; diff --git a/ui/v2.5/src/utils/percent.ts b/ui/v2.5/src/utils/percent.ts new file mode 100644 index 000000000..6616733af --- /dev/null +++ b/ui/v2.5/src/utils/percent.ts @@ -0,0 +1,17 @@ +const numberToString = (seconds: number) => { + return seconds + "%"; +}; + +const stringToNumber = (v?: string) => { + if (!v) { + return 0; + } + + const numStr = v.replace("%", ""); + return parseInt(numStr, 10); +}; + +export default { + numberToString, + stringToNumber, +};