diff --git a/FINISH_BOUNTY.ps1 b/FINISH_BOUNTY.ps1 new file mode 100644 index 0000000000..dea7ddb412 --- /dev/null +++ b/FINISH_BOUNTY.ps1 @@ -0,0 +1,28 @@ +# FAST FINISH - Copy & Run in NEW PowerShell + +cd C:\Users\Admin\Desktop\myrepo\stashapp + +# 1. Setup (30 sec) +if (!(Test-Path "ui\v2.5\build")) { mkdir ui\v2.5\build } +echo $null > ui\v2.5\build\index.html + +# 2. Install gqlgen (10 sec) +go install github.com/99designs/gqlgen@latest + +# 3. Generate GraphQL (20 sec) +go generate ./cmd/stash + +# 4. Build backend (1 min) +go build ./cmd/stash + +# 5. Install npm packages (3 min) +cd ui\v2.5 +npm install + +# 6. Build frontend (5 min) +npm run build + +# 7. Done! +cd ..\.. +Write-Host "✅ BUILD COMPLETE! Run: .\stash.exe" -ForegroundColor Green +Write-Host "Then test & submit PR for $450!" -ForegroundColor Yellow diff --git a/PUSH_TO_GITHUB.ps1 b/PUSH_TO_GITHUB.ps1 new file mode 100644 index 0000000000..41c0b55e46 --- /dev/null +++ b/PUSH_TO_GITHUB.ps1 @@ -0,0 +1,41 @@ +# Stashapp Branch Creation and Push Script +# Run this in PowerShell + +cd C:\Users\Admin\Desktop\myrepo\stashapp + +Write-Host "=== STEP 1: Current Git Status ===" -ForegroundColor Cyan +git status + +Write-Host "`n=== STEP 2: Current Branch ===" -ForegroundColor Cyan +git branch + +Write-Host "`n=== STEP 3: Remote Configuration ===" -ForegroundColor Cyan +git remote -v + +Write-Host "`n=== STEP 4: Creating feature/scene-segments branch ===" -ForegroundColor Cyan +git checkout -b feature/scene-segments 2>&1 + +Write-Host "`n=== STEP 5: Adding scene segment files ===" -ForegroundColor Cyan +git add pkg/models/model_scene_segment.go +git add pkg/models/repository_scene_segment.go +git add pkg/sqlite/scene_segment.go +git add pkg/sqlite/migrations/76_scene_segments.up.sql +git add pkg/sqlite/migrations/76_scene_segments.down.sql +git add graphql/schema/types/scene-segment.graphql +git add internal/api/resolver_model_scene_segment.go +git add internal/api/resolver_mutation_scene_segment.go +git add internal/api/resolver_query_scene_segment.go +git add ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentForm.tsx +git add ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentsPanel.tsx + +Write-Host "`n=== STEP 6: Checking what will be committed ===" -ForegroundColor Cyan +git status + +Write-Host "`n=== STEP 7: Committing changes ===" -ForegroundColor Cyan +git commit -m "feat: implement scene segments feature for issue #3530" + +Write-Host "`n=== STEP 8: Pushing to GitHub ===" -ForegroundColor Cyan +git push -u origin feature/scene-segments + +Write-Host "`n=== DONE! ===" -ForegroundColor Green +Write-Host "Check https://github.com/SBALAVIGNESH123/stash/tree/feature/scene-segments" -ForegroundColor Yellow diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 8936b8a347..d9d86f5b95 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -68,6 +68,11 @@ type Query { ids: [ID!] ): FindSceneMarkersResultType! + "Find scene segments by scene ID" + findSceneSegments(scene_id: ID!): [SceneSegment!]! + "Find a single scene segment by ID" + findSceneSegment(id: ID!): SceneSegment + findImage(id: ID, checksum: String): Image "A function which queries Scene objects" @@ -338,6 +343,10 @@ type Mutation { sceneMarkerDestroy(id: ID!): Boolean! sceneMarkersDestroy(ids: [ID!]!): Boolean! + sceneSegmentCreate(input: SceneSegmentCreateInput!): SceneSegment + sceneSegmentUpdate(input: SceneSegmentUpdateInput!): SceneSegment + sceneSegmentDestroy(id: ID!): Boolean! + sceneAssignFile(input: AssignSceneFileInput!): Boolean! imageUpdate(input: ImageUpdateInput!): Image diff --git a/graphql/schema/types/scene-segment.graphql b/graphql/schema/types/scene-segment.graphql new file mode 100644 index 0000000000..7c3c7de29e --- /dev/null +++ b/graphql/schema/types/scene-segment.graphql @@ -0,0 +1,24 @@ +type SceneSegment { + id: ID! + title: String! + scene_id: ID! + scene: Scene! + start_seconds: Float! + end_seconds: Float! + created_at: Time! + updated_at: Time! +} + +input SceneSegmentCreateInput { + title: String! + scene_id: ID! + start_seconds: Float! + end_seconds: Float! +} + +input SceneSegmentUpdateInput { + id: ID! + title: String + start_seconds: Float + end_seconds: Float +} diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index eca01d15ed..79191def45 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -71,6 +71,7 @@ type Scene { files: [VideoFile!]! paths: ScenePathsType! # Resolver scene_markers: [SceneMarker!]! + scene_segments: [SceneSegment!]! galleries: [Gallery!]! studio: Studio groups: [SceneGroup!]! diff --git a/internal/api/resolver.go b/internal/api/resolver.go index 061d0e1a9b..35a8ee6ba6 100644 --- a/internal/api/resolver.go +++ b/internal/api/resolver.go @@ -69,6 +69,9 @@ func (r *Resolver) Image() ImageResolver { func (r *Resolver) SceneMarker() SceneMarkerResolver { return &sceneMarkerResolver{r} } +func (r *Resolver) SceneSegment() SceneSegmentResolver { + return &sceneSegmentResolver{r} +} func (r *Resolver) Studio() StudioResolver { return &studioResolver{r} } @@ -120,6 +123,7 @@ type galleryChapterResolver struct{ *Resolver } type performerResolver struct{ *Resolver } type sceneResolver struct{ *Resolver } type sceneMarkerResolver struct{ *Resolver } +type sceneSegmentResolver struct{ *Resolver } type imageResolver struct{ *Resolver } type studioResolver struct{ *Resolver } diff --git a/internal/api/resolver_model_scene.go b/internal/api/resolver_model_scene.go index 2600c9538a..aabb7738ed 100644 --- a/internal/api/resolver_model_scene.go +++ b/internal/api/resolver_model_scene.go @@ -142,6 +142,17 @@ func (r *sceneResolver) SceneMarkers(ctx context.Context, obj *models.Scene) (re return ret, nil } +func (r *sceneResolver) SceneSegments(ctx context.Context, obj *models.Scene) (ret []*models.SceneSegment, err error) { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = r.repository.SceneSegment.FindBySceneID(ctx, obj.ID) + return err + }); err != nil { + return nil, err + } + + return ret, nil +} + func (r *sceneResolver) Captions(ctx context.Context, obj *models.Scene) (ret []*models.VideoCaption, err error) { primaryFile, err := r.getPrimaryFile(ctx, obj) if err != nil { diff --git a/internal/api/resolver_model_scene_segment.go b/internal/api/resolver_model_scene_segment.go new file mode 100644 index 0000000000..9bc1658771 --- /dev/null +++ b/internal/api/resolver_model_scene_segment.go @@ -0,0 +1,18 @@ +package api + +import ( + "context" + + "github.com/stashapp/stash/pkg/models" +) + +func (r *sceneSegmentResolver) Scene(ctx context.Context, obj *models.SceneSegment) (ret *models.Scene, err error) { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = r.repository.Scene.Find(ctx, obj.SceneID) + return err + }); err != nil { + return nil, err + } + + return ret, nil +} diff --git a/internal/api/resolver_mutation_scene_segment.go b/internal/api/resolver_mutation_scene_segment.go new file mode 100644 index 0000000000..ec08fc1a1a --- /dev/null +++ b/internal/api/resolver_mutation_scene_segment.go @@ -0,0 +1,85 @@ +package api + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/stashapp/stash/pkg/models" +) + +func (r *mutationResolver) SceneSegmentCreate(ctx context.Context, input SceneSegmentCreateInput) (*models.SceneSegment, error) { + sceneID, err := strconv.Atoi(input.SceneID) + if err != nil { + return nil, fmt.Errorf("converting scene id: %w", err) + } + + // Populate a new scene segment from the input + newSegment := models.NewSceneSegment() + + newSegment.Title = strings.TrimSpace(input.Title) + newSegment.SceneID = sceneID + newSegment.StartSeconds = input.StartSeconds + newSegment.EndSeconds = input.EndSeconds + + // Validate + if err := newSegment.Validate(); err != nil { + return nil, err + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.SceneSegment + + return qb.Create(ctx, &newSegment) + }); err != nil { + return nil, err + } + + return &newSegment, nil +} + +func (r *mutationResolver) SceneSegmentUpdate(ctx context.Context, input SceneSegmentUpdateInput) (*models.SceneSegment, error) { + segmentID, err := strconv.Atoi(input.ID) + if err != nil { + return nil, fmt.Errorf("converting id: %w", err) + } + + translator := changesetTranslator{ + inputMap: getUpdateInputMap(ctx), + } + + // Populate scene segment from the input + updatedSegment := models.SceneSegmentPartial{} + + updatedSegment.Title = translator.optionalString(input.Title, "title") + updatedSegment.StartSeconds = translator.optionalFloat64(input.StartSeconds, "start_seconds") + updatedSegment.EndSeconds = translator.optionalFloat64(input.EndSeconds, "end_seconds") + + var ret *models.SceneSegment + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.SceneSegment + + ret, err = qb.UpdatePartial(ctx, segmentID, updatedSegment) + return err + }); err != nil { + return nil, err + } + + return ret, nil +} + +func (r *mutationResolver) SceneSegmentDestroy(ctx context.Context, id string) (bool, error) { + segmentID, err := strconv.Atoi(id) + if err != nil { + return false, fmt.Errorf("converting id: %w", err) + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + return r.repository.SceneSegment.Destroy(ctx, segmentID) + }); err != nil { + return false, err + } + + return true, nil +} diff --git a/internal/api/resolver_query_scene_segment.go b/internal/api/resolver_query_scene_segment.go new file mode 100644 index 0000000000..1956d3d6f7 --- /dev/null +++ b/internal/api/resolver_query_scene_segment.go @@ -0,0 +1,43 @@ +package api + +import ( + "context" + "fmt" + "strconv" + + "github.com/stashapp/stash/pkg/models" +) + +func (r *queryResolver) FindSceneSegment(ctx context.Context, id string) (*models.SceneSegment, error) { + segmentID, err := strconv.Atoi(id) + if err != nil { + return nil, fmt.Errorf("converting id: %w", err) + } + + var segment *models.SceneSegment + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + segment, err = r.repository.SceneSegment.Find(ctx, segmentID) + return err + }); err != nil { + return nil, err + } + + return segment, nil +} + +func (r *queryResolver) FindSceneSegments(ctx context.Context, sceneID string) ([]*models.SceneSegment, error) { + sid, err := strconv.Atoi(sceneID) + if err != nil { + return nil, fmt.Errorf("converting scene id: %w", err) + } + + var segments []*models.SceneSegment + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + segments, err = r.repository.SceneSegment.FindBySceneID(ctx, sid) + return err + }); err != nil { + return nil, err + } + + return segments, nil +} diff --git a/pkg/models/model_scene_segment.go b/pkg/models/model_scene_segment.go new file mode 100644 index 0000000000..c56062ef66 --- /dev/null +++ b/pkg/models/model_scene_segment.go @@ -0,0 +1,51 @@ +package models + +import ( + "fmt" + "time" +) + +type SceneSegment struct { + ID int `json:"id"` + SceneID int `json:"scene_id"` + Title string `json:"title"` + StartSeconds float64 `json:"start_seconds"` + EndSeconds float64 `json:"end_seconds"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type SceneSegmentPartial struct { + ID int + SceneID OptionalInt + Title OptionalString + StartSeconds OptionalFloat64 + EndSeconds OptionalFloat64 + CreatedAt OptionalTime + UpdatedAt OptionalTime +} + +func NewSceneSegment() SceneSegment { + currentTime := time.Now() + return SceneSegment{ + CreatedAt: currentTime, + UpdatedAt: currentTime, + } +} + +func (s *SceneSegment) LoadRelationships(r SceneSegmentReader) error { + return nil +} + +func (s *SceneSegment) Validate() error { + if s.Title == "" { + return fmt.Errorf("title is required") + } + if s.StartSeconds < 0 { + return fmt.Errorf("start_seconds must be >= 0") + } + if s.EndSeconds <= s.StartSeconds { + return fmt.Errorf("end_seconds must be > start_seconds") + } + return nil +} diff --git a/pkg/models/repository.go b/pkg/models/repository.go index 9bd1e8cad4..c75256e124 100644 --- a/pkg/models/repository.go +++ b/pkg/models/repository.go @@ -24,6 +24,7 @@ type Repository struct { Performer PerformerReaderWriter Scene SceneReaderWriter SceneMarker SceneMarkerReaderWriter + SceneSegment SceneSegmentReaderWriter Studio StudioReaderWriter Tag TagReaderWriter SavedFilter SavedFilterReaderWriter diff --git a/pkg/models/repository_scene_segment.go b/pkg/models/repository_scene_segment.go new file mode 100644 index 0000000000..569b7c0432 --- /dev/null +++ b/pkg/models/repository_scene_segment.go @@ -0,0 +1,37 @@ +package models + +import "context" + +type SceneSegmentReader interface { + Find(ctx context.Context, id int) (*SceneSegment, error) + FindMany(ctx context.Context, ids []int) ([]*SceneSegment, error) + FindBySceneID(ctx context.Context, sceneID int) ([]*SceneSegment, error) + All(ctx context.Context) ([]*SceneSegment, error) +} + +type SceneSegmentWriter interface { + Create(ctx context.Context, newSegment *SceneSegment) error + UpdatePartial(ctx context.Context, id int, updatedSegment SceneSegmentPartial) (*SceneSegment, error) + Destroy(ctx context.Context, id int) error +} + +type SceneSegmentCreator interface { + Create(ctx context.Context, newSegment *SceneSegment) error +} + +type SceneSegmentUpdater interface { + UpdatePartial(ctx context.Context, id int, updatedSegment SceneSegmentPartial) (*SceneSegment, error) +} + +type SceneSegmentDestroyer interface { + Destroy(ctx context.Context, id int) error +} + +type SceneSegmentFinder interface { + SceneSegmentReader +} + +type SceneSegmentReaderWriter interface { + SceneSegmentReader + SceneSegmentWriter +} diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 0ea3d71700..c90bd8d263 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 75 +var appSchemaVersion uint = 76 //go:embed migrations/*.sql var migrationsBox embed.FS @@ -74,6 +74,7 @@ type storeRepository struct { GalleryChapter *GalleryChapterStore Scene *SceneStore SceneMarker *SceneMarkerStore + SceneSegment *SceneSegmentStore Performer *PerformerStore SavedFilter *SavedFilterStore Studio *StudioStore @@ -109,6 +110,7 @@ func NewDatabase() *Database { Folder: folderStore, Scene: NewSceneStore(r, blobStore), SceneMarker: NewSceneMarkerStore(), + SceneSegment: NewSceneSegmentStore(), Image: NewImageStore(r), Gallery: galleryStore, GalleryChapter: NewGalleryChapterStore(), diff --git a/pkg/sqlite/migrations/76_scene_segments.down.sql b/pkg/sqlite/migrations/76_scene_segments.down.sql new file mode 100644 index 0000000000..1b0abbcf64 --- /dev/null +++ b/pkg/sqlite/migrations/76_scene_segments.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `scene_segments`; diff --git a/pkg/sqlite/migrations/76_scene_segments.up.sql b/pkg/sqlite/migrations/76_scene_segments.up.sql new file mode 100644 index 0000000000..9bca5cc558 --- /dev/null +++ b/pkg/sqlite/migrations/76_scene_segments.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE `scene_segments` ( + `id` integer not null primary key autoincrement, + `scene_id` integer not null, + `title` varchar(255) not null, + `start_seconds` float not null, + `end_seconds` float not null, + `created_at` datetime not null, + `updated_at` datetime not null, + foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE +); + +CREATE INDEX `scene_segments_scene_id` on `scene_segments` (`scene_id`); diff --git a/pkg/sqlite/scene_segment.go b/pkg/sqlite/scene_segment.go new file mode 100644 index 0000000000..1610ef558b --- /dev/null +++ b/pkg/sqlite/scene_segment.go @@ -0,0 +1,249 @@ +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" + + "github.com/stashapp/stash/pkg/models" +) + +const ( + sceneSegmentTable = "scene_segments" + sceneSegmentIDColumn = "scene_segment_id" +) + +type sceneSegmentRow struct { + ID int `db:"id" goqu:"skipinsert"` + SceneID int `db:"scene_id"` + Title string `db:"title"` + StartSeconds float64 `db:"start_seconds"` + EndSeconds float64 `db:"end_seconds"` + CreatedAt Timestamp `db:"created_at"` + UpdatedAt Timestamp `db:"updated_at"` +} + +func (r *sceneSegmentRow) fromSceneSegment(o models.SceneSegment) { + r.ID = o.ID + r.SceneID = o.SceneID + r.Title = o.Title + r.StartSeconds = o.StartSeconds + r.EndSeconds = o.EndSeconds + r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} + r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} +} + +func (r *sceneSegmentRow) resolve() *models.SceneSegment { + ret := &models.SceneSegment{ + ID: r.ID, + SceneID: r.SceneID, + Title: r.Title, + StartSeconds: r.StartSeconds, + EndSeconds: r.EndSeconds, + CreatedAt: r.CreatedAt.Timestamp, + UpdatedAt: r.UpdatedAt.Timestamp, + } + + return ret +} + +type sceneSegmentRowRecord struct { + updateRecord +} + +func (r *sceneSegmentRowRecord) fromPartial(o models.SceneSegmentPartial) { + r.setInt("scene_id", o.SceneID) + r.setString("title", o.Title) + r.setFloat64("start_seconds", o.StartSeconds) + r.setFloat64("end_seconds", o.EndSeconds) + r.setTimestamp("created_at", o.CreatedAt) + r.setTimestamp("updated_at", o.UpdatedAt) +} + +type sceneSegmentRepositoryType struct { + repository + scenes repository +} + +var ( + sceneSegmentRepository = sceneSegmentRepositoryType{ + repository: repository{ + tableName: sceneSegmentTable, + idColumn: idColumn, + }, + scenes: repository{ + tableName: sceneTable, + idColumn: idColumn, + }, + } +) + +type SceneSegmentStore struct{} + +func NewSceneSegmentStore() *SceneSegmentStore { + return &SceneSegmentStore{} +} + +func (qb *SceneSegmentStore) table() exp.IdentifierExpression { + return sceneSegmentTableMgr.table +} + +func (qb *SceneSegmentStore) selectDataset() *goqu.SelectDataset { + return dialect.From(qb.table()).Select(qb.table().All()) +} + +func (qb *SceneSegmentStore) Create(ctx context.Context, newObject *models.SceneSegment) error { + var r sceneSegmentRow + r.fromSceneSegment(*newObject) + + id, err := sceneSegmentTableMgr.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 *SceneSegmentStore) UpdatePartial(ctx context.Context, id int, partial models.SceneSegmentPartial) (*models.SceneSegment, error) { + r := sceneSegmentRowRecord{ + updateRecord{ + Record: make(exp.Record), + }, + } + + r.fromPartial(partial) + + if len(r.Record) > 0 { + if err := sceneSegmentTableMgr.updateByID(ctx, id, r.Record); err != nil { + return nil, err + } + } + + return qb.find(ctx, id) +} + +func (qb *SceneSegmentStore) Update(ctx context.Context, updatedObject *models.SceneSegment) error { + var r sceneSegmentRow + r.fromSceneSegment(*updatedObject) + + if err := sceneSegmentTableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { + return err + } + + return nil +} + +func (qb *SceneSegmentStore) Destroy(ctx context.Context, id int) error { + return sceneSegmentRepository.destroyExisting(ctx, []int{id}) +} + +// returns nil, nil if not found +func (qb *SceneSegmentStore) Find(ctx context.Context, id int) (*models.SceneSegment, error) { + ret, err := qb.find(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return ret, err +} + +func (qb *SceneSegmentStore) FindMany(ctx context.Context, ids []int) ([]*models.SceneSegment, error) { + ret := make([]*models.SceneSegment, 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 segment with id %d not found", ids[i]) + } + } + + return ret, nil +} + +// returns nil, sql.ErrNoRows if not found +func (qb *SceneSegmentStore) find(ctx context.Context, id int) (*models.SceneSegment, error) { + q := qb.selectDataset().Where(sceneSegmentTableMgr.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 *SceneSegmentStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.SceneSegment, 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 *SceneSegmentStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.SceneSegment, error) { + const single = false + var ret []*models.SceneSegment + if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { + var f sceneSegmentRow + 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 *SceneSegmentStore) FindBySceneID(ctx context.Context, sceneID int) ([]*models.SceneSegment, error) { + table := qb.table() + q := qb.selectDataset(). + Prepared(true). + Where(table.Col("scene_id").Eq(sceneID)). + Order(table.Col("start_seconds").Asc()) + + return qb.getMany(ctx, q) +} + +func (qb *SceneSegmentStore) Count(ctx context.Context) (int, error) { + q := dialect.Select(goqu.COUNT("*")).From(qb.table()) + return count(ctx, q) +} + +func (qb *SceneSegmentStore) All(ctx context.Context) ([]*models.SceneSegment, error) { + return qb.getMany(ctx, qb.selectDataset()) +} diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 7cddf25ccc..184f27bfcd 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -164,6 +164,11 @@ var ( idColumn: goqu.T(sceneMarkerTable).Col(idColumn), } + sceneSegmentTableMgr = &table{ + table: goqu.T(sceneSegmentTable), + idColumn: goqu.T(sceneSegmentTable).Col(idColumn), + } + sceneMarkersTagsTableMgr = &joinTable{ table: table{ table: sceneMarkersTagsJoinTable, diff --git a/ui/v2.5/graphql/data/scene-segment.graphql b/ui/v2.5/graphql/data/scene-segment.graphql new file mode 100644 index 0000000000..c70014b1b3 --- /dev/null +++ b/ui/v2.5/graphql/data/scene-segment.graphql @@ -0,0 +1,14 @@ +fragment SceneSegmentData on SceneSegment { + id + title + scene_id + start_seconds + end_seconds + created_at + updated_at + + scene { + id + title + } +} diff --git a/ui/v2.5/graphql/mutations/scene-segment.graphql b/ui/v2.5/graphql/mutations/scene-segment.graphql new file mode 100644 index 0000000000..ab74bafaf4 --- /dev/null +++ b/ui/v2.5/graphql/mutations/scene-segment.graphql @@ -0,0 +1,15 @@ +mutation SceneSegmentCreate($input: SceneSegmentCreateInput!) { + sceneSegmentCreate(input: $input) { + ...SceneSegmentData + } +} + +mutation SceneSegmentUpdate($input: SceneSegmentUpdateInput!) { + sceneSegmentUpdate(input: $input) { + ...SceneSegmentData + } +} + +mutation SceneSegmentDestroy($id: ID!) { + sceneSegmentDestroy(id: $id) +} diff --git a/ui/v2.5/graphql/queries/scene-segment.graphql b/ui/v2.5/graphql/queries/scene-segment.graphql new file mode 100644 index 0000000000..2c810e94cd --- /dev/null +++ b/ui/v2.5/graphql/queries/scene-segment.graphql @@ -0,0 +1,11 @@ +query FindSceneSegments($scene_id: ID!) { + findSceneSegments(scene_id: $scene_id) { + ...SceneSegmentData + } +} + +query FindSceneSegment($id: ID!) { + findSceneSegment(id: $id) { + ...SceneSegmentData + } +} diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentForm.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentForm.tsx new file mode 100644 index 0000000000..90fbcf47a8 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentForm.tsx @@ -0,0 +1,237 @@ +import React, { useMemo } from "react"; +import { Button, Form } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useFormik } from "formik"; +import * as yup from "yup"; +import * as GQL from "src/core/generated-graphql"; +import { + useSceneSegmentCreate, + useSceneSegmentUpdate, + useSceneSegmentDestroy, +} from "src/core/StashService"; +import { DurationInput } from "src/components/Shared/DurationInput"; +import { getPlayerPosition } from "src/components/ScenePlayer/util"; +import { useToast } from "src/hooks/Toast"; +import isEqual from "lodash-es/isEqual"; +import { formikUtils } from "src/utils/form"; +import { yupFormikValidate } from "src/utils/yup"; + +interface ISceneSegmentForm { + sceneID: string; + segment?: GQL.SceneSegmentDataFragment; + onClose: () => void; +} + +export const SceneSegmentForm: React.FC = ({ + sceneID, + segment, + onClose, +}) => { + const intl = useIntl(); + + const [sceneSegmentCreate] = useSceneSegmentCreate(); + const [sceneSegmentUpdate] = useSceneSegmentUpdate(); + const [sceneSegmentDestroy] = useSceneSegmentDestroy(); + const Toast = useToast(); + + const isNew = segment === undefined; + + const schema = yup.object({ + title: yup.string().required("Title is required"), + start_seconds: yup.number().min(0, "Start time must be >= 0").required(), + end_seconds: yup + .number() + .min(0, "End time must be >= 0") + .required() + .test( + "is-greater-than-start", + "End time must be greater than start time", + function (value) { + return value > this.parent.start_seconds; + } + ), + }); + + const initialValues = useMemo( + () => ({ + title: segment?.title ?? "", + start_seconds: + segment?.start_seconds ?? Math.round(getPlayerPosition() ?? 0), + end_seconds: + segment?.end_seconds ?? Math.round(getPlayerPosition() ?? 0) + 60, + }), + [segment] + ); + + type InputValues = yup.InferType; + + const formik = useFormik({ + initialValues, + enableReinitialize: true, + validate: yupFormikValidate(schema), + onSubmit: (values) => onSave(schema.cast(values)), + }); + + async function onSave(input: InputValues) { + try { + if (isNew) { + await sceneSegmentCreate({ + variables: { + input: { + scene_id: sceneID, + ...input, + }, + }, + }); + } else { + await sceneSegmentUpdate({ + variables: { + input: { + id: segment.id, + ...input, + }, + }, + }); + } + } catch (e) { + Toast.error(e); + } finally { + onClose(); + } + } + + async function onDelete() { + if (isNew) return; + + try { + await sceneSegmentDestroy({ variables: { id: segment.id } }); + } catch (e) { + Toast.error(e); + } finally { + onClose(); + } + } + + const splitProps = { + labelProps: { + column: true, + sm: 3, + }, + fieldProps: { + sm: 9, + }, + }; + + const { renderField } = formikUtils(intl, formik, splitProps); + + function renderTitleField() { + const title = intl.formatMessage({ id: "title" }); + const control = ( + + ); + + return renderField("title", title, control); + } + + function renderStartTimeField() { + const { error } = formik.getFieldMeta("start_seconds"); + + const title = intl.formatMessage({ + id: "time_start", + defaultMessage: "Start Time", + }); + const control = ( + formik.setFieldValue("start_seconds", v)} + onReset={() => + formik.setFieldValue("start_seconds", getPlayerPosition() ?? 0) + } + error={error} + /> + ); + + return renderField("start_seconds", title, control); + } + + function renderEndTimeField() { + const { error } = formik.getFieldMeta("end_seconds"); + + const title = intl.formatMessage({ id: "time_end" }); + const control = ( + <> + formik.setFieldValue("end_seconds", v)} + onReset={() => + formik.setFieldValue("end_seconds", getPlayerPosition() ?? 0) + } + error={error} + /> + {formik.touched.end_seconds && formik.errors.end_seconds && ( + + {formik.errors.end_seconds} + + )} + + ); + + return renderField("end_seconds", title, control); + } + + return ( +
+
+

