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 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,