diff --git a/graphql/schema/types/scene-marker.graphql b/graphql/schema/types/scene-marker.graphql index 8b995c9d507..a8ee88d4ec9 100644 --- a/graphql/schema/types/scene-marker.graphql +++ b/graphql/schema/types/scene-marker.graphql @@ -3,6 +3,7 @@ type SceneMarker { scene: Scene! title: String! seconds: Float! + end_seconds: Float! primary_tag: Tag! tags: [Tag!]! created_at: Time! @@ -19,6 +20,7 @@ type SceneMarker { input SceneMarkerCreateInput { title: String! seconds: Float! + end_seconds: Float scene_id: ID! primary_tag_id: ID! tag_ids: [ID!] @@ -28,6 +30,7 @@ input SceneMarkerUpdateInput { id: ID! title: String seconds: Float + 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 ca99dafc150..84aca7175b0 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -655,6 +655,16 @@ func (r *mutationResolver) SceneMarkerCreate(ctx context.Context, input SceneMar newMarker.PrimaryTagID = primaryTagID newMarker.SceneID = sceneID + if input.EndSeconds != nil { + newMarker.EndSeconds = *input.EndSeconds + // Validate that end_seconds is not less than seconds when it's not -1 + if newMarker.EndSeconds != -1 && newMarker.EndSeconds < newMarker.Seconds { + return nil, fmt.Errorf("end_seconds (%f) must be greater than or equal to seconds (%f)", newMarker.EndSeconds, newMarker.Seconds) + } + } else { + newMarker.EndSeconds = -1 + } + tagIDs, err := stringslice.StringSliceToIntSlice(input.TagIds) if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) @@ -695,6 +705,9 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar updatedMarker.Title = translator.optionalString(input.Title, "title") updatedMarker.Seconds = translator.optionalFloat64(input.Seconds, "seconds") + if input.EndSeconds != nil { + 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 +748,19 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar return fmt.Errorf("scene marker with id %d not found", markerID) } + // Validate end_seconds + newSeconds := existingMarker.Seconds + if updatedMarker.Seconds.Set { + newSeconds = updatedMarker.Seconds.Value + } + newEndSeconds := existingMarker.EndSeconds + if updatedMarker.EndSeconds.Set { + newEndSeconds = updatedMarker.EndSeconds.Value + } + if newEndSeconds != -1 && newEndSeconds < newSeconds { + return fmt.Errorf("end_seconds (%f) must be greater than or equal to seconds (%f)", newEndSeconds, newSeconds) + } + newMarker, err := qb.UpdatePartial(ctx, markerID, updatedMarker) if err != nil { return err @@ -749,7 +775,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 df77afecd77..b5951256072 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 eed335f0973..0510d7baf26 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 67 +var appSchemaVersion uint = 68 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/68_markers_end.up.sql b/pkg/sqlite/migrations/68_markers_end.up.sql new file mode 100644 index 00000000000..87745e54ed7 --- /dev/null +++ b/pkg/sqlite/migrations/68_markers_end.up.sql @@ -0,0 +1 @@ +ALTER TABLE scene_markers ADD COLUMN end_seconds FLOAT NOT NULL DEFAULT -1; \ No newline at end of file diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index 87a849d2084..8195526b795 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -31,12 +31,14 @@ type sceneMarkerRow struct { SceneID int `db:"scene_id"` CreatedAt Timestamp `db:"created_at"` UpdatedAt Timestamp `db:"updated_at"` + EndSeconds float64 `db:"end_seconds"` } func (r *sceneMarkerRow) fromSceneMarker(o models.SceneMarker) { r.ID = o.ID r.Title = o.Title r.Seconds = o.Seconds + r.EndSeconds = o.EndSeconds r.PrimaryTagID = o.PrimaryTagID r.SceneID = o.SceneID r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} @@ -48,6 +50,7 @@ func (r *sceneMarkerRow) resolve() *models.SceneMarker { ID: r.ID, Title: r.Title, Seconds: r.Seconds, + EndSeconds: r.EndSeconds, PrimaryTagID: r.PrimaryTagID, SceneID: r.SceneID, CreatedAt: r.CreatedAt.Timestamp, @@ -69,6 +72,7 @@ func (r *sceneMarkerRowRecord) fromPartial(o models.SceneMarkerPartial) { r.set("title", o.Title.Value) } r.setFloat64("seconds", o.Seconds) + r.setFloat64("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 9fd0c7d3ded..e2ebfc4df34 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 fb4c9744434..84d009bdc87 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 9694ca9ed29..2eb5cb903b5 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx @@ -52,7 +52,11 @@ export const PrimaryTags: React.FC = ({ -
{TextUtils.secondsToTimestamp(marker.seconds)}
+
+ {marker.end_seconds !== -1 + ? `${TextUtils.secondsToTimestamp(marker.seconds)}-${TextUtils.secondsToTimestamp(marker.end_seconds)}` + : TextUtils.secondsToTimestamp(marker.seconds)} +
{tags}
); diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx index 7452bdd198b..6bc0921b896 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx @@ -44,6 +44,13 @@ export const SceneMarkerForm: React.FC = ({ const schema = yup.object({ title: yup.string().ensure(), seconds: yup.number().min(0).required(), + end_seconds: yup.number().min(-1).test( + 'is-greater-than-seconds', + 'End time must be greater than or equal to start time', + function (value) { + return value !== undefined && (value === -1 || value >= this.parent.seconds); + } + ).required(), primary_tag_id: yup.string().required(), tag_ids: yup.array(yup.string().required()).defined(), }); @@ -53,6 +60,7 @@ export const SceneMarkerForm: React.FC = ({ () => ({ title: marker?.title ?? "", seconds: marker?.seconds ?? Math.round(getPlayerPosition() ?? 0), + end_seconds: marker?.end_seconds ?? -1, primary_tag_id: marker?.primary_tag.id ?? "", tag_ids: marker?.tags.map((tag) => tag.id) ?? [], }), @@ -205,6 +213,32 @@ 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)} + onReset={() => + formik.setFieldValue("end_seconds", Math.round(getPlayerPosition() ?? 0)) + } + error={error} + allowNegative={true} + /> + {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 +259,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 427c060cc3e..a6aff61e56d 100644 --- a/ui/v2.5/src/components/Wall/WallItem.tsx +++ b/ui/v2.5/src/components/Wall/WallItem.tsx @@ -183,7 +183,9 @@ export const WallItem = ({ case "sceneMarker": const sceneMarker = data as GQL.SceneMarkerDataFragment; const newTitle = markerTitle(sceneMarker); - const seconds = TextUtils.secondsToTimestamp(sceneMarker.seconds); + const seconds = sceneMarker.end_seconds !== -1 + ? `${TextUtils.secondsToTimestamp(sceneMarker.seconds)}-${TextUtils.secondsToTimestamp(sceneMarker.end_seconds)}` + : TextUtils.secondsToTimestamp(sceneMarker.seconds); 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 74073d1ccfb..39e592c89e0 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1450,6 +1450,7 @@ "tags": "Tags", "tattoos": "Tattoos", "time": "Time", + "time_end": "Time End", "title": "Title", "toast": { "added_entity": "Added {count, plural, one {{singularEntity}} other {{pluralEntity}}}",