diff --git a/graphql/schema/types/scene-marker.graphql b/graphql/schema/types/scene-marker.graphql index 8b995c9d5..6d1441213 100644 --- a/graphql/schema/types/scene-marker.graphql +++ b/graphql/schema/types/scene-marker.graphql @@ -2,7 +2,10 @@ type SceneMarker { id: ID! scene: Scene! title: String! + "The required start time of the marker (in seconds). Supports decimals." seconds: Float! + "The optional end time of the marker (in seconds). Supports decimals." + end_seconds: Float primary_tag: Tag! tags: [Tag!]! created_at: Time! @@ -18,7 +21,10 @@ type SceneMarker { input SceneMarkerCreateInput { title: String! + "The required start time of the marker (in seconds). Supports decimals." seconds: Float! + "The optional end time of the marker (in seconds). Supports decimals." + end_seconds: Float scene_id: ID! primary_tag_id: ID! tag_ids: [ID!] @@ -27,7 +33,10 @@ input SceneMarkerCreateInput { input SceneMarkerUpdateInput { id: ID! title: String + "The start time of the marker (in seconds). Supports decimals." seconds: Float + "The end time of the marker (in seconds). Supports decimals." + end_seconds: Float scene_id: ID primary_tag_id: ID tag_ids: [ID!] diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index b0c6ac8b5..101cc8ba5 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -655,6 +655,13 @@ func (r *mutationResolver) SceneMarkerCreate(ctx context.Context, input SceneMar newMarker.PrimaryTagID = primaryTagID newMarker.SceneID = sceneID + if input.EndSeconds != nil { + if err := validateSceneMarkerEndSeconds(newMarker.Seconds, *input.EndSeconds); err != nil { + return nil, err + } + newMarker.EndSeconds = input.EndSeconds + } + tagIDs, err := stringslice.StringSliceToIntSlice(input.TagIds) if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) @@ -680,6 +687,13 @@ func (r *mutationResolver) SceneMarkerCreate(ctx context.Context, input SceneMar return r.getSceneMarker(ctx, newMarker.ID) } +func validateSceneMarkerEndSeconds(seconds, endSeconds float64) error { + if endSeconds < seconds { + return fmt.Errorf("end_seconds (%f) must be greater than or equal to seconds (%f)", endSeconds, seconds) + } + return nil +} + func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMarkerUpdateInput) (*models.SceneMarker, error) { markerID, err := strconv.Atoi(input.ID) if err != nil { @@ -695,6 +709,7 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar updatedMarker.Title = translator.optionalString(input.Title, "title") updatedMarker.Seconds = translator.optionalFloat64(input.Seconds, "seconds") + updatedMarker.EndSeconds = translator.optionalFloat64(input.EndSeconds, "end_seconds") updatedMarker.SceneID, err = translator.optionalIntFromString(input.SceneID, "scene_id") if err != nil { return nil, fmt.Errorf("converting scene id: %w", err) @@ -735,6 +750,26 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar return fmt.Errorf("scene marker with id %d not found", markerID) } + // Validate end_seconds + shouldValidateEndSeconds := (updatedMarker.Seconds.Set || updatedMarker.EndSeconds.Set) && !updatedMarker.EndSeconds.Null + if shouldValidateEndSeconds { + seconds := existingMarker.Seconds + if updatedMarker.Seconds.Set { + seconds = updatedMarker.Seconds.Value + } + + endSeconds := existingMarker.EndSeconds + if updatedMarker.EndSeconds.Set { + endSeconds = &updatedMarker.EndSeconds.Value + } + + if endSeconds != nil { + if err := validateSceneMarkerEndSeconds(seconds, *endSeconds); err != nil { + return err + } + } + } + newMarker, err := qb.UpdatePartial(ctx, markerID, updatedMarker) if err != nil { return err @@ -749,7 +784,7 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar } // remove the marker preview if the scene changed or if the timestamp was changed - if existingMarker.SceneID != newMarker.SceneID || existingMarker.Seconds != newMarker.Seconds { + if existingMarker.SceneID != newMarker.SceneID || existingMarker.Seconds != newMarker.Seconds || existingMarker.EndSeconds != newMarker.EndSeconds { seconds := int(existingMarker.Seconds) if err := fileDeleter.MarkMarkerFiles(existingScene, seconds); err != nil { return err diff --git a/pkg/models/model_scene_marker.go b/pkg/models/model_scene_marker.go index df77afecd..778603315 100644 --- a/pkg/models/model_scene_marker.go +++ b/pkg/models/model_scene_marker.go @@ -8,6 +8,7 @@ type SceneMarker struct { ID int `json:"id"` Title string `json:"title"` Seconds float64 `json:"seconds"` + EndSeconds *float64 `json:"end_seconds"` PrimaryTagID int `json:"primary_tag_id"` SceneID int `json:"scene_id"` CreatedAt time.Time `json:"created_at"` @@ -27,6 +28,7 @@ func NewSceneMarker() SceneMarker { type SceneMarkerPartial struct { Title OptionalString Seconds OptionalFloat64 + EndSeconds OptionalFloat64 PrimaryTagID OptionalInt SceneID OptionalInt CreatedAt OptionalTime diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 965c44ef9..d2c0a8191 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 69 +var appSchemaVersion uint = 70 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/70_markers_end.up.sql b/pkg/sqlite/migrations/70_markers_end.up.sql new file mode 100644 index 000000000..05469953a --- /dev/null +++ b/pkg/sqlite/migrations/70_markers_end.up.sql @@ -0,0 +1 @@ +ALTER TABLE scene_markers ADD COLUMN end_seconds FLOAT; \ No newline at end of file diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index 4af4d6b4b..8b2306eab 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -10,6 +10,7 @@ import ( "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" + "gopkg.in/guregu/null.v4" "github.com/stashapp/stash/pkg/models" ) @@ -24,19 +25,23 @@ GROUP BY scene_markers.id ` type sceneMarkerRow struct { - ID int `db:"id" goqu:"skipinsert"` - Title string `db:"title"` // TODO: make db schema (and gql schema) nullable - Seconds float64 `db:"seconds"` - PrimaryTagID int `db:"primary_tag_id"` - SceneID int `db:"scene_id"` - CreatedAt Timestamp `db:"created_at"` - UpdatedAt Timestamp `db:"updated_at"` + ID int `db:"id" goqu:"skipinsert"` + Title string `db:"title"` // TODO: make db schema (and gql schema) nullable + Seconds float64 `db:"seconds"` + PrimaryTagID int `db:"primary_tag_id"` + SceneID int `db:"scene_id"` + CreatedAt Timestamp `db:"created_at"` + UpdatedAt Timestamp `db:"updated_at"` + EndSeconds null.Float `db:"end_seconds"` } func (r *sceneMarkerRow) fromSceneMarker(o models.SceneMarker) { r.ID = o.ID r.Title = o.Title r.Seconds = o.Seconds + if o.EndSeconds != nil { + r.EndSeconds = null.FloatFrom(*o.EndSeconds) + } r.PrimaryTagID = o.PrimaryTagID r.SceneID = o.SceneID r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} @@ -48,6 +53,7 @@ func (r *sceneMarkerRow) resolve() *models.SceneMarker { ID: r.ID, Title: r.Title, Seconds: r.Seconds, + EndSeconds: r.EndSeconds.Ptr(), PrimaryTagID: r.PrimaryTagID, SceneID: r.SceneID, CreatedAt: r.CreatedAt.Timestamp, @@ -69,6 +75,7 @@ func (r *sceneMarkerRowRecord) fromPartial(o models.SceneMarkerPartial) { r.set("title", o.Title.Value) } r.setFloat64("seconds", o.Seconds) + r.setNullFloat64("end_seconds", o.EndSeconds) r.setInt("primary_tag_id", o.PrimaryTagID) r.setInt("scene_id", o.SceneID) r.setTimestamp("created_at", o.CreatedAt) diff --git a/ui/v2.5/graphql/data/scene-marker.graphql b/ui/v2.5/graphql/data/scene-marker.graphql index 9fd0c7d3d..e2ebfc4df 100644 --- a/ui/v2.5/graphql/data/scene-marker.graphql +++ b/ui/v2.5/graphql/data/scene-marker.graphql @@ -2,6 +2,7 @@ fragment SceneMarkerData on SceneMarker { id title seconds + end_seconds stream preview screenshot diff --git a/ui/v2.5/graphql/mutations/scene-marker.graphql b/ui/v2.5/graphql/mutations/scene-marker.graphql index fb4c97444..3b1de35c7 100644 --- a/ui/v2.5/graphql/mutations/scene-marker.graphql +++ b/ui/v2.5/graphql/mutations/scene-marker.graphql @@ -1,6 +1,7 @@ mutation SceneMarkerCreate( $title: String! $seconds: Float! + $end_seconds: Float $scene_id: ID! $primary_tag_id: ID! $tag_ids: [ID!] = [] @@ -9,6 +10,7 @@ mutation SceneMarkerCreate( input: { title: $title seconds: $seconds + end_seconds: $end_seconds scene_id: $scene_id primary_tag_id: $primary_tag_id tag_ids: $tag_ids @@ -22,6 +24,7 @@ mutation SceneMarkerUpdate( $id: ID! $title: String! $seconds: Float! + $end_seconds: Float $scene_id: ID! $primary_tag_id: ID! $tag_ids: [ID!] = [] @@ -31,6 +34,7 @@ mutation SceneMarkerUpdate( id: $id title: $title seconds: $seconds + end_seconds: $end_seconds scene_id: $scene_id primary_tag_id: $primary_tag_id tag_ids: $tag_ids diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx index 9694ca9ed..11c805ec6 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx @@ -52,7 +52,12 @@ export const PrimaryTags: React.FC = ({ -
{TextUtils.secondsToTimestamp(marker.seconds)}
+
+ {TextUtils.formatTimestampRange( + marker.seconds, + marker.end_seconds ?? undefined + )} +
{tags}
); diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx index 7452bdd19..03fcb3b48 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx @@ -44,6 +44,18 @@ export const SceneMarkerForm: React.FC = ({ const schema = yup.object({ title: yup.string().ensure(), seconds: yup.number().min(0).required(), + end_seconds: yup + .number() + .min(0) + .nullable() + .defined() + .test( + "is-greater-than-seconds", + intl.formatMessage({ id: "end_time_before_start_time" }), + function (value) { + return value === null || value >= this.parent.seconds; + } + ), primary_tag_id: yup.string().required(), tag_ids: yup.array(yup.string().required()).defined(), }); @@ -53,6 +65,7 @@ export const SceneMarkerForm: React.FC = ({ () => ({ title: marker?.title ?? "", seconds: marker?.seconds ?? Math.round(getPlayerPosition() ?? 0), + end_seconds: marker?.end_seconds ?? null, primary_tag_id: marker?.primary_tag.id ?? "", tag_ids: marker?.tags.map((tag) => tag.id) ?? [], }), @@ -103,6 +116,8 @@ export const SceneMarkerForm: React.FC = ({ variables: { scene_id: sceneID, ...input, + // undefined means setting to null, not omitting the field + end_seconds: input.end_seconds ?? null, }, }); } else { @@ -111,6 +126,8 @@ export const SceneMarkerForm: React.FC = ({ id: marker.id, scene_id: sceneID, ...input, + // undefined means setting to null, not omitting the field + end_seconds: input.end_seconds ?? null, }, }); } @@ -205,6 +222,34 @@ export const SceneMarkerForm: React.FC = ({ return renderField("seconds", title, control); } + function renderEndTimeField() { + const { error } = formik.getFieldMeta("end_seconds"); + + const title = intl.formatMessage({ id: "time_end" }); + const control = ( + <> + formik.setFieldValue("end_seconds", v ?? null)} + onReset={() => + formik.setFieldValue( + "end_seconds", + Math.round(getPlayerPosition() ?? 0) + ) + } + error={error} + /> + {formik.touched.end_seconds && formik.errors.end_seconds && ( + + {formik.errors.end_seconds} + + )} + + ); + + return renderField("end_seconds", title, control); + } + function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); const control = ( @@ -225,6 +270,7 @@ export const SceneMarkerForm: React.FC = ({ {renderTitleField()} {renderPrimaryTagField()} {renderTimeField()} + {renderEndTimeField()} {renderTagsField()}
diff --git a/ui/v2.5/src/components/Wall/WallItem.tsx b/ui/v2.5/src/components/Wall/WallItem.tsx index 427c060cc..5811b7543 100644 --- a/ui/v2.5/src/components/Wall/WallItem.tsx +++ b/ui/v2.5/src/components/Wall/WallItem.tsx @@ -183,7 +183,10 @@ export const WallItem = ({ case "sceneMarker": const sceneMarker = data as GQL.SceneMarkerDataFragment; const newTitle = markerTitle(sceneMarker); - const seconds = TextUtils.secondsToTimestamp(sceneMarker.seconds); + const seconds = TextUtils.formatTimestampRange( + sceneMarker.seconds, + sceneMarker.end_seconds ?? undefined + ); if (newTitle) { return `${newTitle} - ${seconds}`; } else { diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 6be0a6542..edad1c8e7 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1457,6 +1457,7 @@ "tags": "Tags", "tattoos": "Tattoos", "time": "Time", + "time_end": "End Time", "title": "Title", "toast": { "added_entity": "Added {count, plural, one {{singularEntity}} other {{pluralEntity}}}", @@ -1488,6 +1489,7 @@ "validation": { "blank": "${path} must not be blank", "date_invalid_form": "${path} must be in YYYY-MM-DD form", + "end_time_before_start_time": "End time must be greater than or equal to start time", "required": "${path} is a required field", "unique": "${path} must be unique" }, diff --git a/ui/v2.5/src/utils/text.ts b/ui/v2.5/src/utils/text.ts index 627822f21..da7f7e024 100644 --- a/ui/v2.5/src/utils/text.ts +++ b/ui/v2.5/src/utils/text.ts @@ -184,6 +184,13 @@ const secondsToTimestamp = (seconds: number) => { } }; +const formatTimestampRange = (start: number, end: number | undefined) => { + if (end === undefined) { + return secondsToTimestamp(start); + } + return `${secondsToTimestamp(start)}-${secondsToTimestamp(end)}`; +}; + const timestampToSeconds = (v: string | null | undefined) => { if (!v) { return null; @@ -470,6 +477,7 @@ const TextUtils = { formatFileSizeUnit, fileSizeFractionalDigits, secondsToTimestamp, + formatTimestampRange, timestampToSeconds, fileNameFromPath, stringToDate,