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
|
organized
|
||||||
interactive
|
interactive
|
||||||
interactive_speed
|
interactive_speed
|
||||||
|
resume_time
|
||||||
|
play_duration
|
||||||
|
play_count
|
||||||
|
|
||||||
files {
|
files {
|
||||||
...VideoFileData
|
...VideoFileData
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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!
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,10 @@ type Scene struct {
|
||||||
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"`
|
||||||
|
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"`
|
StashIDs []models.StashID `json:"stash_ids,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
@ -148,6 +153,10 @@ type ScenePartial struct {
|
||||||
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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))
|
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))
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -66,6 +67,10 @@ type sceneRow struct {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,10 @@ func Test_sceneQueryBuilder_Create(t *testing.T) {
|
||||||
url = "url"
|
url = "url"
|
||||||
rating = 60
|
rating = 60
|
||||||
ocounter = 5
|
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)
|
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
updatedAt = 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
|
sceneIndex = 123
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
@ -306,6 +318,10 @@ func Test_sceneQueryBuilder_Update(t *testing.T) {
|
||||||
url = "url"
|
url = "url"
|
||||||
rating = 60
|
rating = 60
|
||||||
ocounter = 5
|
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)
|
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
updatedAt = 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
|
sceneIndex = 123
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
@ -514,6 +534,10 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) {
|
||||||
url = "url"
|
url = "url"
|
||||||
rating = 60
|
rating = 60
|
||||||
ocounter = 5
|
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)
|
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
updatedAt = 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
|
sceneIndex = 123
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
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)}
|
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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
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 { 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";
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
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