Track watch activity for scenes. (#3055)

* track watchtime and view time
* add view count sorting, added continue position filter
* display metrics in file info
* add toggle for tracking activity
* save activity every 10 seconds
* reset resume when video is nearly complete
* start from beginning when playing scene in queue

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
CJ 2022-11-20 19:55:15 -06:00 committed by GitHub
parent f39fa416a9
commit 0664c5b974
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1239 additions and 104 deletions

View file

@ -11,6 +11,9 @@ fragment SlimSceneData on Scene {
organized
interactive
interactive_speed
resume_time
play_duration
play_count
files {
...VideoFileData

View file

@ -17,6 +17,10 @@ fragment SceneData on Scene {
}
created_at
updated_at
resume_time
last_played_at
play_duration
play_count
files {
...VideoFileData

View file

@ -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)
}

View file

@ -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!

View file

@ -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"""

View file

@ -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
}

View file

@ -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)
}

View file

@ -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 {

View file

@ -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())

View file

@ -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 {

View file

@ -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)

View file

@ -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"`
}

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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;

View file

@ -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))

View file

@ -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 {

View file

@ -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)
}

View file

@ -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

View file

@ -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),
}
}

View file

@ -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<IScenePlayerProps> = ({
onPrevious,
}) => {
const { configuration } = useContext(ConfigurationContext);
const config = configuration?.interface;
const interfaceConfig = configuration?.interface;
const uiConfig = configuration?.ui as IUIConfig | undefined;
const videoRef = useRef<HTMLVideoElement>(null);
const playerRef = useRef<VideoJsPlayer>();
const sceneId = useRef<string>();
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<IScenePlayerProps> = ({
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<IScenePlayerProps> = ({
back: 10,
},
skipButtons: {},
trackActivity: {},
},
};
@ -341,6 +355,7 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
player.off("fullscreenchange", fullscreenchange);
};
}, []);
useEffect(() => {
function onplay(this: VideoJsPlayer) {
this.persistVolume().enabled = true;
@ -390,6 +405,14 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
// 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<IScenePlayerProps> = ({
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<IScenePlayerProps> = ({
}
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<IScenePlayerProps> = ({
}, [
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;

View file

@ -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<void> = () => {
return Promise.resolve();
};
saveActivity: (
resumeTime: number,
playDuration: number
) => Promise<void> = () => {
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;

View file

@ -388,6 +388,8 @@ export const SceneCard: React.FC<ISceneCardProps> = (
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

View file

@ -309,7 +309,23 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
value={props.scene.url}
truncate
/>
<URLField
id="media_info.downloaded_from"
url={props.scene.url}
value={props.scene.url}
truncate
/>
{renderStashIDs()}
<TextField
id="media_info.play_count"
value={(props.scene.play_count ?? 0).toString()}
truncate
/>
<TextField
id="media_info.play_duration"
value={TextUtils.secondsToTimestamp(props.scene.play_duration ?? 0)}
truncate
/>
</dl>
{filesPanel}

View file

@ -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<ISceneMergeDetailsProps> = ({
const [oCounter, setOCounter] = useState(
new ScrapeResult<number>(dest.o_counter)
);
const [playCount, setPlayCount] = useState(
new ScrapeResult<number>(dest.play_count)
);
const [playDuration, setPlayDuration] = useState(
new ScrapeResult<number>(dest.play_duration)
);
const [studio, setStudio] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.studio?.id)
);
@ -209,6 +216,20 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
)
);
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<ISceneMergeDetailsProps> = ({
className="bg-secondary text-white border-secondary"
/>
)}
onChange={(value) => setRating(value)}
onChange={(value) => setOCounter(value)}
/>
<ScrapeDialogRow
title={intl.formatMessage({ id: "play_count" })}
result={playCount}
renderOriginalField={() => (
<FormControl
value={playCount.originalValue ?? 0}
readOnly
onChange={() => {}}
className="bg-secondary text-white border-secondary"
/>
)}
renderNewField={() => (
<FormControl
value={playCount.newValue ?? 0}
readOnly
onChange={() => {}}
className="bg-secondary text-white border-secondary"
/>
)}
onChange={(value) => setPlayCount(value)}
/>
<ScrapeDialogRow
title={intl.formatMessage({ id: "play_duration" })}
result={playDuration}
renderOriginalField={() => (
<FormControl
value={TextUtils.secondsToTimestamp(
playDuration.originalValue ?? 0
)}
readOnly
onChange={() => {}}
className="bg-secondary text-white border-secondary"
/>
)}
renderNewField={() => (
<FormControl
value={TextUtils.secondsToTimestamp(playDuration.newValue ?? 0)}
readOnly
onChange={() => {}}
className="bg-secondary text-white border-secondary"
/>
)}
onChange={(value) => setPlayDuration(value)}
/>
<ScrapeDialogRow
title={intl.formatMessage({ id: "galleries" })}
@ -434,6 +499,8 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
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(),

View file

@ -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 })}
/>
<BooleanSetting
id="always-start-from-beginning"
headingID="config.ui.scene_player.options.always_start_from_beginning"
checked={ui.alwaysStartFromBeginning ?? undefined}
onChange={(v) => saveUI({ alwaysStartFromBeginning: v })}
/>
<BooleanSetting
id="track-activity"
headingID="config.ui.scene_player.options.track_activity"
checked={ui.trackActivity ?? undefined}
onChange={(v) => saveUI({ trackActivity: v })}
/>
<ModalSetting<number>
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) => (
<PercentInput
numericValue={value}
onValueChange={(interval) => setValue(interval ?? 0)}
/>
)}
renderValue={(v) => {
return <span>{v}%</span>;
}}
/>
<NumberSetting
headingID="config.ui.slideshow_delay.heading"
subHeadingID="config.ui.slideshow_delay.description"
value={iface.imageLightbox?.slideshowDelay ?? undefined}
onChange={(v) => saveLightboxSettings({ slideshowDelay: v })}
/>
<BooleanSetting
id="auto-start-video"
headingID="config.ui.scene_player.options.auto_start_video"

View file

@ -18,6 +18,8 @@ interface ICardProps {
selecting?: boolean;
selected?: boolean;
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
resumeTime?: number;
duration?: number;
interactiveHeatmap?: string;
}
@ -91,6 +93,22 @@ export const GridCard: React.FC<ICardProps> = (props: ICardProps) => {
}
}
function maybeRenderProgressBar() {
if (
props.resumeTime &&
props.duration &&
props.duration > props.resumeTime
) {
const percentValue = (100 / props.duration) * props.resumeTime;
const percentStr = percentValue + "%";
return (
<div title={Math.round(percentValue) + "%"} className="progress-bar">
<div style={{ width: percentStr }} className="progress-indicator" />
</div>
);
}
}
return (
<Card
className={cx(props.className, "grid-card")}
@ -110,6 +128,7 @@ export const GridCard: React.FC<ICardProps> = (props: ICardProps) => {
{props.image}
</Link>
{props.overlays}
{maybeRenderProgressBar()}
</div>
{maybeRenderInteractiveHeatmap()}
<div className="card-section">

View file

@ -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<IProps> = (props: IProps) => {
const [value, setValue] = useState<string | undefined>(
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 (
<ButtonGroup vertical>
<Button
variant="secondary"
className="percent-button"
disabled={props.disabled}
onClick={() => increment()}
>
<Icon icon={faChevronUp} />
</Button>
<Button
variant="secondary"
className="percent-button"
disabled={props.disabled}
onClick={() => decrement()}
>
<Icon icon={faChevronDown} />
</Button>
</ButtonGroup>
);
}
}
function onReset() {
if (props.onReset) {
props.onReset();
}
}
function maybeRenderReset() {
if (props.onReset) {
return (
<Button variant="secondary" onClick={onReset}>
<Icon icon={faClock} />
</Button>
);
}
}
return (
<div className={`percent-input ${props.className}`}>
<InputGroup>
<Form.Control
className="percent-control text-input"
disabled={props.disabled}
value={value ?? 0}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
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
}
/>
<InputGroup.Append>
{maybeRenderReset()}
{renderButtons()}
</InputGroup.Append>
</InputGroup>
</div>
);
};

View file

@ -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";

View file

@ -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;

View file

@ -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,
];

View file

@ -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(

View file

@ -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))

View file

@ -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.

View file

@ -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",

View file

@ -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();

View file

@ -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,

View file

@ -146,6 +146,9 @@ export type CriterionType =
| "interactive"
| "interactive_speed"
| "captions"
| "resume_time"
| "play_count"
| "play_duration"
| "name"
| "details"
| "title"

View file

@ -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";

View file

@ -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,
};