Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Markers can have end time #5311

Merged
merged 10 commits into from
Nov 2, 2024
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
Loading