mirror of https://github.com/stashapp/stash.git
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>
This commit is contained in:
parent
180a0fa8dd
commit
0d40056f8c
|
@ -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!]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE scene_markers ADD COLUMN end_seconds FLOAT;
|
|
@ -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)
|
||||
|
|
|
@ -2,6 +2,7 @@ fragment SceneMarkerData on SceneMarker {
|
|||
id
|
||||
title
|
||||
seconds
|
||||
end_seconds
|
||||
stream
|
||||
preview
|
||||
screenshot
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
@ -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) ?? [],
|
||||
}),
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -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 = (
|
||||
|
@ -225,6 +270,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
|
|||
{renderTitleField()}
|
||||
{renderPrimaryTagField()}
|
||||
{renderTimeField()}
|
||||
{renderEndTimeField()}
|
||||
{renderTagsField()}
|
||||
</div>
|
||||
<div className="buttons-container px-3">
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue