From 0d40056f8c6b11cec1b9acd11a9fdac4e9fb13f6 Mon Sep 17 00:00:00 2001 From: MinasukiHikimuna <121475844+MinasukiHikimuna@users.noreply.github.com> Date: Sat, 2 Nov 2024 02:55:48 +0200 Subject: [PATCH] Markers can have end time (#5311) * 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. - GraphQL API does not require end_seconds. --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/schema/types/scene-marker.graphql | 9 ++++ internal/api/resolver_mutation_scene.go | 37 ++++++++++++++- pkg/models/model_scene_marker.go | 2 + pkg/sqlite/database.go | 2 +- pkg/sqlite/migrations/70_markers_end.up.sql | 1 + pkg/sqlite/scene_marker.go | 21 ++++++--- ui/v2.5/graphql/data/scene-marker.graphql | 1 + .../graphql/mutations/scene-marker.graphql | 4 ++ .../Scenes/SceneDetails/PrimaryTags.tsx | 7 ++- .../Scenes/SceneDetails/SceneMarkerForm.tsx | 46 +++++++++++++++++++ ui/v2.5/src/components/Wall/WallItem.tsx | 5 +- ui/v2.5/src/locales/en-GB.json | 2 + ui/v2.5/src/utils/text.ts | 8 ++++ 13 files changed, 134 insertions(+), 11 deletions(-) create mode 100644 pkg/sqlite/migrations/70_markers_end.up.sql diff --git a/graphql/schema/types/scene-marker.graphql b/graphql/schema/types/scene-marker.graphql index 8b995c9d507..6d144121374 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 b0c6ac8b5aa..101cc8ba5e5 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 df77afecd77..77860331533 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 965c44ef9f4..d2c0a8191e5 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 00000000000..05469953ace --- /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 4af4d6b4bae..8b2306eab4b 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 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..3b1de35c7b2 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..11c805ec6b5 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 7452bdd198b..03fcb3b483f 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 427c060cc3e..5811b75437e 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 6be0a654240..edad1c8e7cf 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 627822f21c2..da7f7e0241d 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,