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 organized
interactive interactive
interactive_speed interactive_speed
resume_time
play_duration
play_count
files { files {
...VideoFileData ...VideoFileData

View file

@ -17,6 +17,10 @@ fragment SceneData on Scene {
} }
created_at created_at
updated_at updated_at
resume_time
last_played_at
play_duration
play_count
files { files {
...VideoFileData ...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!) { mutation SceneIncrementO($id: ID!) {
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""" """Resets the o-counter for a scene to 0. Returns the new value"""
sceneResetO(id: ID!): Int! 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""" """Generates screenshot at specified time in seconds. Leave empty to generate default screenshot"""
sceneGenerateScreenshot(id: ID!, at: Float): String! sceneGenerateScreenshot(id: ID!, at: Float): String!

View file

@ -217,6 +217,12 @@ input SceneFilterType {
interactive_speed: IntCriterionInput interactive_speed: IntCriterionInput
"""Filter by captions""" """Filter by captions"""
captions: StringCriterionInput 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""" """Filter by date"""
date: DateCriterionInput date: DateCriterionInput
"""Filter by creation time""" """Filter by creation time"""

View file

@ -56,6 +56,14 @@ type Scene {
created_at: Time! created_at: Time!
updated_at: Time! updated_at: Time!
file_mod_time: 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") file: SceneFileType! @deprecated(reason: "Use files")
files: [VideoFile!]! files: [VideoFile!]!
@ -128,6 +136,13 @@ input SceneUpdateInput {
cover_image: String cover_image: String
stash_ids: [StashIDInput!] 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 primary_file_id: ID
} }

View file

@ -296,3 +296,11 @@ func (t changesetTranslator) optionalBool(value *bool, field string) models.Opti
return models.NewOptionalBoolPtr(value) 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.Date = translator.optionalDate(input.Date, "date")
updatedScene.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100) updatedScene.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100)
updatedScene.OCounter = translator.optionalInt(input.OCounter, "o_counter") 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 var err error
updatedScene.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") updatedScene.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil { if err != nil {
@ -856,6 +858,42 @@ func (r *mutationResolver) changeMarker(ctx context.Context, changeType int, cha
return sceneMarker, nil 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) { func (r *mutationResolver) SceneIncrementO(ctx context.Context, id string) (ret int, err error) {
sceneID, err := strconv.Atoi(id) sceneID, err := strconv.Atoi(id)
if err != nil { 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.Galleries = gallery.GetRefs(galleries)
newSceneJSON.ResumeTime = s.ResumeTime
newSceneJSON.PlayCount = s.PlayCount
newSceneJSON.PlayDuration = s.PlayDuration
performers, err := performerReader.FindBySceneID(ctx, s.ID) performers, err := performerReader.FindBySceneID(ctx, s.ID)
if err != nil { if err != nil {
logger.Errorf("[scenes] <%s> error getting scene performer names: %s", sceneHash, err.Error()) 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 { type Scene struct {
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Code string `json:"code,omitempty"` Code string `json:"code,omitempty"`
Studio string `json:"studio,omitempty"` Studio string `json:"studio,omitempty"`
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
Rating int `json:"rating,omitempty"` Rating int `json:"rating,omitempty"`
Organized bool `json:"organized,omitempty"` Organized bool `json:"organized,omitempty"`
OCounter int `json:"o_counter,omitempty"` OCounter int `json:"o_counter,omitempty"`
Details string `json:"details,omitempty"` Details string `json:"details,omitempty"`
Director string `json:"director,omitempty"` Director string `json:"director,omitempty"`
Galleries []GalleryRef `json:"galleries,omitempty"` Galleries []GalleryRef `json:"galleries,omitempty"`
Performers []string `json:"performers,omitempty"` Performers []string `json:"performers,omitempty"`
Movies []SceneMovie `json:"movies,omitempty"` Movies []SceneMovie `json:"movies,omitempty"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
Markers []SceneMarker `json:"markers,omitempty"` Markers []SceneMarker `json:"markers,omitempty"`
Files []string `json:"files,omitempty"` Files []string `json:"files,omitempty"`
Cover string `json:"cover,omitempty"` Cover string `json:"cover,omitempty"`
CreatedAt json.JSONTime `json:"created_at,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"`
UpdatedAt json.JSONTime `json:"updated_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
StashIDs []models.StashID `json:"stash_ids,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 { 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 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 // IncrementOCounter provides a mock function with given fields: ctx, id
func (_m *SceneReaderWriter) IncrementOCounter(ctx context.Context, id int) (int, error) { func (_m *SceneReaderWriter) IncrementOCounter(ctx context.Context, id int) (int, error) {
ret := _m.Called(ctx, id) ret := _m.Called(ctx, id)

View file

@ -38,6 +38,11 @@ type Scene struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_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"` GalleryIDs RelatedIDs `json:"gallery_ids"`
TagIDs RelatedIDs `json:"tag_ids"` TagIDs RelatedIDs `json:"tag_ids"`
PerformerIDs RelatedIDs `json:"performer_ids"` PerformerIDs RelatedIDs `json:"performer_ids"`
@ -142,12 +147,16 @@ type ScenePartial struct {
URL OptionalString URL OptionalString
Date OptionalDate Date OptionalDate
// Rating expressed in 1-100 scale // Rating expressed in 1-100 scale
Rating OptionalInt Rating OptionalInt
Organized OptionalBool Organized OptionalBool
OCounter OptionalInt OCounter OptionalInt
StudioID OptionalInt StudioID OptionalInt
CreatedAt OptionalTime CreatedAt OptionalTime
UpdatedAt OptionalTime UpdatedAt OptionalTime
ResumeTime OptionalFloat64
PlayDuration OptionalFloat64
PlayCount OptionalInt
LastPlayedAt OptionalTime
GalleryIDs *UpdateIDs GalleryIDs *UpdateIDs
TagIDs *UpdateIDs TagIDs *UpdateIDs
@ -192,6 +201,9 @@ type SceneUpdateInput struct {
// This should be a URL or a base64 encoded data URL // This should be a URL or a base64 encoded data URL
CoverImage *string `json:"cover_image"` CoverImage *string `json:"cover_image"`
StashIds []StashID `json:"stash_ids"` 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"` PrimaryFileID *string `json:"primary_file_id"`
} }

View file

@ -77,8 +77,14 @@ type SceneFilterType struct {
Interactive *bool `json:"interactive"` Interactive *bool `json:"interactive"`
// Filter by InteractiveSpeed // Filter by InteractiveSpeed
InteractiveSpeed *IntCriterionInput `json:"interactive_speed"` InteractiveSpeed *IntCriterionInput `json:"interactive_speed"`
// Filter by captions
Captions *StringCriterionInput `json:"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 // Filter by date
Date *DateCriterionInput `json:"date"` Date *DateCriterionInput `json:"date"`
// Filter by created at // Filter by created at
@ -179,6 +185,8 @@ type SceneWriter interface {
IncrementOCounter(ctx context.Context, id int) (int, error) IncrementOCounter(ctx context.Context, id int) (int, error)
DecrementOCounter(ctx context.Context, id int) (int, error) DecrementOCounter(ctx context.Context, id int) (int, error)
ResetOCounter(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 Destroy(ctx context.Context, id int) error
UpdateCover(ctx context.Context, sceneID int, cover []byte) error UpdateCover(ctx context.Context, sceneID int, cover []byte) error
DestroyCover(ctx context.Context, sceneID int) error DestroyCover(ctx context.Context, sceneID int) error

View file

@ -199,6 +199,18 @@ func NewOptionalFloat64(v float64) OptionalFloat64 {
return OptionalFloat64{v, false, true} 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. // OptionalDate represents an optional date argument that may be null. See OptionalString.
type OptionalDate struct { type OptionalDate struct {
Value Date Value Date

View file

@ -105,6 +105,13 @@ func (i *Importer) sceneJSONToScene(sceneJSON jsonschema.Scene) models.Scene {
newScene.OCounter = sceneJSON.OCounter newScene.OCounter = sceneJSON.OCounter
newScene.CreatedAt = sceneJSON.CreatedAt.GetTime() newScene.CreatedAt = sceneJSON.CreatedAt.GetTime()
newScene.UpdatedAt = sceneJSON.UpdatedAt.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 return newScene
} }

View file

@ -21,7 +21,7 @@ import (
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
) )
var appSchemaVersion uint = 40 var appSchemaVersion uint = 41
//go:embed migrations/*.sql //go:embed migrations/*.sql
var migrationsBox embed.FS 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)) query.handleCriterion(ctx, intCriterionHandler(movieFilter.Rating100, "movies.rating", nil))
// legacy rating handler // legacy rating handler
query.handleCriterion(ctx, rating5CriterionHandler(movieFilter.Rating, "movies.rating", nil)) 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, movieIsMissingCriterionHandler(qb, movieFilter.IsMissing))
query.handleCriterion(ctx, stringCriterionHandler(movieFilter.URL, "movies.url")) query.handleCriterion(ctx, stringCriterionHandler(movieFilter.URL, "movies.url"))
query.handleCriterion(ctx, movieStudioCriterionHandler(qb, movieFilter.Studios)) 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) { func (r *updateRecord) setFloat64(destField string, v models.OptionalFloat64) {
// if v.Set { if v.Set {
// if v.Null { if v.Null {
// panic("null value not allowed in optional float64") panic("null value not allowed in optional float64")
// } }
// r.set(destField, v.Value) r.set(destField, v.Value)
// } }
// } }
// func (r *updateRecord) setNullFloat64(destField string, v models.OptionalFloat64) { // func (r *updateRecord) setNullFloat64(destField string, v models.OptionalFloat64) {
// if v.Set { // if v.Set {

View file

@ -8,6 +8,7 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9"
"github.com/doug-martin/goqu/v9/exp" "github.com/doug-martin/goqu/v9/exp"
@ -60,12 +61,16 @@ type sceneRow struct {
URL zero.String `db:"url"` URL zero.String `db:"url"`
Date models.SQLiteDate `db:"date"` Date models.SQLiteDate `db:"date"`
// expressed as 1-100 // expressed as 1-100
Rating null.Int `db:"rating"` Rating null.Int `db:"rating"`
Organized bool `db:"organized"` Organized bool `db:"organized"`
OCounter int `db:"o_counter"` OCounter int `db:"o_counter"`
StudioID null.Int `db:"studio_id,omitempty"` StudioID null.Int `db:"studio_id,omitempty"`
CreatedAt models.SQLiteTimestamp `db:"created_at"` CreatedAt models.SQLiteTimestamp `db:"created_at"`
UpdatedAt models.SQLiteTimestamp `db:"updated_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) { func (r *sceneRow) fromScene(o models.Scene) {
@ -84,6 +89,15 @@ func (r *sceneRow) fromScene(o models.Scene) {
r.StudioID = intFromPtr(o.StudioID) r.StudioID = intFromPtr(o.StudioID)
r.CreatedAt = models.SQLiteTimestamp{Timestamp: o.CreatedAt} r.CreatedAt = models.SQLiteTimestamp{Timestamp: o.CreatedAt}
r.UpdatedAt = models.SQLiteTimestamp{Timestamp: o.UpdatedAt} 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 { type sceneQueryRow struct {
@ -115,12 +129,20 @@ func (r *sceneQueryRow) resolve() *models.Scene {
CreatedAt: r.CreatedAt.Timestamp, CreatedAt: r.CreatedAt.Timestamp,
UpdatedAt: r.UpdatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp,
ResumeTime: r.ResumeTime,
PlayDuration: r.PlayDuration,
PlayCount: r.PlayCount,
} }
if r.PrimaryFileFolderPath.Valid && r.PrimaryFileBasename.Valid { if r.PrimaryFileFolderPath.Valid && r.PrimaryFileBasename.Valid {
ret.Path = filepath.Join(r.PrimaryFileFolderPath.String, r.PrimaryFileBasename.String) ret.Path = filepath.Join(r.PrimaryFileFolderPath.String, r.PrimaryFileBasename.String)
} }
if r.LastPlayedAt.Valid {
ret.LastPlayedAt = &r.LastPlayedAt.Timestamp
}
return ret return ret
} }
@ -141,6 +163,10 @@ func (r *sceneRowRecord) fromPartial(o models.ScenePartial) {
r.setNullInt("studio_id", o.StudioID) r.setNullInt("studio_id", o.StudioID)
r.setSQLiteTimestamp("created_at", o.CreatedAt) r.setSQLiteTimestamp("created_at", o.CreatedAt)
r.setSQLiteTimestamp("updated_at", o.UpdatedAt) 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 { 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, intCriterionHandler(sceneFilter.OCounter, "scenes.o_counter", nil))
query.handleCriterion(ctx, boolCriterionHandler(sceneFilter.Organized, "scenes.organized", 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, resolutionCriterionHandler(sceneFilter.Resolution, "video_files.height", "video_files.width", qb.addVideoFilesTable))
query.handleCriterion(ctx, hasMarkersCriterionHandler(sceneFilter.HasMarkers)) 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, 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, sceneTagsCriterionHandler(qb, sceneFilter.Tags))
query.handleCriterion(ctx, sceneTagCountCriterionHandler(qb, sceneFilter.TagCount)) query.handleCriterion(ctx, sceneTagCountCriterionHandler(qb, sceneFilter.TagCount))
query.handleCriterion(ctx, scenePerformersCriterionHandler(qb, sceneFilter.Performers)) 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) { return func(ctx context.Context, f *filterBuilder) {
if durationFilter != nil { if durationFilter != nil {
if addJoinFn != nil { if addJoinFn != nil {
@ -1417,6 +1447,9 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF
addFileTable() addFileTable()
addFolderTable() addFolderTable()
query.sortAndPagination += " ORDER BY scenes.title COLLATE NATURAL_CS " + direction + ", folders.path " + direction + ", files.basename COLLATE NATURAL_CS " + direction 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: default:
query.sortAndPagination += getSort(sort, direction, "scenes") 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) { func (qb *SceneStore) GetCover(ctx context.Context, sceneID int) ([]byte, error) {
return qb.imageRepository().get(ctx, sceneID) 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) { func Test_sceneQueryBuilder_Create(t *testing.T) {
var ( var (
title = "title" title = "title"
code = "1337" code = "1337"
details = "details" details = "details"
director = "director" director = "director"
url = "url" url = "url"
rating = 60 rating = 60
ocounter = 5 ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) lastPlayedAt = time.Date(2002, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) resumeTime = 10.0
sceneIndex = 123 playCount = 3
sceneIndex2 = 234 playDuration = 34.0
endpoint1 = "endpoint1" createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
endpoint2 = "endpoint2" updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
stashID1 = "stashid1" sceneIndex = 123
stashID2 = "stashid2" sceneIndex2 = 234
endpoint1 = "endpoint1"
endpoint2 = "endpoint2"
stashID1 = "stashid1"
stashID2 = "stashid2"
date = models.NewDate("2003-02-01") date = models.NewDate("2003-02-01")
@ -136,6 +140,10 @@ func Test_sceneQueryBuilder_Create(t *testing.T) {
Endpoint: endpoint2, Endpoint: endpoint2,
}, },
}), }),
LastPlayedAt: &lastPlayedAt,
ResumeTime: float64(resumeTime),
PlayCount: playCount,
PlayDuration: playDuration,
}, },
false, false,
}, },
@ -180,6 +188,10 @@ func Test_sceneQueryBuilder_Create(t *testing.T) {
Endpoint: endpoint2, Endpoint: endpoint2,
}, },
}), }),
LastPlayedAt: &lastPlayedAt,
ResumeTime: resumeTime,
PlayCount: playCount,
PlayDuration: playDuration,
}, },
false, false,
}, },
@ -299,21 +311,25 @@ func makeSceneFileWithID(i int) *file.VideoFile {
func Test_sceneQueryBuilder_Update(t *testing.T) { func Test_sceneQueryBuilder_Update(t *testing.T) {
var ( var (
title = "title" title = "title"
code = "1337" code = "1337"
details = "details" details = "details"
director = "director" director = "director"
url = "url" url = "url"
rating = 60 rating = 60
ocounter = 5 ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) lastPlayedAt = time.Date(2002, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) resumeTime = 10.0
sceneIndex = 123 playCount = 3
sceneIndex2 = 234 playDuration = 34.0
endpoint1 = "endpoint1" createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
endpoint2 = "endpoint2" updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
stashID1 = "stashid1" sceneIndex = 123
stashID2 = "stashid2" sceneIndex2 = 234
endpoint1 = "endpoint1"
endpoint2 = "endpoint2"
stashID1 = "stashid1"
stashID2 = "stashid2"
date = models.NewDate("2003-02-01") date = models.NewDate("2003-02-01")
) )
@ -362,6 +378,10 @@ func Test_sceneQueryBuilder_Update(t *testing.T) {
Endpoint: endpoint2, Endpoint: endpoint2,
}, },
}), }),
LastPlayedAt: &lastPlayedAt,
ResumeTime: resumeTime,
PlayCount: playCount,
PlayDuration: playDuration,
}, },
false, false,
}, },
@ -507,21 +527,25 @@ func clearScenePartial() models.ScenePartial {
func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) { func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) {
var ( var (
title = "title" title = "title"
code = "1337" code = "1337"
details = "details" details = "details"
director = "director" director = "director"
url = "url" url = "url"
rating = 60 rating = 60
ocounter = 5 ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) lastPlayedAt = time.Date(2002, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) resumeTime = 10.0
sceneIndex = 123 playCount = 3
sceneIndex2 = 234 playDuration = 34.0
endpoint1 = "endpoint1" createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
endpoint2 = "endpoint2" updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
stashID1 = "stashid1" sceneIndex = 123
stashID2 = "stashid2" sceneIndex2 = 234
endpoint1 = "endpoint1"
endpoint2 = "endpoint2"
stashID1 = "stashid1"
stashID2 = "stashid2"
date = models.NewDate("2003-02-01") date = models.NewDate("2003-02-01")
) )
@ -587,6 +611,10 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) {
}, },
Mode: models.RelationshipUpdateModeSet, Mode: models.RelationshipUpdateModeSet,
}, },
LastPlayedAt: models.NewOptionalTime(lastPlayedAt),
ResumeTime: models.NewOptionalFloat64(resumeTime),
PlayCount: models.NewOptionalInt(playCount),
PlayDuration: models.NewOptionalFloat64(playDuration),
}, },
models.Scene{ models.Scene{
ID: sceneIDs[sceneIdxWithSpacedName], ID: sceneIDs[sceneIdxWithSpacedName],
@ -628,6 +656,10 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) {
Endpoint: endpoint2, Endpoint: endpoint2,
}, },
}), }),
LastPlayedAt: &lastPlayedAt,
ResumeTime: resumeTime,
PlayCount: playCount,
PlayDuration: playDuration,
}, },
false, false,
}, },
@ -2088,6 +2120,45 @@ func TestSceneQuery(t *testing.T) {
excludeIdxs []int excludeIdxs []int
wantErr bool 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", "stash id with endpoint",
nil, nil,
@ -3697,6 +3768,34 @@ func TestSceneQuerySorting(t *testing.T) {
-1, -1,
-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 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 Count
// TODO SizeCount // 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 { func makeScene(i int) *models.Scene {
title := getSceneTitle(i) title := getSceneTitle(i)
details := getSceneStringValue(i, "Details") details := getSceneStringValue(i, "Details")
@ -984,6 +1013,10 @@ func makeScene(i int) *models.Scene {
StashIDs: models.NewRelatedStashIDs([]models.StashID{ StashIDs: models.NewRelatedStashIDs([]models.StashID{
sceneStashID(i), 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 "./markers";
import "./vtt-thumbnails"; import "./vtt-thumbnails";
import "./big-buttons"; import "./big-buttons";
import "./track-activity";
import cx from "classnames"; import cx from "classnames";
import {
useSceneSaveActivity,
useSceneIncrementPlayCount,
} from "src/core/StashService";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { ScenePlayerScrubber } from "./ScenePlayerScrubber"; import { ScenePlayerScrubber } from "./ScenePlayerScrubber";
@ -28,6 +33,7 @@ import {
import { SceneInteractiveStatus } from "src/hooks/Interactive/status"; import { SceneInteractiveStatus } from "src/hooks/Interactive/status";
import { languageMap } from "src/utils/caption"; import { languageMap } from "src/utils/caption";
import { VIDEO_PLAYER_ID } from "./util"; import { VIDEO_PLAYER_ID } from "./util";
import { IUIConfig } from "src/core/config";
function handleHotkeys(player: VideoJsPlayer, event: videojs.KeyboardEvent) { function handleHotkeys(player: VideoJsPlayer, event: videojs.KeyboardEvent) {
function seekPercent(percent: number) { function seekPercent(percent: number) {
@ -156,13 +162,17 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
onPrevious, onPrevious,
}) => { }) => {
const { configuration } = useContext(ConfigurationContext); 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 videoRef = useRef<HTMLVideoElement>(null);
const playerRef = useRef<VideoJsPlayer>(); const playerRef = useRef<VideoJsPlayer>();
const sceneId = useRef<string>(); const sceneId = useRef<string>();
const [sceneSaveActivity] = useSceneSaveActivity();
const [sceneIncrementPlayCount] = useSceneIncrementPlayCount();
const [time, setTime] = useState(0); const [time, setTime] = useState(0);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [sessionInitialised, setSessionInitialised] = useState(false); // tracks play session. This is reset whenever ScenePlayer page is exited
const { const {
interactive: interactiveClient, interactive: interactiveClient,
@ -180,12 +190,15 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
const auto = useRef(false); const auto = useRef(false);
const interactiveReady = useRef(false); const interactiveReady = useRef(false);
const minimumPlayPercent = uiConfig?.minimumPlayPercent ?? 0;
const trackActivity = uiConfig?.trackActivity ?? false;
const file = useMemo( const file = useMemo(
() => ((scene?.files.length ?? 0) > 0 ? scene?.files[0] : undefined), () => ((scene?.files.length ?? 0) > 0 ? scene?.files[0] : undefined),
[scene] [scene]
); );
const maxLoopDuration = config?.maximumLoopDuration ?? 0; const maxLoopDuration = interfaceConfig?.maximumLoopDuration ?? 0;
const looping = useMemo( const looping = useMemo(
() => () =>
!!file?.duration && !!file?.duration &&
@ -256,6 +269,7 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
back: 10, back: 10,
}, },
skipButtons: {}, skipButtons: {},
trackActivity: {},
}, },
}; };
@ -341,6 +355,7 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
player.off("fullscreenchange", fullscreenchange); player.off("fullscreenchange", fullscreenchange);
}; };
}, []); }, []);
useEffect(() => { useEffect(() => {
function onplay(this: VideoJsPlayer) { function onplay(this: VideoJsPlayer) {
this.persistVolume().enabled = true; 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 // don't re-initialise the player unless the scene has changed
if (!scene || !file || scene.id === sceneId.current) return; 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; sceneId.current = scene.id;
setReady(false); setReady(false);
@ -398,6 +421,8 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
interactiveClient.pause(); interactiveClient.pause();
interactiveReady.current = false; interactiveReady.current = false;
const alwaysStartFromBeginning =
uiConfig?.alwaysStartFromBeginning ?? false;
const isLandscape = file.height && file.width && file.width > file.height; const isLandscape = file.height && file.width && file.width > file.height;
if (isLandscape) { if (isLandscape) {
@ -489,10 +514,21 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
} }
auto.current = auto.current =
autoplay || (config?.autostartVideo ?? false) || _initialTimestamp > 0; autoplay ||
(interfaceConfig?.autostartVideo ?? false) ||
_initialTimestamp > 0;
initialTimestamp.current = _initialTimestamp; var startPositition = _initialTimestamp;
setTime(_initialTimestamp); if (
!(alwaysStartFromBeginning || sessionInitialised) &&
file.duration > scene.resume_time!
) {
startPositition = scene.resume_time!;
}
initialTimestamp.current = startPositition;
setTime(startPositition);
setSessionInitialised(true);
player.load(); player.load();
player.focus(); player.focus();
@ -510,12 +546,54 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
}, [ }, [
file, file,
scene, scene,
trackActivity,
interactiveClient, interactiveClient,
sessionInitialised,
autoplay, autoplay,
config?.autostartVideo, interfaceConfig?.autostartVideo,
uiConfig?.alwaysStartFromBeginning,
_initialTimestamp, _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(() => { useEffect(() => {
const player = playerRef.current; const player = playerRef.current;
if (!player) return; 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)} title={objectTitle(props.scene)}
linkClassName="scene-card-link" linkClassName="scene-card-link"
thumbnailSectionClassName="video-section" thumbnailSectionClassName="video-section"
resumeTime={props.scene.resume_time ?? undefined}
duration={file?.duration ?? undefined}
interactiveHeatmap={ interactiveHeatmap={
props.scene.interactive_speed props.scene.interactive_speed
? props.scene.paths.interactive_heatmap ?? undefined ? props.scene.paths.interactive_heatmap ?? undefined

View file

@ -309,7 +309,23 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
value={props.scene.url} value={props.scene.url}
truncate truncate
/> />
<URLField
id="media_info.downloaded_from"
url={props.scene.url}
value={props.scene.url}
truncate
/>
{renderStashIDs()} {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> </dl>
{filesPanel} {filesPanel}

View file

@ -9,7 +9,7 @@ import {
SceneSelect, SceneSelect,
StringListSelect, StringListSelect,
} from "src/components/Shared"; } 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 { mutateSceneMerge, queryFindScenesByID } from "src/core/StashService";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
@ -72,6 +72,13 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
const [oCounter, setOCounter] = useState( const [oCounter, setOCounter] = useState(
new ScrapeResult<number>(dest.o_counter) 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>>( const [studio, setStudio] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.studio?.id) 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( setStashIDs(
new ScrapeResult( new ScrapeResult(
dest.stash_ids, dest.stash_ids,
@ -352,7 +373,51 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
className="bg-secondary text-white border-secondary" 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 <ScrapeDialogRow
title={intl.formatMessage({ id: "galleries" })} title={intl.formatMessage({ id: "galleries" })}
@ -434,6 +499,8 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
date: date.getNewValue(), date: date.getNewValue(),
rating100: rating.getNewValue(), rating100: rating.getNewValue(),
o_counter: oCounter.getNewValue(), o_counter: oCounter.getNewValue(),
play_count: playCount.getNewValue(),
play_duration: playDuration.getNewValue(),
gallery_ids: galleries.getNewValue(), gallery_ids: galleries.getNewValue(),
studio_id: studio.getNewValue(), studio_id: studio.getNewValue(),
performer_ids: performers.getNewValue(), performer_ids: performers.getNewValue(),

View file

@ -1,7 +1,11 @@
import React from "react"; import React from "react";
import { Button, Form } from "react-bootstrap"; import { Button, Form } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl"; 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 { CheckboxGroup } from "./CheckboxGroup";
import { SettingSection } from "../SettingSection"; import { SettingSection } from "../SettingSection";
import { import {
@ -243,6 +247,41 @@ export const SettingsInterfacePanel: React.FC = () => {
checked={iface.showScrubber ?? undefined} checked={iface.showScrubber ?? undefined}
onChange={(v) => saveInterface({ showScrubber: v })} 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 <BooleanSetting
id="auto-start-video" id="auto-start-video"
headingID="config.ui.scene_player.options.auto_start_video" headingID="config.ui.scene_player.options.auto_start_video"

View file

@ -18,6 +18,8 @@ interface ICardProps {
selecting?: boolean; selecting?: boolean;
selected?: boolean; selected?: boolean;
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
resumeTime?: number;
duration?: number;
interactiveHeatmap?: string; 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 ( return (
<Card <Card
className={cx(props.className, "grid-card")} className={cx(props.className, "grid-card")}
@ -110,6 +128,7 @@ export const GridCard: React.FC<ICardProps> = (props: ICardProps) => {
{props.image} {props.image}
</Link> </Link>
{props.overlays} {props.overlays}
{maybeRenderProgressBar()}
</div> </div>
{maybeRenderInteractiveHeatmap()} {maybeRenderInteractiveHeatmap()}
<div className="card-section"> <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 { CollapseButton } from "./CollapseButton";
export { DetailsEditNavbar } from "./DetailsEditNavbar"; export { DetailsEditNavbar } from "./DetailsEditNavbar";
export { DurationInput } from "./DurationInput"; export { DurationInput } from "./DurationInput";
export { PercentInput } from "./PercentInput";
export { TagLink } from "./TagLink"; export { TagLink } from "./TagLink";
export { HoverPopover } from "./HoverPopover"; export { HoverPopover } from "./HoverPopover";
export { default as LoadingIndicator } from "./LoadingIndicator"; export { default as LoadingIndicator } from "./LoadingIndicator";

View file

@ -59,12 +59,15 @@
} }
} }
.duration-input { .duration-input,
.duration-control { .percent-input {
.duration-control,
.percent-control {
min-width: 3rem; min-width: 3rem;
} }
.duration-button { .duration-button,
.percent-button {
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
border-top-left-radius: 0; border-top-left-radius: 0;
line-height: 10px; line-height: 10px;
@ -167,6 +170,20 @@ button.collapse-button.btn-primary:not(:disabled):not(.disabled):active {
text-decoration: none; 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 { .card-check {
left: 0.5rem; left: 0.5rem;
margin-top: -12px; margin-top: -12px;

View file

@ -887,6 +887,16 @@ export const useTagsDestroy = (input: GQL.TagsDestroyMutationVariables) =>
update: deleteCache(tagMutationImpactedQueries), update: deleteCache(tagMutationImpactedQueries),
}); });
export const useSceneSaveActivity = () =>
GQL.useSceneSaveActivityMutation({
update: deleteCache([GQL.FindScenesDocument]),
});
export const useSceneIncrementPlayCount = () =>
GQL.useSceneIncrementPlayCountMutation({
update: deleteCache([GQL.FindScenesDocument]),
});
export const savedFilterMutationImpactedQueries = [ export const savedFilterMutationImpactedQueries = [
GQL.FindSavedFiltersDocument, GQL.FindSavedFiltersDocument,
]; ];

View file

@ -28,13 +28,24 @@ export type FrontPageContent = ISavedFilterRow | ICustomFilter;
export interface IUIConfig { export interface IUIConfig {
frontPageContent?: FrontPageContent[]; frontPageContent?: FrontPageContent[];
lastNoteSeen?: number;
showChildTagContent?: boolean; showChildTagContent?: boolean;
showChildStudioContent?: boolean; showChildStudioContent?: boolean;
showTagCardOnHover?: boolean; showTagCardOnHover?: boolean;
abbreviateCounters?: boolean; abbreviateCounters?: boolean;
ratingSystemOptions?: RatingSystemOptions; 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( function recentlyReleased(

View file

@ -1,4 +1,6 @@
### ✨ New Features ### ✨ 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 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 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)) * 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. 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 ## 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. 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", "description": "Show or hide different types of content on the navigation bar",
"heading": "Menu Items" "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": { "show_tag_card_on_hover": {
"description": "Show tag card when hovering tag badges", "description": "Show tag card when hovering tag badges",
"heading": "Tag card tooltips" "heading": "Tag card tooltips"
@ -561,6 +565,7 @@
"scene_player": { "scene_player": {
"heading": "Scene Player", "heading": "Scene Player",
"options": { "options": {
"always_start_from_beginning": "Always start video from beginning",
"auto_start_video": "Auto-start video", "auto_start_video": "Auto-start video",
"auto_start_video_on_play_selected": { "auto_start_video_on_play_selected": {
"description": "Auto-start scene videos when playing from queue, or playing selected or random from Scenes page", "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", "description": "Play next scene in queue when video finishes",
"heading": "Continue playlist by default" "heading": "Continue playlist by default"
}, },
"show_scrubber": "Show Scrubber" "show_scrubber": "Show Scrubber",
"track_activity": "Track Activity"
} }
}, },
"scene_wall": { "scene_wall": {
@ -610,6 +616,7 @@
} }
}, },
"configuration": "Configuration", "configuration": "Configuration",
"resume_time": "Resume Time",
"countables": { "countables": {
"files": "{count, plural, one {File} other {Files}}", "files": "{count, plural, one {File} other {Files}}",
"galleries": "{count, plural, one {Gallery} other {Galleries}}", "galleries": "{count, plural, one {Gallery} other {Galleries}}",
@ -856,6 +863,7 @@
"interactive": "Interactive", "interactive": "Interactive",
"interactive_speed": "Interactive speed", "interactive_speed": "Interactive speed",
"isMissing": "Is Missing", "isMissing": "Is Missing",
"last_played_at": "Last Played At",
"library": "Library", "library": "Library",
"loading": { "loading": {
"generic": "Loading…" "generic": "Loading…"
@ -873,6 +881,8 @@
"age": "{age} {years_old}", "age": "{age} {years_old}",
"age_context": "{age} {years_old} in this scene" "age_context": "{age} {years_old} in this scene"
}, },
"play_count": "Play Count",
"play_duration": "Play Duration",
"phash": "PHash", "phash": "PHash",
"stream": "Stream", "stream": "Stream",
"video_codec": "Video Codec" "video_codec": "Video Codec"
@ -1107,6 +1117,8 @@
"url": "URL", "url": "URL",
"videos": "Videos", "videos": "Videos",
"view_all": "View All", "view_all": "View All",
"play_count": "Play Count",
"play_duration": "Play Duration",
"weight": "Weight", "weight": "Weight",
"weight_kg": "Weight (kg)", "weight_kg": "Weight (kg)",
"years_old": "years old", "years_old": "years old",

View file

@ -87,6 +87,7 @@ export function makeCriteria(
case "performer_age": case "performer_age":
case "tag_count": case "tag_count":
case "file_count": case "file_count":
case "play_count":
return new NumberCriterion( return new NumberCriterion(
new MandatoryNumberCriterionOption(type, type) new MandatoryNumberCriterionOption(type, type)
); );
@ -102,7 +103,9 @@ export function makeCriteria(
return new ResolutionCriterion(); return new ResolutionCriterion();
case "average_resolution": case "average_resolution":
return new AverageResolutionCriterion(); return new AverageResolutionCriterion();
case "resume_time":
case "duration": case "duration":
case "play_duration":
return new DurationCriterion(new NumberCriterionOption(type, type)); return new DurationCriterion(new NumberCriterionOption(type, type));
case "favorite": case "favorite":
return new FavoriteCriterion(); return new FavoriteCriterion();

View file

@ -38,6 +38,10 @@ const sortByOptions = [
"duration", "duration",
"framerate", "framerate",
"bitrate", "bitrate",
"last_played_at",
"resume_time",
"play_duration",
"play_count",
"movie_scene_number", "movie_scene_number",
"interactive", "interactive",
"interactive_speed", "interactive_speed",
@ -71,6 +75,9 @@ const criterionOptions = [
createMandatoryNumberCriterionOption("o_counter"), createMandatoryNumberCriterionOption("o_counter"),
ResolutionCriterionOption, ResolutionCriterionOption,
createMandatoryNumberCriterionOption("duration"), createMandatoryNumberCriterionOption("duration"),
createMandatoryNumberCriterionOption("resume_time"),
createMandatoryNumberCriterionOption("play_duration"),
createMandatoryNumberCriterionOption("play_count"),
HasMarkersCriterionOption, HasMarkersCriterionOption,
SceneIsMissingCriterionOption, SceneIsMissingCriterionOption,
TagsCriterionOption, TagsCriterionOption,

View file

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

View file

@ -6,6 +6,7 @@ export { default as TextUtils } from "./text";
export { default as EditableTextUtils } from "./editabletext"; export { default as EditableTextUtils } from "./editabletext";
export { default as FormUtils } from "./form"; export { default as FormUtils } from "./form";
export { default as DurationUtils } from "./duration"; export { default as DurationUtils } from "./duration";
export { default as PercentUtils } from "./percent";
export { default as SessionUtils } from "./session"; export { default as SessionUtils } from "./session";
export { default as flattenMessages } from "./flattenMessages"; export { default as flattenMessages } from "./flattenMessages";
export * from "./country"; 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,
};