mirror of
https://github.com/stashapp/stash.git
synced 2025-12-07 17:02:38 +01:00
* 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>
456 lines
12 KiB
Go
456 lines
12 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
|
|
"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"
|
|
)
|
|
|
|
const sceneMarkerTable = "scene_markers"
|
|
|
|
const countSceneMarkersForTagQuery = `
|
|
SELECT scene_markers.id FROM scene_markers
|
|
LEFT JOIN scene_markers_tags as tags_join on tags_join.scene_marker_id = scene_markers.id
|
|
WHERE tags_join.tag_id = ? OR scene_markers.primary_tag_id = ?
|
|
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"`
|
|
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}
|
|
r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt}
|
|
}
|
|
|
|
func (r *sceneMarkerRow) resolve() *models.SceneMarker {
|
|
ret := &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,
|
|
UpdatedAt: r.UpdatedAt.Timestamp,
|
|
}
|
|
|
|
return ret
|
|
}
|
|
|
|
type sceneMarkerRowRecord struct {
|
|
updateRecord
|
|
}
|
|
|
|
func (r *sceneMarkerRowRecord) fromPartial(o models.SceneMarkerPartial) {
|
|
// TODO: replace with setNullString after schema is made nullable
|
|
// r.setNullString("title", o.Title)
|
|
// saves a null input as the empty string
|
|
if o.Title.Set {
|
|
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)
|
|
r.setTimestamp("updated_at", o.UpdatedAt)
|
|
}
|
|
|
|
type sceneMarkerRepositoryType struct {
|
|
repository
|
|
|
|
scenes repository
|
|
tags joinRepository
|
|
}
|
|
|
|
var (
|
|
sceneMarkerRepository = sceneMarkerRepositoryType{
|
|
repository: repository{
|
|
tableName: sceneMarkerTable,
|
|
idColumn: idColumn,
|
|
},
|
|
scenes: repository{
|
|
tableName: sceneTable,
|
|
idColumn: idColumn,
|
|
},
|
|
tags: joinRepository{
|
|
repository: repository{
|
|
tableName: "scene_markers_tags",
|
|
idColumn: "scene_marker_id",
|
|
},
|
|
fkColumn: tagIDColumn,
|
|
},
|
|
}
|
|
)
|
|
|
|
type SceneMarkerStore struct{}
|
|
|
|
func NewSceneMarkerStore() *SceneMarkerStore {
|
|
return &SceneMarkerStore{}
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) table() exp.IdentifierExpression {
|
|
return sceneMarkerTableMgr.table
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) selectDataset() *goqu.SelectDataset {
|
|
return dialect.From(qb.table()).Select(qb.table().All())
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) Create(ctx context.Context, newObject *models.SceneMarker) error {
|
|
var r sceneMarkerRow
|
|
r.fromSceneMarker(*newObject)
|
|
|
|
id, err := sceneMarkerTableMgr.insertID(ctx, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
updated, err := qb.find(ctx, id)
|
|
if err != nil {
|
|
return fmt.Errorf("finding after create: %w", err)
|
|
}
|
|
|
|
*newObject = *updated
|
|
|
|
return nil
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) UpdatePartial(ctx context.Context, id int, partial models.SceneMarkerPartial) (*models.SceneMarker, error) {
|
|
r := sceneMarkerRowRecord{
|
|
updateRecord{
|
|
Record: make(exp.Record),
|
|
},
|
|
}
|
|
|
|
r.fromPartial(partial)
|
|
|
|
if len(r.Record) > 0 {
|
|
if err := sceneMarkerTableMgr.updateByID(ctx, id, r.Record); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return qb.find(ctx, id)
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) Update(ctx context.Context, updatedObject *models.SceneMarker) error {
|
|
var r sceneMarkerRow
|
|
r.fromSceneMarker(*updatedObject)
|
|
|
|
if err := sceneMarkerTableMgr.updateByID(ctx, updatedObject.ID, r); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) Destroy(ctx context.Context, id int) error {
|
|
return sceneMarkerRepository.destroyExisting(ctx, []int{id})
|
|
}
|
|
|
|
// returns nil, nil if not found
|
|
func (qb *SceneMarkerStore) Find(ctx context.Context, id int) (*models.SceneMarker, error) {
|
|
ret, err := qb.find(ctx, id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return ret, err
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) FindMany(ctx context.Context, ids []int) ([]*models.SceneMarker, error) {
|
|
ret := make([]*models.SceneMarker, len(ids))
|
|
|
|
table := qb.table()
|
|
q := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(ids))
|
|
unsorted, err := qb.getMany(ctx, q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, s := range unsorted {
|
|
i := slices.Index(ids, s.ID)
|
|
ret[i] = s
|
|
}
|
|
|
|
for i := range ret {
|
|
if ret[i] == nil {
|
|
return nil, fmt.Errorf("scene marker with id %d not found", ids[i])
|
|
}
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
// returns nil, sql.ErrNoRows if not found
|
|
func (qb *SceneMarkerStore) find(ctx context.Context, id int) (*models.SceneMarker, error) {
|
|
q := qb.selectDataset().Where(sceneMarkerTableMgr.byID(id))
|
|
|
|
ret, err := qb.get(ctx, q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
// returns nil, sql.ErrNoRows if not found
|
|
func (qb *SceneMarkerStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.SceneMarker, error) {
|
|
ret, err := qb.getMany(ctx, q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(ret) == 0 {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
|
|
return ret[0], nil
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.SceneMarker, error) {
|
|
const single = false
|
|
var ret []*models.SceneMarker
|
|
if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error {
|
|
var f sceneMarkerRow
|
|
if err := r.StructScan(&f); err != nil {
|
|
return err
|
|
}
|
|
|
|
s := f.resolve()
|
|
|
|
ret = append(ret, s)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) FindBySceneID(ctx context.Context, sceneID int) ([]*models.SceneMarker, error) {
|
|
query := `
|
|
SELECT scene_markers.* FROM scene_markers
|
|
WHERE scene_markers.scene_id = ?
|
|
GROUP BY scene_markers.id
|
|
ORDER BY scene_markers.seconds ASC
|
|
`
|
|
args := []interface{}{sceneID}
|
|
return qb.querySceneMarkers(ctx, query, args)
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) CountByTagID(ctx context.Context, tagID int) (int, error) {
|
|
args := []interface{}{tagID, tagID}
|
|
return sceneMarkerRepository.runCountQuery(ctx, sceneMarkerRepository.buildCountQuery(countSceneMarkersForTagQuery), args)
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) GetMarkerStrings(ctx context.Context, q *string, sort *string) ([]*models.MarkerStringsResultType, error) {
|
|
query := "SELECT count(*) as `count`, scene_markers.id as id, scene_markers.title as title FROM scene_markers"
|
|
if q != nil {
|
|
query += " WHERE title LIKE '%" + *q + "%'"
|
|
}
|
|
query += " GROUP BY title"
|
|
if sort != nil && *sort == "count" {
|
|
query += " ORDER BY `count` DESC"
|
|
} else {
|
|
query += " ORDER BY title ASC"
|
|
}
|
|
var args []interface{}
|
|
return qb.queryMarkerStringsResultType(ctx, query, args)
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) Wall(ctx context.Context, q *string) ([]*models.SceneMarker, error) {
|
|
s := ""
|
|
if q != nil {
|
|
s = *q
|
|
}
|
|
|
|
table := qb.table()
|
|
qq := qb.selectDataset().Prepared(true).Where(table.Col("title").Like("%" + s + "%")).Order(goqu.L("RANDOM()").Asc()).Limit(80)
|
|
return qb.getMany(ctx, qq)
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) makeQuery(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) {
|
|
if sceneMarkerFilter == nil {
|
|
sceneMarkerFilter = &models.SceneMarkerFilterType{}
|
|
}
|
|
if findFilter == nil {
|
|
findFilter = &models.FindFilterType{}
|
|
}
|
|
|
|
query := sceneMarkerRepository.newQuery()
|
|
distinctIDs(&query, sceneMarkerTable)
|
|
|
|
if q := findFilter.Q; q != nil && *q != "" {
|
|
query.join(sceneTable, "", "scenes.id = scene_markers.scene_id")
|
|
query.join(tagTable, "", "scene_markers.primary_tag_id = tags.id")
|
|
searchColumns := []string{"scene_markers.title", "scenes.title", "tags.name"}
|
|
query.parseQueryString(searchColumns, *q)
|
|
}
|
|
|
|
filter := filterBuilderFromHandler(ctx, &sceneMarkerFilterHandler{
|
|
sceneMarkerFilter: sceneMarkerFilter,
|
|
})
|
|
|
|
if err := query.addFilter(filter); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := qb.setSceneMarkerSort(&query, findFilter); err != nil {
|
|
return nil, err
|
|
}
|
|
query.sortAndPagination += getPagination(findFilter)
|
|
|
|
return &query, nil
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) Query(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, findFilter *models.FindFilterType) ([]*models.SceneMarker, int, error) {
|
|
query, err := qb.makeQuery(ctx, sceneMarkerFilter, findFilter)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
idsResult, countResult, err := query.executeFind(ctx)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
sceneMarkers, err := qb.FindMany(ctx, idsResult)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return sceneMarkers, countResult, nil
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) QueryCount(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, findFilter *models.FindFilterType) (int, error) {
|
|
query, err := qb.makeQuery(ctx, sceneMarkerFilter, findFilter)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return query.executeCount(ctx)
|
|
}
|
|
|
|
var sceneMarkerSortOptions = sortOptions{
|
|
"created_at",
|
|
"id",
|
|
"title",
|
|
"random",
|
|
"scene_id",
|
|
"scenes_updated_at",
|
|
"seconds",
|
|
"updated_at",
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) setSceneMarkerSort(query *queryBuilder, findFilter *models.FindFilterType) error {
|
|
sort := findFilter.GetSort("title")
|
|
direction := findFilter.GetDirection()
|
|
|
|
// CVE-2024-32231 - ensure sort is in the list of allowed sorts
|
|
if err := sceneMarkerSortOptions.validateSort(sort); err != nil {
|
|
return err
|
|
}
|
|
|
|
switch sort {
|
|
case "scenes_updated_at":
|
|
sort = "updated_at"
|
|
query.join(sceneTable, "", "scenes.id = scene_markers.scene_id")
|
|
query.sortAndPagination += getSort(sort, direction, sceneTable)
|
|
case "title":
|
|
query.join(tagTable, "", "scene_markers.primary_tag_id = tags.id")
|
|
query.sortAndPagination += " ORDER BY COALESCE(NULLIF(scene_markers.title,''), tags.name) COLLATE NATURAL_CI " + direction
|
|
default:
|
|
query.sortAndPagination += getSort(sort, direction, sceneMarkerTable)
|
|
}
|
|
|
|
query.sortAndPagination += ", scene_markers.scene_id ASC, scene_markers.seconds ASC"
|
|
return nil
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) querySceneMarkers(ctx context.Context, query string, args []interface{}) ([]*models.SceneMarker, error) {
|
|
const single = false
|
|
var ret []*models.SceneMarker
|
|
if err := sceneMarkerRepository.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error {
|
|
var f sceneMarkerRow
|
|
if err := r.StructScan(&f); err != nil {
|
|
return err
|
|
}
|
|
|
|
s := f.resolve()
|
|
|
|
ret = append(ret, s)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) queryMarkerStringsResultType(ctx context.Context, query string, args []interface{}) ([]*models.MarkerStringsResultType, error) {
|
|
rows, err := dbWrapper.Queryx(ctx, query, args...)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
markerStrings := make([]*models.MarkerStringsResultType, 0)
|
|
for rows.Next() {
|
|
markerString := models.MarkerStringsResultType{}
|
|
if err := rows.StructScan(&markerString); err != nil {
|
|
return nil, err
|
|
}
|
|
markerStrings = append(markerStrings, &markerString)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return markerStrings, nil
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) GetTagIDs(ctx context.Context, id int) ([]int, error) {
|
|
return sceneMarkerRepository.tags.getIDs(ctx, id)
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) UpdateTags(ctx context.Context, id int, tagIDs []int) error {
|
|
// Delete the existing joins and then create new ones
|
|
return sceneMarkerRepository.tags.replace(ctx, id, tagIDs)
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) Count(ctx context.Context) (int, error) {
|
|
q := dialect.Select(goqu.COUNT("*")).From(qb.table())
|
|
return count(ctx, q)
|
|
}
|
|
|
|
func (qb *SceneMarkerStore) All(ctx context.Context) ([]*models.SceneMarker, error) {
|
|
return qb.getMany(ctx, qb.selectDataset())
|
|
}
|