Skip to content

Commit

Permalink
Markers can have end time (#5311)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
MinasukiHikimuna authored Nov 2, 2024
1 parent 180a0fa commit 0d40056
Show file tree
Hide file tree
Showing 13 changed files with 134 additions and 11 deletions.
9 changes: 9 additions & 0 deletions graphql/schema/types/scene-marker.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand All @@ -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!]
Expand All @@ -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!]
Expand Down
37 changes: 36 additions & 1 deletion internal/api/resolver_mutation_scene.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions pkg/models/model_scene_marker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -27,6 +28,7 @@ func NewSceneMarker() SceneMarker {
type SceneMarkerPartial struct {
Title OptionalString
Seconds OptionalFloat64
EndSeconds OptionalFloat64
PrimaryTagID OptionalInt
SceneID OptionalInt
CreatedAt OptionalTime
Expand Down
2 changes: 1 addition & 1 deletion pkg/sqlite/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pkg/sqlite/migrations/70_markers_end.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE scene_markers ADD COLUMN end_seconds FLOAT;
21 changes: 14 additions & 7 deletions pkg/sqlite/scene_marker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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}
Expand All @@ -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,
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions ui/v2.5/graphql/data/scene-marker.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ fragment SceneMarkerData on SceneMarker {
id
title
seconds
end_seconds
stream
preview
screenshot
Expand Down
4 changes: 4 additions & 0 deletions ui/v2.5/graphql/mutations/scene-marker.graphql
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mutation SceneMarkerCreate(
$title: String!
$seconds: Float!
$end_seconds: Float
$scene_id: ID!
$primary_tag_id: ID!
$tag_ids: [ID!] = []
Expand All @@ -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
Expand All @@ -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!] = []
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,12 @@ export const PrimaryTags: React.FC<IPrimaryTags> = ({
<FormattedMessage id="actions.edit" />
</Button>
</div>
<div>{TextUtils.secondsToTimestamp(marker.seconds)}</div>
<div>
{TextUtils.formatTimestampRange(
marker.seconds,
marker.end_seconds ?? undefined
)}
</div>
<div className="card-section centered">{tags}</div>
</div>
);
Expand Down
46 changes: 46 additions & 0 deletions ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
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(),
});
Expand All @@ -53,6 +65,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
() => ({
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) ?? [],
}),
Expand Down Expand Up @@ -103,6 +116,8 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
variables: {
scene_id: sceneID,
...input,
// undefined means setting to null, not omitting the field
end_seconds: input.end_seconds ?? null,
},
});
} else {
Expand All @@ -111,6 +126,8 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
id: marker.id,
scene_id: sceneID,
...input,
// undefined means setting to null, not omitting the field
end_seconds: input.end_seconds ?? null,
},
});
}
Expand Down Expand Up @@ -205,6 +222,34 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
return renderField("seconds", title, control);
}

function renderEndTimeField() {
const { error } = formik.getFieldMeta("end_seconds");

const title = intl.formatMessage({ id: "time_end" });
const control = (
<>
<DurationInput
value={formik.values.end_seconds}
setValue={(v) => 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 && (
<Form.Control.Feedback type="invalid">
{formik.errors.end_seconds}
</Form.Control.Feedback>
)}
</>
);

return renderField("end_seconds", title, control);
}

function renderTagsField() {
const title = intl.formatMessage({ id: "tags" });
const control = (
Expand All @@ -225,6 +270,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
{renderTitleField()}
{renderPrimaryTagField()}
{renderTimeField()}
{renderEndTimeField()}
{renderTagsField()}
</div>
<div className="buttons-container px-3">
Expand Down
5 changes: 4 additions & 1 deletion ui/v2.5/src/components/Wall/WallItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,10 @@ export const WallItem = <T extends WallItemType>({
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 {
Expand Down
2 changes: 2 additions & 0 deletions ui/v2.5/src/locales/en-GB.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}}",
Expand Down Expand Up @@ -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"
},
Expand Down
8 changes: 8 additions & 0 deletions ui/v2.5/src/utils/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -470,6 +477,7 @@ const TextUtils = {
formatFileSizeUnit,
fileSizeFractionalDigits,
secondsToTimestamp,
formatTimestampRange,
timestampToSeconds,
fileNameFromPath,
stringToDate,
Expand Down

0 comments on commit 0d40056

Please sign in to comment.