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:
MinasukiHikimuna 2024-11-02 02:55:48 +02:00 committed by GitHub
parent 180a0fa8dd
commit 0d40056f8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 134 additions and 11 deletions

View File

@ -2,7 +2,10 @@ type SceneMarker {
id: ID! id: ID!
scene: Scene! scene: Scene!
title: String! title: String!
"The required start time of the marker (in seconds). Supports decimals."
seconds: Float! seconds: Float!
"The optional end time of the marker (in seconds). Supports decimals."
end_seconds: Float
primary_tag: Tag! primary_tag: Tag!
tags: [Tag!]! tags: [Tag!]!
created_at: Time! created_at: Time!
@ -18,7 +21,10 @@ type SceneMarker {
input SceneMarkerCreateInput { input SceneMarkerCreateInput {
title: String! title: String!
"The required start time of the marker (in seconds). Supports decimals."
seconds: Float! seconds: Float!
"The optional end time of the marker (in seconds). Supports decimals."
end_seconds: Float
scene_id: ID! scene_id: ID!
primary_tag_id: ID! primary_tag_id: ID!
tag_ids: [ID!] tag_ids: [ID!]
@ -27,7 +33,10 @@ input SceneMarkerCreateInput {
input SceneMarkerUpdateInput { input SceneMarkerUpdateInput {
id: ID! id: ID!
title: String title: String
"The start time of the marker (in seconds). Supports decimals."
seconds: Float seconds: Float
"The end time of the marker (in seconds). Supports decimals."
end_seconds: Float
scene_id: ID scene_id: ID
primary_tag_id: ID primary_tag_id: ID
tag_ids: [ID!] tag_ids: [ID!]

View File

@ -655,6 +655,13 @@ func (r *mutationResolver) SceneMarkerCreate(ctx context.Context, input SceneMar
newMarker.PrimaryTagID = primaryTagID newMarker.PrimaryTagID = primaryTagID
newMarker.SceneID = sceneID 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) tagIDs, err := stringslice.StringSliceToIntSlice(input.TagIds)
if err != nil { if err != nil {
return nil, fmt.Errorf("converting tag ids: %w", err) 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) 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) { func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMarkerUpdateInput) (*models.SceneMarker, error) {
markerID, err := strconv.Atoi(input.ID) markerID, err := strconv.Atoi(input.ID)
if err != nil { if err != nil {
@ -695,6 +709,7 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar
updatedMarker.Title = translator.optionalString(input.Title, "title") updatedMarker.Title = translator.optionalString(input.Title, "title")
updatedMarker.Seconds = translator.optionalFloat64(input.Seconds, "seconds") updatedMarker.Seconds = translator.optionalFloat64(input.Seconds, "seconds")
updatedMarker.EndSeconds = translator.optionalFloat64(input.EndSeconds, "end_seconds")
updatedMarker.SceneID, err = translator.optionalIntFromString(input.SceneID, "scene_id") updatedMarker.SceneID, err = translator.optionalIntFromString(input.SceneID, "scene_id")
if err != nil { if err != nil {
return nil, fmt.Errorf("converting scene id: %w", err) 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) 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) newMarker, err := qb.UpdatePartial(ctx, markerID, updatedMarker)
if err != nil { if err != nil {
return err 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 // 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) seconds := int(existingMarker.Seconds)
if err := fileDeleter.MarkMarkerFiles(existingScene, seconds); err != nil { if err := fileDeleter.MarkMarkerFiles(existingScene, seconds); err != nil {
return err return err

View File

@ -8,6 +8,7 @@ type SceneMarker struct {
ID int `json:"id"` ID int `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Seconds float64 `json:"seconds"` Seconds float64 `json:"seconds"`
EndSeconds *float64 `json:"end_seconds"`
PrimaryTagID int `json:"primary_tag_id"` PrimaryTagID int `json:"primary_tag_id"`
SceneID int `json:"scene_id"` SceneID int `json:"scene_id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@ -27,6 +28,7 @@ func NewSceneMarker() SceneMarker {
type SceneMarkerPartial struct { type SceneMarkerPartial struct {
Title OptionalString Title OptionalString
Seconds OptionalFloat64 Seconds OptionalFloat64
EndSeconds OptionalFloat64
PrimaryTagID OptionalInt PrimaryTagID OptionalInt
SceneID OptionalInt SceneID OptionalInt
CreatedAt OptionalTime CreatedAt OptionalTime

View File

@ -34,7 +34,7 @@ const (
cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE"
) )
var appSchemaVersion uint = 69 var appSchemaVersion uint = 70
//go:embed migrations/*.sql //go:embed migrations/*.sql
var migrationsBox embed.FS var migrationsBox embed.FS

View File

@ -0,0 +1 @@
ALTER TABLE scene_markers ADD COLUMN end_seconds FLOAT;

View File

@ -10,6 +10,7 @@ import (
"github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9"
"github.com/doug-martin/goqu/v9/exp" "github.com/doug-martin/goqu/v9/exp"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"gopkg.in/guregu/null.v4"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
) )
@ -24,19 +25,23 @@ GROUP BY scene_markers.id
` `
type sceneMarkerRow struct { type sceneMarkerRow struct {
ID int `db:"id" goqu:"skipinsert"` ID int `db:"id" goqu:"skipinsert"`
Title string `db:"title"` // TODO: make db schema (and gql schema) nullable Title string `db:"title"` // TODO: make db schema (and gql schema) nullable
Seconds float64 `db:"seconds"` Seconds float64 `db:"seconds"`
PrimaryTagID int `db:"primary_tag_id"` PrimaryTagID int `db:"primary_tag_id"`
SceneID int `db:"scene_id"` SceneID int `db:"scene_id"`
CreatedAt Timestamp `db:"created_at"` CreatedAt Timestamp `db:"created_at"`
UpdatedAt Timestamp `db:"updated_at"` UpdatedAt Timestamp `db:"updated_at"`
EndSeconds null.Float `db:"end_seconds"`
} }
func (r *sceneMarkerRow) fromSceneMarker(o models.SceneMarker) { func (r *sceneMarkerRow) fromSceneMarker(o models.SceneMarker) {
r.ID = o.ID r.ID = o.ID
r.Title = o.Title r.Title = o.Title
r.Seconds = o.Seconds r.Seconds = o.Seconds
if o.EndSeconds != nil {
r.EndSeconds = null.FloatFrom(*o.EndSeconds)
}
r.PrimaryTagID = o.PrimaryTagID r.PrimaryTagID = o.PrimaryTagID
r.SceneID = o.SceneID r.SceneID = o.SceneID
r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} r.CreatedAt = Timestamp{Timestamp: o.CreatedAt}
@ -48,6 +53,7 @@ func (r *sceneMarkerRow) resolve() *models.SceneMarker {
ID: r.ID, ID: r.ID,
Title: r.Title, Title: r.Title,
Seconds: r.Seconds, Seconds: r.Seconds,
EndSeconds: r.EndSeconds.Ptr(),
PrimaryTagID: r.PrimaryTagID, PrimaryTagID: r.PrimaryTagID,
SceneID: r.SceneID, SceneID: r.SceneID,
CreatedAt: r.CreatedAt.Timestamp, CreatedAt: r.CreatedAt.Timestamp,
@ -69,6 +75,7 @@ func (r *sceneMarkerRowRecord) fromPartial(o models.SceneMarkerPartial) {
r.set("title", o.Title.Value) r.set("title", o.Title.Value)
} }
r.setFloat64("seconds", o.Seconds) r.setFloat64("seconds", o.Seconds)
r.setNullFloat64("end_seconds", o.EndSeconds)
r.setInt("primary_tag_id", o.PrimaryTagID) r.setInt("primary_tag_id", o.PrimaryTagID)
r.setInt("scene_id", o.SceneID) r.setInt("scene_id", o.SceneID)
r.setTimestamp("created_at", o.CreatedAt) r.setTimestamp("created_at", o.CreatedAt)

View File

@ -2,6 +2,7 @@ fragment SceneMarkerData on SceneMarker {
id id
title title
seconds seconds
end_seconds
stream stream
preview preview
screenshot screenshot

View File

@ -1,6 +1,7 @@
mutation SceneMarkerCreate( mutation SceneMarkerCreate(
$title: String! $title: String!
$seconds: Float! $seconds: Float!
$end_seconds: Float
$scene_id: ID! $scene_id: ID!
$primary_tag_id: ID! $primary_tag_id: ID!
$tag_ids: [ID!] = [] $tag_ids: [ID!] = []
@ -9,6 +10,7 @@ mutation SceneMarkerCreate(
input: { input: {
title: $title title: $title
seconds: $seconds seconds: $seconds
end_seconds: $end_seconds
scene_id: $scene_id scene_id: $scene_id
primary_tag_id: $primary_tag_id primary_tag_id: $primary_tag_id
tag_ids: $tag_ids tag_ids: $tag_ids
@ -22,6 +24,7 @@ mutation SceneMarkerUpdate(
$id: ID! $id: ID!
$title: String! $title: String!
$seconds: Float! $seconds: Float!
$end_seconds: Float
$scene_id: ID! $scene_id: ID!
$primary_tag_id: ID! $primary_tag_id: ID!
$tag_ids: [ID!] = [] $tag_ids: [ID!] = []
@ -31,6 +34,7 @@ mutation SceneMarkerUpdate(
id: $id id: $id
title: $title title: $title
seconds: $seconds seconds: $seconds
end_seconds: $end_seconds
scene_id: $scene_id scene_id: $scene_id
primary_tag_id: $primary_tag_id primary_tag_id: $primary_tag_id
tag_ids: $tag_ids tag_ids: $tag_ids

View File

@ -52,7 +52,12 @@ export const PrimaryTags: React.FC<IPrimaryTags> = ({
<FormattedMessage id="actions.edit" /> <FormattedMessage id="actions.edit" />
</Button> </Button>
</div> </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 className="card-section centered">{tags}</div>
</div> </div>
); );

View File

@ -44,6 +44,18 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
const schema = yup.object({ const schema = yup.object({
title: yup.string().ensure(), title: yup.string().ensure(),
seconds: yup.number().min(0).required(), 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(), primary_tag_id: yup.string().required(),
tag_ids: yup.array(yup.string().required()).defined(), tag_ids: yup.array(yup.string().required()).defined(),
}); });
@ -53,6 +65,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
() => ({ () => ({
title: marker?.title ?? "", title: marker?.title ?? "",
seconds: marker?.seconds ?? Math.round(getPlayerPosition() ?? 0), seconds: marker?.seconds ?? Math.round(getPlayerPosition() ?? 0),
end_seconds: marker?.end_seconds ?? null,
primary_tag_id: marker?.primary_tag.id ?? "", primary_tag_id: marker?.primary_tag.id ?? "",
tag_ids: marker?.tags.map((tag) => tag.id) ?? [], tag_ids: marker?.tags.map((tag) => tag.id) ?? [],
}), }),
@ -103,6 +116,8 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
variables: { variables: {
scene_id: sceneID, scene_id: sceneID,
...input, ...input,
// undefined means setting to null, not omitting the field
end_seconds: input.end_seconds ?? null,
}, },
}); });
} else { } else {
@ -111,6 +126,8 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
id: marker.id, id: marker.id,
scene_id: sceneID, scene_id: sceneID,
...input, ...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); 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() { function renderTagsField() {
const title = intl.formatMessage({ id: "tags" }); const title = intl.formatMessage({ id: "tags" });
const control = ( const control = (
@ -225,6 +270,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
{renderTitleField()} {renderTitleField()}
{renderPrimaryTagField()} {renderPrimaryTagField()}
{renderTimeField()} {renderTimeField()}
{renderEndTimeField()}
{renderTagsField()} {renderTagsField()}
</div> </div>
<div className="buttons-container px-3"> <div className="buttons-container px-3">

View File

@ -183,7 +183,10 @@ export const WallItem = <T extends WallItemType>({
case "sceneMarker": case "sceneMarker":
const sceneMarker = data as GQL.SceneMarkerDataFragment; const sceneMarker = data as GQL.SceneMarkerDataFragment;
const newTitle = markerTitle(sceneMarker); const newTitle = markerTitle(sceneMarker);
const seconds = TextUtils.secondsToTimestamp(sceneMarker.seconds); const seconds = TextUtils.formatTimestampRange(
sceneMarker.seconds,
sceneMarker.end_seconds ?? undefined
);
if (newTitle) { if (newTitle) {
return `${newTitle} - ${seconds}`; return `${newTitle} - ${seconds}`;
} else { } else {

View File

@ -1457,6 +1457,7 @@
"tags": "Tags", "tags": "Tags",
"tattoos": "Tattoos", "tattoos": "Tattoos",
"time": "Time", "time": "Time",
"time_end": "End Time",
"title": "Title", "title": "Title",
"toast": { "toast": {
"added_entity": "Added {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "added_entity": "Added {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
@ -1488,6 +1489,7 @@
"validation": { "validation": {
"blank": "${path} must not be blank", "blank": "${path} must not be blank",
"date_invalid_form": "${path} must be in YYYY-MM-DD form", "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", "required": "${path} is a required field",
"unique": "${path} must be unique" "unique": "${path} must be unique"
}, },

View File

@ -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) => { const timestampToSeconds = (v: string | null | undefined) => {
if (!v) { if (!v) {
return null; return null;
@ -470,6 +477,7 @@ const TextUtils = {
formatFileSizeUnit, formatFileSizeUnit,
fileSizeFractionalDigits, fileSizeFractionalDigits,
secondsToTimestamp, secondsToTimestamp,
formatTimestampRange,
timestampToSeconds, timestampToSeconds,
fileNameFromPath, fileNameFromPath,
stringToDate, stringToDate,