mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
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:
parent
f39fa416a9
commit
0664c5b974
42 changed files with 1239 additions and 104 deletions
|
|
@ -11,6 +11,9 @@ fragment SlimSceneData on Scene {
|
|||
organized
|
||||
interactive
|
||||
interactive_speed
|
||||
resume_time
|
||||
play_duration
|
||||
play_count
|
||||
|
||||
files {
|
||||
...VideoFileData
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ fragment SceneData on Scene {
|
|||
}
|
||||
created_at
|
||||
updated_at
|
||||
resume_time
|
||||
last_played_at
|
||||
play_duration
|
||||
play_count
|
||||
|
||||
files {
|
||||
...VideoFileData
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ type Scene struct {
|
|||
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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
@ -148,6 +153,10 @@ type ScenePartial struct {
|
|||
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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
4
pkg/sqlite/migrations/41_scene_activity.up.sql
Normal file
4
pkg/sqlite/migrations/41_scene_activity.up.sql
Normal 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;
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
|
|
@ -66,6 +67,10 @@ type sceneRow struct {
|
|||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,10 @@ func Test_sceneQueryBuilder_Create(t *testing.T) {
|
|||
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
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
@ -306,6 +318,10 @@ func Test_sceneQueryBuilder_Update(t *testing.T) {
|
|||
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
|
||||
|
|
@ -362,6 +378,10 @@ func Test_sceneQueryBuilder_Update(t *testing.T) {
|
|||
Endpoint: endpoint2,
|
||||
},
|
||||
}),
|
||||
LastPlayedAt: &lastPlayedAt,
|
||||
ResumeTime: resumeTime,
|
||||
PlayCount: playCount,
|
||||
PlayDuration: playDuration,
|
||||
},
|
||||
false,
|
||||
},
|
||||
|
|
@ -514,6 +534,10 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) {
|
|||
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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
131
ui/v2.5/src/components/ScenePlayer/track-activity.ts
Normal file
131
ui/v2.5/src/components/ScenePlayer/track-activity.ts
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
140
ui/v2.5/src/components/Shared/PercentInput.tsx
Normal file
140
ui/v2.5/src/components/Shared/PercentInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -146,6 +146,9 @@ export type CriterionType =
|
|||
| "interactive"
|
||||
| "interactive_speed"
|
||||
| "captions"
|
||||
| "resume_time"
|
||||
| "play_count"
|
||||
| "play_duration"
|
||||
| "name"
|
||||
| "details"
|
||||
| "title"
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
17
ui/v2.5/src/utils/percent.ts
Normal file
17
ui/v2.5/src/utils/percent.ts
Normal 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,
|
||||
};
|
||||
Loading…
Reference in a new issue