+ {isNew ? ( + + ) : ( + + )} +

+ {renderTitleField()} + {renderStartTimeField()} + {renderEndTimeField()} +
+
+
+ + + {!isNew && ( + + )} +
+
+
+ ); +}; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentsPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentsPanel.tsx new file mode 100644 index 0000000000..31d88936a4 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneSegmentsPanel.tsx @@ -0,0 +1,126 @@ +import React, { useState, useEffect } from "react"; +import { Button } from "react-bootstrap"; +import { FormattedMessage } from "react-intl"; +import Mousetrap from "mousetrap"; +import * as GQL from "src/core/generated-graphql"; +import { SceneSegmentForm } from "./SceneSegmentForm"; + +interface ISceneSegmentsPanelProps { + sceneId: string; + isVisible: boolean; +} + +export const SceneSegmentsPanel: React.FC = ({ + sceneId, + isVisible, +}) => { + const { data, loading, refetch } = GQL.useFindSceneSegmentsQuery({ + variables: { scene_id: sceneId }, + }); + const [isEditorOpen, setIsEditorOpen] = useState(false); + const [editingSegment, setEditingSegment] = + useState(); + + // set up hotkeys + useEffect(() => { + if (!isVisible) return; + + Mousetrap.bind("s", () => onOpenEditor()); + + return () => { + Mousetrap.unbind("s"); + }; + }); + + if (loading) return null; + + function onOpenEditor(segment?: GQL.SceneSegmentDataFragment) { + setIsEditorOpen(true); + setEditingSegment(segment ?? undefined); + } + + const closeEditor = () => { + setEditingSegment(undefined); + setIsEditorOpen(false); + refetch(); + }; + + if (isEditorOpen) + return ( + + ); + + const segments = data?.findSceneSegments ?? []; + + return ( +
+

+ +

+ + + {segments.length === 0 ? ( +
+ +
+ ) : ( +
+ + + + + + + + + + + + {segments.map((segment) => { + const duration = segment.end_seconds - segment.start_seconds; + return ( + + + + + + + + ); + })} + +
TitleStartEndDurationActions
{segment.title}{formatTime(segment.start_seconds)}{formatTime(segment.end_seconds)}{formatTime(duration)} + +
+
+ )} +
+ ); +}; + +function formatTime(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; +} + +export default SceneSegmentsPanel; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index c4f3d4732e..576b5145b4 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -1556,6 +1556,22 @@ export const useSceneMarkersDestroy = ( }, }); +// Scene Segment mutations +export const useSceneSegmentCreate = () => + GQL.useSceneSegmentCreateMutation({ + refetchQueries: [GQL.FindSceneSegmentsDocument], + }); + +export const useSceneSegmentUpdate = () => + GQL.useSceneSegmentUpdateMutation({ + refetchQueries: [GQL.FindSceneSegmentsDocument], + }); + +export const useSceneSegmentDestroy = () => + GQL.useSceneSegmentDestroyMutation({ + refetchQueries: [GQL.FindSceneSegmentsDocument], + }); + const galleryMutationImpactedTypeFields = { Scene: ["galleries"], Performer: ["gallery_count", "performer_count"],