From 57c2d595b5c3278e0601cae7bf004550acb73563 Mon Sep 17 00:00:00 2001 From: Minasuki Hikimuna Date: Sat, 28 Sep 2024 07:27:22 +0300 Subject: [PATCH] Markers can have end time Other metadata sources such as ThePornDB and timestamp.trade support end times for markers but Stash did not yet support saving those. This is a first step which only allows end time to be set either via API or via UI. Other aspects of Stash such as video player timeline are not yet updated to take end time into account. - User can set end time when creating or editing markers in the UI or in the API. - End time cannot be before start time. This is validated in the backend and for better UX also in the frontend. - End time is shown in scene details view or markers wall view if present. - Existing markers in the database will be updated to have -1 for end. - GraphQL API does not require end_seconds. Omitted end_seconds will default to -1. --- graphql/schema/types/scene-marker.graphql | 3 ++ internal/api/resolver_mutation_scene.go | 28 +++++++++++- pkg/models/model_scene_marker.go | 2 + pkg/sqlite/database.go | 2 +- pkg/sqlite/migrations/68_markers_end.up.sql | 1 + pkg/sqlite/scene_marker.go | 4 ++ ui/v2.5/graphql/data/scene-marker.graphql | 1 + .../graphql/mutations/scene-marker.graphql | 4 ++ .../Scenes/SceneDetails/PrimaryTags.tsx | 8 +++- .../Scenes/SceneDetails/SceneMarkerForm.tsx | 45 +++++++++++++++++++ ui/v2.5/src/components/Wall/WallItem.tsx | 7 ++- ui/v2.5/src/locales/en-GB.json | 1 + 12 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 pkg/sqlite/migrations/68_markers_end.up.sql 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..dfe940be812 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx @@ -52,7 +52,13 @@ 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..88f58a3b00a 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx @@ -44,6 +44,20 @@ 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 +67,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 +220,35 @@ 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 +269,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..dd6bb9f67f7 100644 --- a/ui/v2.5/src/components/Wall/WallItem.tsx +++ b/ui/v2.5/src/components/Wall/WallItem.tsx @@ -183,7 +183,12 @@ 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}}}",