mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 04:14:39 +03:00
Decouple galleries from scenes (#1057)
This commit is contained in:
@@ -21,7 +21,7 @@ fragment GallerySlimData on Gallery {
|
||||
performers {
|
||||
...PerformerData
|
||||
}
|
||||
scene {
|
||||
scenes {
|
||||
id
|
||||
title
|
||||
path
|
||||
|
||||
@@ -24,9 +24,7 @@ fragment GalleryData on Gallery {
|
||||
performers {
|
||||
...PerformerData
|
||||
}
|
||||
scene {
|
||||
id
|
||||
title
|
||||
path
|
||||
scenes {
|
||||
...SceneData
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ fragment SlimSceneData on Scene {
|
||||
seconds
|
||||
}
|
||||
|
||||
gallery {
|
||||
galleries {
|
||||
id
|
||||
path
|
||||
title
|
||||
|
||||
@@ -35,8 +35,8 @@ fragment SceneData on Scene {
|
||||
...SceneMarkerData
|
||||
}
|
||||
|
||||
gallery {
|
||||
...GalleryData
|
||||
galleries {
|
||||
...GallerySlimData
|
||||
}
|
||||
|
||||
studio {
|
||||
|
||||
@@ -36,14 +36,6 @@ query AllTagsForFilter {
|
||||
}
|
||||
}
|
||||
|
||||
query ValidGalleriesForScene($scene_id: ID!) {
|
||||
validGalleriesForScene(scene_id: $scene_id) {
|
||||
id
|
||||
path
|
||||
title
|
||||
}
|
||||
}
|
||||
|
||||
query Stats {
|
||||
stats {
|
||||
scene_count,
|
||||
|
||||
@@ -45,7 +45,7 @@ query ParseSceneFilenames($filter: FindFilterType!, $config: SceneParserInput!)
|
||||
date
|
||||
rating
|
||||
studio_id
|
||||
gallery_id
|
||||
gallery_ids
|
||||
movies {
|
||||
movie_id
|
||||
}
|
||||
|
||||
@@ -50,8 +50,6 @@ type Query {
|
||||
|
||||
"""Get marker strings"""
|
||||
markerStrings(q: String, sort: String): [MarkerStringsResultType]!
|
||||
"""Get the list of valid galleries for a given scene ID"""
|
||||
validGalleriesForScene(scene_id: ID): [Gallery!]!
|
||||
"""Get stats"""
|
||||
stats: StatsResultType!
|
||||
"""Organize scene markers by tag for a given scene ID"""
|
||||
|
||||
@@ -9,7 +9,7 @@ type Gallery {
|
||||
details: String
|
||||
rating: Int
|
||||
organized: Boolean!
|
||||
scene: Scene
|
||||
scenes: [Scene!]!
|
||||
studio: Studio
|
||||
image_count: Int!
|
||||
tags: [Tag!]!
|
||||
@@ -33,7 +33,7 @@ input GalleryCreateInput {
|
||||
details: String
|
||||
rating: Int
|
||||
organized: Boolean
|
||||
scene_id: ID
|
||||
scene_ids: [ID!]
|
||||
studio_id: ID
|
||||
tag_ids: [ID!]
|
||||
performer_ids: [ID!]
|
||||
@@ -48,7 +48,7 @@ input GalleryUpdateInput {
|
||||
details: String
|
||||
rating: Int
|
||||
organized: Boolean
|
||||
scene_id: ID
|
||||
scene_ids: [ID!]
|
||||
studio_id: ID
|
||||
tag_ids: [ID!]
|
||||
performer_ids: [ID!]
|
||||
@@ -62,7 +62,7 @@ input BulkGalleryUpdateInput {
|
||||
details: String
|
||||
rating: Int
|
||||
organized: Boolean
|
||||
scene_id: ID
|
||||
scene_ids: BulkUpdateIds
|
||||
studio_id: ID
|
||||
tag_ids: BulkUpdateIds
|
||||
performer_ids: BulkUpdateIds
|
||||
|
||||
@@ -40,7 +40,7 @@ type Scene {
|
||||
paths: ScenePathsType! # Resolver
|
||||
|
||||
scene_markers: [SceneMarker!]!
|
||||
gallery: Gallery
|
||||
galleries: [Gallery!]!
|
||||
studio: Studio
|
||||
movies: [SceneMovie!]!
|
||||
tags: [Tag!]!
|
||||
@@ -63,7 +63,7 @@ input SceneUpdateInput {
|
||||
rating: Int
|
||||
organized: Boolean
|
||||
studio_id: ID
|
||||
gallery_id: ID
|
||||
gallery_ids: [ID!]
|
||||
performer_ids: [ID!]
|
||||
movies: [SceneMovieInput!]
|
||||
tag_ids: [ID!]
|
||||
@@ -93,7 +93,7 @@ input BulkSceneUpdateInput {
|
||||
rating: Int
|
||||
organized: Boolean
|
||||
studio_id: ID
|
||||
gallery_id: ID
|
||||
gallery_ids: BulkUpdateIds
|
||||
performer_ids: BulkUpdateIds
|
||||
tag_ids: BulkUpdateIds
|
||||
}
|
||||
@@ -134,7 +134,7 @@ type SceneParserResult {
|
||||
date: String
|
||||
rating: Int
|
||||
studio_id: ID
|
||||
gallery_id: ID
|
||||
gallery_ids: [ID!]
|
||||
performer_ids: [ID!]
|
||||
movies: [SceneMovieID!]
|
||||
tag_ids: [ID!]
|
||||
|
||||
@@ -120,41 +120,6 @@ func (r *queryResolver) MarkerStrings(ctx context.Context, q *string, sort *stri
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *queryResolver) ValidGalleriesForScene(ctx context.Context, scene_id *string) ([]*models.Gallery, error) {
|
||||
if scene_id == nil {
|
||||
panic("nil scene id") // TODO make scene_id mandatory
|
||||
}
|
||||
sceneID, err := strconv.Atoi(*scene_id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var validGalleries []*models.Gallery
|
||||
var sceneGallery *models.Gallery
|
||||
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
|
||||
sqb := repo.Scene()
|
||||
scene, err := sqb.Find(sceneID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
qb := repo.Gallery()
|
||||
validGalleries, err = qb.ValidGalleriesForScenePath(scene.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sceneGallery, err = qb.FindBySceneID(sceneID)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if sceneGallery != nil {
|
||||
validGalleries = append(validGalleries, sceneGallery)
|
||||
}
|
||||
return validGalleries, nil
|
||||
}
|
||||
|
||||
func (r *queryResolver) Stats(ctx context.Context) (*models.StatsResultType, error) {
|
||||
var ret models.StatsResultType
|
||||
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
|
||||
|
||||
@@ -90,15 +90,10 @@ func (r *galleryResolver) Rating(ctx context.Context, obj *models.Gallery) (*int
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *galleryResolver) Scene(ctx context.Context, obj *models.Gallery) (ret *models.Scene, err error) {
|
||||
if !obj.SceneID.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *galleryResolver) Scenes(ctx context.Context, obj *models.Gallery) (ret []*models.Scene, err error) {
|
||||
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
|
||||
var err error
|
||||
ret, err = repo.Scene().Find(int(obj.SceneID.Int64))
|
||||
|
||||
ret, err = repo.Scene().FindByGalleryID(obj.ID)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -105,7 +105,7 @@ func (r *sceneResolver) SceneMarkers(ctx context.Context, obj *models.Scene) (re
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *sceneResolver) Gallery(ctx context.Context, obj *models.Scene) (ret *models.Gallery, err error) {
|
||||
func (r *sceneResolver) Galleries(ctx context.Context, obj *models.Scene) (ret []*models.Gallery, err error) {
|
||||
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
|
||||
ret, err = repo.Gallery().FindBySceneID(obj.ID)
|
||||
return err
|
||||
|
||||
@@ -60,14 +60,6 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input models.Galle
|
||||
newGallery.StudioID = sql.NullInt64{Valid: false}
|
||||
}
|
||||
|
||||
if input.SceneID != nil {
|
||||
sceneID, _ := strconv.ParseInt(*input.SceneID, 10, 64)
|
||||
newGallery.SceneID = sql.NullInt64{Int64: sceneID, Valid: true}
|
||||
} else {
|
||||
// studio must be nullable
|
||||
newGallery.SceneID = sql.NullInt64{Valid: false}
|
||||
}
|
||||
|
||||
// Start the transaction and save the gallery
|
||||
var gallery *models.Gallery
|
||||
if err := r.withTxn(ctx, func(repo models.Repository) error {
|
||||
@@ -88,6 +80,11 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input models.Galle
|
||||
return err
|
||||
}
|
||||
|
||||
// Save the scenes
|
||||
if err := r.updateGalleryScenes(qb, gallery.ID, input.SceneIds); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
@@ -112,6 +109,14 @@ func (r *mutationResolver) updateGalleryTags(qb models.GalleryReaderWriter, gall
|
||||
return qb.UpdateTags(galleryID, ids)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) updateGalleryScenes(qb models.GalleryReaderWriter, galleryID int, sceneIDs []string) error {
|
||||
ids, err := utils.StringSliceToIntSlice(sceneIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return qb.UpdateScenes(galleryID, ids)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) GalleryUpdate(ctx context.Context, input models.GalleryUpdateInput) (ret *models.Gallery, err error) {
|
||||
translator := changesetTranslator{
|
||||
inputMap: getUpdateInputMap(ctx),
|
||||
@@ -221,6 +226,13 @@ func (r *mutationResolver) galleryUpdate(input models.GalleryUpdateInput, transl
|
||||
}
|
||||
}
|
||||
|
||||
// Save the scenes
|
||||
if translator.hasField("scene_ids") {
|
||||
if err := r.updateGalleryScenes(qb, galleryID, input.SceneIds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return gallery, nil
|
||||
}
|
||||
|
||||
@@ -241,7 +253,6 @@ func (r *mutationResolver) BulkGalleryUpdate(ctx context.Context, input models.B
|
||||
updatedGallery.Date = translator.sqliteDate(input.Date, "date")
|
||||
updatedGallery.Rating = translator.nullInt64(input.Rating, "rating")
|
||||
updatedGallery.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id")
|
||||
updatedGallery.SceneID = translator.nullInt64FromString(input.SceneID, "scene_id")
|
||||
updatedGallery.Organized = input.Organized
|
||||
|
||||
ret := []*models.Gallery{}
|
||||
@@ -284,6 +295,18 @@ func (r *mutationResolver) BulkGalleryUpdate(ctx context.Context, input models.B
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Save the scenes
|
||||
if translator.hasField("scene_ids") {
|
||||
sceneIDs, err := adjustGallerySceneIDs(qb, galleryID, *input.SceneIds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := qb.UpdateScenes(galleryID, sceneIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -312,6 +335,15 @@ func adjustGalleryTagIDs(qb models.GalleryReader, galleryID int, ids models.Bulk
|
||||
return adjustIDs(ret, ids), nil
|
||||
}
|
||||
|
||||
func adjustGallerySceneIDs(qb models.GalleryReader, galleryID int, ids models.BulkUpdateIds) (ret []int, err error) {
|
||||
ret, err = qb.GetSceneIDs(galleryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return adjustIDs(ret, ids), nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.GalleryDestroyInput) (bool, error) {
|
||||
galleryIDs, err := utils.StringSliceToIntSlice(input.Ids)
|
||||
if err != nil {
|
||||
|
||||
@@ -101,29 +101,6 @@ func (r *mutationResolver) sceneUpdate(input models.SceneUpdateInput, translator
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the existing gallery value
|
||||
if translator.hasField("gallery_id") {
|
||||
gqb := repo.Gallery()
|
||||
err = gqb.ClearGalleryId(sceneID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if input.GalleryID != nil {
|
||||
// Save the gallery
|
||||
galleryID, _ := strconv.Atoi(*input.GalleryID)
|
||||
updatedGallery := models.GalleryPartial{
|
||||
ID: galleryID,
|
||||
SceneID: &sql.NullInt64{Int64: int64(sceneID), Valid: true},
|
||||
UpdatedAt: &models.SQLiteTimestamp{Timestamp: updatedTime},
|
||||
}
|
||||
_, err := gqb.UpdatePartial(updatedGallery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save the performers
|
||||
if translator.hasField("performer_ids") {
|
||||
if err := r.updateScenePerformers(qb, sceneID, input.PerformerIds); err != nil {
|
||||
@@ -145,6 +122,13 @@ func (r *mutationResolver) sceneUpdate(input models.SceneUpdateInput, translator
|
||||
}
|
||||
}
|
||||
|
||||
// Save the galleries
|
||||
if translator.hasField("gallery_ids") {
|
||||
if err := r.updateSceneGalleries(qb, sceneID, input.GalleryIds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Save the stash_ids
|
||||
if translator.hasField("stash_ids") {
|
||||
stashIDJoins := models.StashIDsFromInput(input.StashIds)
|
||||
@@ -206,6 +190,14 @@ func (r *mutationResolver) updateSceneTags(qb models.SceneReaderWriter, sceneID
|
||||
return qb.UpdateTags(sceneID, ids)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) updateSceneGalleries(qb models.SceneReaderWriter, sceneID int, galleryIDs []string) error {
|
||||
ids, err := utils.StringSliceToIntSlice(galleryIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return qb.UpdateGalleries(sceneID, ids)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input models.BulkSceneUpdateInput) ([]*models.Scene, error) {
|
||||
sceneIDs, err := utils.StringSliceToIntSlice(input.Ids)
|
||||
if err != nil {
|
||||
@@ -236,7 +228,6 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input models.Bul
|
||||
// Start the transaction and save the scene marker
|
||||
if err := r.withTxn(ctx, func(repo models.Repository) error {
|
||||
qb := repo.Scene()
|
||||
gqb := repo.Gallery()
|
||||
|
||||
for _, sceneID := range sceneIDs {
|
||||
updatedScene.ID = sceneID
|
||||
@@ -248,20 +239,6 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input models.Bul
|
||||
|
||||
ret = append(ret, scene)
|
||||
|
||||
if translator.hasField("gallery_id") {
|
||||
// Save the gallery
|
||||
galleryID, _ := strconv.Atoi(*input.GalleryID)
|
||||
updatedGallery := models.GalleryPartial{
|
||||
ID: galleryID,
|
||||
SceneID: &sql.NullInt64{Int64: int64(sceneID), Valid: true},
|
||||
UpdatedAt: &models.SQLiteTimestamp{Timestamp: updatedTime},
|
||||
}
|
||||
|
||||
if _, err := gqb.UpdatePartial(updatedGallery); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Save the performers
|
||||
if translator.hasField("performer_ids") {
|
||||
performerIDs, err := adjustScenePerformerIDs(qb, sceneID, *input.PerformerIds)
|
||||
@@ -285,6 +262,18 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input models.Bul
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Save the galleries
|
||||
if translator.hasField("gallery_ids") {
|
||||
galleryIDs, err := adjustSceneGalleryIDs(qb, sceneID, *input.GalleryIds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := qb.UpdateGalleries(sceneID, galleryIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -350,6 +339,15 @@ func adjustSceneTagIDs(qb models.SceneReader, sceneID int, ids models.BulkUpdate
|
||||
return adjustIDs(ret, ids), nil
|
||||
}
|
||||
|
||||
func adjustSceneGalleryIDs(qb models.SceneReader, sceneID int, ids models.BulkUpdateIds) (ret []int, err error) {
|
||||
ret, err = qb.GetGalleryIDs(sceneID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return adjustIDs(ret, ids), nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneDestroyInput) (bool, error) {
|
||||
sceneID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
var DB *sqlx.DB
|
||||
var dbPath string
|
||||
var appSchemaVersion uint = 17
|
||||
var appSchemaVersion uint = 18
|
||||
var databaseSchemaVersion uint
|
||||
|
||||
const sqlite3Driver = "sqlite3ex"
|
||||
|
||||
138
pkg/database/migrations/18_scene_galleries.up.sql
Normal file
138
pkg/database/migrations/18_scene_galleries.up.sql
Normal file
@@ -0,0 +1,138 @@
|
||||
-- recreate the tables referencing galleries to correct their references
|
||||
ALTER TABLE `galleries` rename to `_galleries_old`;
|
||||
ALTER TABLE `galleries_images` rename to `_galleries_images_old`;
|
||||
ALTER TABLE `galleries_tags` rename to `_galleries_tags_old`;
|
||||
ALTER TABLE `performers_galleries` rename to `_performers_galleries_old`;
|
||||
|
||||
CREATE TABLE `galleries` (
|
||||
`id` integer not null primary key autoincrement,
|
||||
`path` varchar(510),
|
||||
`checksum` varchar(255) not null,
|
||||
`zip` boolean not null default '0',
|
||||
`title` varchar(255),
|
||||
`url` varchar(255),
|
||||
`date` date,
|
||||
`details` text,
|
||||
`studio_id` integer,
|
||||
`rating` tinyint,
|
||||
`file_mod_time` datetime,
|
||||
`organized` boolean not null default '0',
|
||||
`created_at` datetime not null,
|
||||
`updated_at` datetime not null,
|
||||
foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL
|
||||
);
|
||||
|
||||
DROP INDEX IF EXISTS `index_galleries_on_scene_id`;
|
||||
DROP INDEX IF EXISTS `galleries_path_unique`;
|
||||
DROP INDEX IF EXISTS `galleries_checksum_unique`;
|
||||
DROP INDEX IF EXISTS `index_galleries_on_studio_id`;
|
||||
|
||||
CREATE UNIQUE INDEX `galleries_path_unique` on `galleries` (`path`);
|
||||
CREATE UNIQUE INDEX `galleries_checksum_unique` on `galleries` (`checksum`);
|
||||
CREATE INDEX `index_galleries_on_studio_id` on `galleries` (`studio_id`);
|
||||
|
||||
CREATE TABLE `scenes_galleries` (
|
||||
`scene_id` integer,
|
||||
`gallery_id` integer,
|
||||
foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE,
|
||||
foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX `index_scenes_galleries_on_scene_id` on `scenes_galleries` (`scene_id`);
|
||||
CREATE INDEX `index_scenes_galleries_on_gallery_id` on `scenes_galleries` (`gallery_id`);
|
||||
|
||||
CREATE TABLE `galleries_images` (
|
||||
`gallery_id` integer,
|
||||
`image_id` integer,
|
||||
foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE,
|
||||
foreign key(`image_id`) references `images`(`id`) on delete CASCADE
|
||||
);
|
||||
|
||||
DROP INDEX IF EXISTS `index_galleries_images_on_image_id`;
|
||||
DROP INDEX IF EXISTS `index_galleries_images_on_gallery_id`;
|
||||
|
||||
CREATE INDEX `index_galleries_images_on_image_id` on `galleries_images` (`image_id`);
|
||||
CREATE INDEX `index_galleries_images_on_gallery_id` on `galleries_images` (`gallery_id`);
|
||||
|
||||
CREATE TABLE `performers_galleries` (
|
||||
`performer_id` integer,
|
||||
`gallery_id` integer,
|
||||
foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE,
|
||||
foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE
|
||||
);
|
||||
|
||||
DROP INDEX IF EXISTS `index_performers_galleries_on_gallery_id`;
|
||||
DROP INDEX IF EXISTS `index_performers_galleries_on_performer_id`;
|
||||
|
||||
CREATE INDEX `index_performers_galleries_on_gallery_id` on `performers_galleries` (`gallery_id`);
|
||||
CREATE INDEX `index_performers_galleries_on_performer_id` on `performers_galleries` (`performer_id`);
|
||||
|
||||
CREATE TABLE `galleries_tags` (
|
||||
`gallery_id` integer,
|
||||
`tag_id` integer,
|
||||
foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE,
|
||||
foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE
|
||||
);
|
||||
|
||||
DROP INDEX IF EXISTS `index_galleries_tags_on_tag_id`;
|
||||
DROP INDEX IF EXISTS `index_galleries_tags_on_gallery_id`;
|
||||
|
||||
CREATE INDEX `index_galleries_tags_on_tag_id` on `galleries_tags` (`tag_id`);
|
||||
CREATE INDEX `index_galleries_tags_on_gallery_id` on `galleries_tags` (`gallery_id`);
|
||||
|
||||
-- populate from the old tables
|
||||
INSERT INTO `galleries`
|
||||
(
|
||||
`id`,
|
||||
`path`,
|
||||
`checksum`,
|
||||
`zip`,
|
||||
`title`,
|
||||
`url`,
|
||||
`date`,
|
||||
`details`,
|
||||
`studio_id`,
|
||||
`rating`,
|
||||
`file_mod_time`,
|
||||
`organized`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
)
|
||||
SELECT
|
||||
`id`,
|
||||
`path`,
|
||||
`checksum`,
|
||||
`zip`,
|
||||
`title`,
|
||||
`url`,
|
||||
`date`,
|
||||
`details`,
|
||||
`studio_id`,
|
||||
`rating`,
|
||||
`file_mod_time`,
|
||||
`organized`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
FROM `_galleries_old`;
|
||||
|
||||
INSERT INTO `scenes_galleries`
|
||||
(
|
||||
`scene_id`,
|
||||
`gallery_id`
|
||||
)
|
||||
SELECT
|
||||
`scene_id`,
|
||||
`id`
|
||||
FROM `_galleries_old`
|
||||
WHERE scene_id IS NOT NULL;
|
||||
|
||||
-- these tables are a direct copy
|
||||
INSERT INTO `galleries_images` SELECT * from `_galleries_images_old`;
|
||||
INSERT INTO `galleries_tags` SELECT * from `_galleries_tags_old`;
|
||||
INSERT INTO `performers_galleries` SELECT * from `_performers_galleries_old`;
|
||||
|
||||
-- drop old tables
|
||||
DROP TABLE `_galleries_old`;
|
||||
DROP TABLE `_galleries_images_old`;
|
||||
DROP TABLE `_galleries_tags_old`;
|
||||
DROP TABLE `_performers_galleries_old`;
|
||||
@@ -74,3 +74,14 @@ func GetIDs(galleries []*models.Gallery) []int {
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func GetChecksums(galleries []*models.Gallery) []string {
|
||||
var results []string
|
||||
for _, gallery := range galleries {
|
||||
if gallery.Checksum != "" {
|
||||
results = append(results, gallery.Checksum)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ type Scene struct {
|
||||
Organized bool `json:"organized,omitempty"`
|
||||
OCounter int `json:"o_counter,omitempty"`
|
||||
Details string `json:"details,omitempty"`
|
||||
Gallery string `json:"gallery,omitempty"`
|
||||
Galleries []string `json:"galleries,omitempty"`
|
||||
Performers []string `json:"performers,omitempty"`
|
||||
Movies []SceneMovie `json:"movies,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
|
||||
@@ -17,11 +17,6 @@ import (
|
||||
func DestroyScene(scene *models.Scene, repo models.Repository) (func(), error) {
|
||||
qb := repo.Scene()
|
||||
mqb := repo.SceneMarker()
|
||||
gqb := repo.Gallery()
|
||||
|
||||
if err := gqb.ClearGalleryId(scene.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
markers, err := mqb.FindBySceneID(scene.ID)
|
||||
if err != nil {
|
||||
|
||||
@@ -382,15 +382,13 @@ func exportScene(wg *sync.WaitGroup, jobChan <-chan *models.Scene, repo models.R
|
||||
continue
|
||||
}
|
||||
|
||||
sceneGallery, err := galleryReader.FindBySceneID(s.ID)
|
||||
galleries, err := galleryReader.FindBySceneID(s.ID)
|
||||
if err != nil {
|
||||
logger.Errorf("[scenes] <%s> error getting scene gallery: %s", sceneHash, err.Error())
|
||||
logger.Errorf("[scenes] <%s> error getting scene gallery checksums: %s", sceneHash, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
if sceneGallery != nil {
|
||||
newSceneJSON.Gallery = sceneGallery.Checksum
|
||||
}
|
||||
newSceneJSON.Galleries = gallery.GetChecksums(galleries)
|
||||
|
||||
performers, err := performerReader.FindBySceneID(s.ID)
|
||||
if err != nil {
|
||||
@@ -423,9 +421,7 @@ func exportScene(wg *sync.WaitGroup, jobChan <-chan *models.Scene, repo models.R
|
||||
t.studios.IDs = utils.IntAppendUnique(t.studios.IDs, int(s.StudioID.Int64))
|
||||
}
|
||||
|
||||
if sceneGallery != nil {
|
||||
t.galleries.IDs = utils.IntAppendUnique(t.galleries.IDs, sceneGallery.ID)
|
||||
}
|
||||
t.galleries.IDs = utils.IntAppendUniques(t.galleries.IDs, gallery.GetIDs(galleries))
|
||||
|
||||
tagIDs, err := scene.GetDependentTagIDs(tagReader, sceneMarkerReader, s)
|
||||
if err != nil {
|
||||
|
||||
@@ -300,8 +300,6 @@ func (t *ScanTask) associateGallery(wg *sizedwaitgroup.SizedWaitGroup) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// gallery has no SceneID
|
||||
if !g.SceneID.Valid {
|
||||
basename := strings.TrimSuffix(t.FilePath, filepath.Ext(t.FilePath))
|
||||
var relatedFiles []string
|
||||
vExt := config.GetVideoExtensions()
|
||||
@@ -314,26 +312,13 @@ func (t *ScanTask) associateGallery(wg *sizedwaitgroup.SizedWaitGroup) {
|
||||
}
|
||||
}
|
||||
for _, scenePath := range relatedFiles {
|
||||
s, err := sqb.FindByPath(scenePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scene, _ := sqb.FindByPath(scenePath)
|
||||
// found related Scene
|
||||
if s != nil {
|
||||
logger.Infof("associate: Gallery %s is related to scene: %d", t.FilePath, s.ID)
|
||||
if scene != nil {
|
||||
logger.Infof("associate: Gallery %s is related to scene: %d", t.FilePath, scene.ID)
|
||||
|
||||
g.SceneID.Int64 = int64(s.ID)
|
||||
g.SceneID.Valid = true
|
||||
|
||||
_, err = qb.Update(*g)
|
||||
if err != nil {
|
||||
return fmt.Errorf("associate: Error updating gallery sceneId %s", err)
|
||||
}
|
||||
|
||||
// since a gallery can have only one related scene
|
||||
// only first found is associated
|
||||
break
|
||||
if err := sqb.UpdateGalleries(scene.ID, []int{g.ID}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,16 @@ type GalleryReader interface {
|
||||
Find(id int) (*Gallery, error)
|
||||
FindMany(ids []int) ([]*Gallery, error)
|
||||
FindByChecksum(checksum string) (*Gallery, error)
|
||||
FindByChecksums(checksums []string) ([]*Gallery, error)
|
||||
FindByPath(path string) (*Gallery, error)
|
||||
FindBySceneID(sceneID int) (*Gallery, error)
|
||||
FindBySceneID(sceneID int) ([]*Gallery, error)
|
||||
FindByImageID(imageID int) ([]*Gallery, error)
|
||||
ValidGalleriesForScenePath(scenePath string) ([]*Gallery, error)
|
||||
Count() (int, error)
|
||||
All() ([]*Gallery, error)
|
||||
Query(galleryFilter *GalleryFilterType, findFilter *FindFilterType) ([]*Gallery, int, error)
|
||||
GetPerformerIDs(galleryID int) ([]int, error)
|
||||
GetTagIDs(galleryID int) ([]int, error)
|
||||
GetSceneIDs(galleryID int) ([]int, error)
|
||||
GetImageIDs(galleryID int) ([]int, error)
|
||||
}
|
||||
|
||||
@@ -22,9 +23,9 @@ type GalleryWriter interface {
|
||||
UpdatePartial(updatedGallery GalleryPartial) (*Gallery, error)
|
||||
UpdateFileModTime(id int, modTime NullSQLiteTimestamp) error
|
||||
Destroy(id int) error
|
||||
ClearGalleryId(sceneID int) error
|
||||
UpdatePerformers(galleryID int, performerIDs []int) error
|
||||
UpdateTags(galleryID int, tagIDs []int) error
|
||||
UpdateScenes(galleryID int, sceneIDs []int) error
|
||||
UpdateImages(galleryID int, imageIDs []int) error
|
||||
}
|
||||
|
||||
|
||||
@@ -35,20 +35,6 @@ func (_m *GalleryReaderWriter) All() ([]*models.Gallery, error) {
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// ClearGalleryId provides a mock function with given fields: sceneID
|
||||
func (_m *GalleryReaderWriter) ClearGalleryId(sceneID int) error {
|
||||
ret := _m.Called(sceneID)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(int) error); ok {
|
||||
r0 = rf(sceneID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Count provides a mock function with given fields:
|
||||
func (_m *GalleryReaderWriter) Count() (int, error) {
|
||||
ret := _m.Called()
|
||||
@@ -153,6 +139,29 @@ func (_m *GalleryReaderWriter) FindByChecksum(checksum string) (*models.Gallery,
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindByChecksums provides a mock function with given fields: checksums
|
||||
func (_m *GalleryReaderWriter) FindByChecksums(checksums []string) ([]*models.Gallery, error) {
|
||||
ret := _m.Called(checksums)
|
||||
|
||||
var r0 []*models.Gallery
|
||||
if rf, ok := ret.Get(0).(func([]string) []*models.Gallery); ok {
|
||||
r0 = rf(checksums)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*models.Gallery)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func([]string) error); ok {
|
||||
r1 = rf(checksums)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindByImageID provides a mock function with given fields: imageID
|
||||
func (_m *GalleryReaderWriter) FindByImageID(imageID int) ([]*models.Gallery, error) {
|
||||
ret := _m.Called(imageID)
|
||||
@@ -200,15 +209,15 @@ func (_m *GalleryReaderWriter) FindByPath(path string) (*models.Gallery, error)
|
||||
}
|
||||
|
||||
// FindBySceneID provides a mock function with given fields: sceneID
|
||||
func (_m *GalleryReaderWriter) FindBySceneID(sceneID int) (*models.Gallery, error) {
|
||||
func (_m *GalleryReaderWriter) FindBySceneID(sceneID int) ([]*models.Gallery, error) {
|
||||
ret := _m.Called(sceneID)
|
||||
|
||||
var r0 *models.Gallery
|
||||
if rf, ok := ret.Get(0).(func(int) *models.Gallery); ok {
|
||||
var r0 []*models.Gallery
|
||||
if rf, ok := ret.Get(0).(func(int) []*models.Gallery); ok {
|
||||
r0 = rf(sceneID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*models.Gallery)
|
||||
r0 = ret.Get(0).([]*models.Gallery)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,6 +323,29 @@ func (_m *GalleryReaderWriter) GetTagIDs(galleryID int) ([]int, error) {
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetSceneIDs provides a mock function with given fields: galleryID
|
||||
func (_m *GalleryReaderWriter) GetSceneIDs(galleryID int) ([]int, error) {
|
||||
ret := _m.Called(galleryID)
|
||||
|
||||
var r0 []int
|
||||
if rf, ok := ret.Get(0).(func(int) []int); ok {
|
||||
r0 = rf(galleryID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]int)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(int) error); ok {
|
||||
r1 = rf(galleryID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Query provides a mock function with given fields: galleryFilter, findFilter
|
||||
func (_m *GalleryReaderWriter) Query(galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) ([]*models.Gallery, int, error) {
|
||||
ret := _m.Called(galleryFilter, findFilter)
|
||||
@@ -446,25 +478,16 @@ func (_m *GalleryReaderWriter) UpdateTags(galleryID int, tagIDs []int) error {
|
||||
return r0
|
||||
}
|
||||
|
||||
// ValidGalleriesForScenePath provides a mock function with given fields: scenePath
|
||||
func (_m *GalleryReaderWriter) ValidGalleriesForScenePath(scenePath string) ([]*models.Gallery, error) {
|
||||
ret := _m.Called(scenePath)
|
||||
// UpdateScenes provides a mock function with given fields: galleryID, sceneIDs
|
||||
func (_m *GalleryReaderWriter) UpdateScenes(galleryID int, sceneIDs []int) error {
|
||||
ret := _m.Called(galleryID, sceneIDs)
|
||||
|
||||
var r0 []*models.Gallery
|
||||
if rf, ok := ret.Get(0).(func(string) []*models.Gallery); ok {
|
||||
r0 = rf(scenePath)
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(int, []int) error); ok {
|
||||
r0 = rf(galleryID, sceneIDs)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*models.Gallery)
|
||||
}
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(string) error); ok {
|
||||
r1 = rf(scenePath)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
return r0
|
||||
}
|
||||
|
||||
@@ -392,6 +392,29 @@ func (_m *SceneReaderWriter) FindByPerformerID(performerID int) ([]*models.Scene
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindByGalleryID provides a mock function with given fields: galleryID
|
||||
func (_m *SceneReaderWriter) FindByGalleryID(galleryID int) ([]*models.Scene, error) {
|
||||
ret := _m.Called(galleryID)
|
||||
|
||||
var r0 []*models.Scene
|
||||
if rf, ok := ret.Get(0).(func(int) []*models.Scene); ok {
|
||||
r0 = rf(galleryID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*models.Scene)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(int) error); ok {
|
||||
r1 = rf(galleryID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindMany provides a mock function with given fields: ids
|
||||
func (_m *SceneReaderWriter) FindMany(ids []int) ([]*models.Scene, error) {
|
||||
ret := _m.Called(ids)
|
||||
@@ -461,13 +484,13 @@ func (_m *SceneReaderWriter) GetMovies(sceneID int) ([]models.MoviesScenes, erro
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetPerformerIDs provides a mock function with given fields: imageID
|
||||
func (_m *SceneReaderWriter) GetPerformerIDs(imageID int) ([]int, error) {
|
||||
ret := _m.Called(imageID)
|
||||
// GetPerformerIDs provides a mock function with given fields: sceneID
|
||||
func (_m *SceneReaderWriter) GetPerformerIDs(sceneID int) ([]int, error) {
|
||||
ret := _m.Called(sceneID)
|
||||
|
||||
var r0 []int
|
||||
if rf, ok := ret.Get(0).(func(int) []int); ok {
|
||||
r0 = rf(imageID)
|
||||
r0 = rf(sceneID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]int)
|
||||
@@ -476,7 +499,30 @@ func (_m *SceneReaderWriter) GetPerformerIDs(imageID int) ([]int, error) {
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(int) error); ok {
|
||||
r1 = rf(imageID)
|
||||
r1 = rf(sceneID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetGalleryIDs provides a mock function with given fields: sceneID
|
||||
func (_m *SceneReaderWriter) GetGalleryIDs(sceneID int) ([]int, error) {
|
||||
ret := _m.Called(sceneID)
|
||||
|
||||
var r0 []int
|
||||
if rf, ok := ret.Get(0).(func(int) []int); ok {
|
||||
r0 = rf(sceneID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]int)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(int) error); ok {
|
||||
r1 = rf(sceneID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
@@ -778,6 +824,20 @@ func (_m *SceneReaderWriter) UpdatePerformers(sceneID int, performerIDs []int) e
|
||||
return r0
|
||||
}
|
||||
|
||||
// UpdateGalleries provides a mock function with given fields: sceneID, galleryIDs
|
||||
func (_m *SceneReaderWriter) UpdateGalleries(sceneID int, galleryIDs []int) error {
|
||||
ret := _m.Called(sceneID, galleryIDs)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(int, []int) error); ok {
|
||||
r0 = rf(sceneID, galleryIDs)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// UpdateStashIDs provides a mock function with given fields: sceneID, stashIDs
|
||||
func (_m *SceneReaderWriter) UpdateStashIDs(sceneID int, stashIDs []models.StashID) error {
|
||||
ret := _m.Called(sceneID, stashIDs)
|
||||
|
||||
@@ -16,7 +16,6 @@ type Gallery struct {
|
||||
Rating sql.NullInt64 `db:"rating" json:"rating"`
|
||||
Organized bool `db:"organized" json:"organized"`
|
||||
StudioID sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"`
|
||||
SceneID sql.NullInt64 `db:"scene_id,omitempty" json:"scene_id"`
|
||||
FileModTime NullSQLiteTimestamp `db:"file_mod_time" json:"file_mod_time"`
|
||||
CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"`
|
||||
UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"`
|
||||
@@ -35,7 +34,6 @@ type GalleryPartial struct {
|
||||
Rating *sql.NullInt64 `db:"rating" json:"rating"`
|
||||
Organized *bool `db:"organized" json:"organized"`
|
||||
StudioID *sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"`
|
||||
SceneID *sql.NullInt64 `db:"scene_id,omitempty" json:"scene_id"`
|
||||
FileModTime *NullSQLiteTimestamp `db:"file_mod_time" json:"file_mod_time"`
|
||||
CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"`
|
||||
UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"`
|
||||
|
||||
@@ -7,6 +7,7 @@ type SceneReader interface {
|
||||
FindByOSHash(oshash string) (*Scene, error)
|
||||
FindByPath(path string) (*Scene, error)
|
||||
FindByPerformerID(performerID int) ([]*Scene, error)
|
||||
FindByGalleryID(performerID int) ([]*Scene, error)
|
||||
CountByPerformerID(performerID int) (int, error)
|
||||
// FindByStudioID(studioID int) ([]*Scene, error)
|
||||
FindByMovieID(movieID int) ([]*Scene, error)
|
||||
@@ -25,9 +26,10 @@ type SceneReader interface {
|
||||
QueryByPathRegex(findFilter *FindFilterType) ([]*Scene, int, error)
|
||||
GetCover(sceneID int) ([]byte, error)
|
||||
GetMovies(sceneID int) ([]MoviesScenes, error)
|
||||
GetTagIDs(imageID int) ([]int, error)
|
||||
GetPerformerIDs(imageID int) ([]int, error)
|
||||
GetStashIDs(performerID int) ([]*StashID, error)
|
||||
GetTagIDs(sceneID int) ([]int, error)
|
||||
GetGalleryIDs(sceneID int) ([]int, error)
|
||||
GetPerformerIDs(sceneID int) ([]int, error)
|
||||
GetStashIDs(sceneID int) ([]*StashID, error)
|
||||
}
|
||||
|
||||
type SceneWriter interface {
|
||||
@@ -43,6 +45,7 @@ type SceneWriter interface {
|
||||
DestroyCover(sceneID int) error
|
||||
UpdatePerformers(sceneID int, performerIDs []int) error
|
||||
UpdateTags(sceneID int, tagIDs []int) error
|
||||
UpdateGalleries(sceneID int, galleryIDs []int) error
|
||||
UpdateMovies(sceneID int, movies []MoviesScenes) error
|
||||
UpdateStashIDs(sceneID int, stashIDs []StashID) error
|
||||
}
|
||||
|
||||
@@ -127,21 +127,6 @@ func GetStudioName(reader models.StudioReader, scene *models.Scene) (string, err
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// GetGalleryChecksum returns the checksum of the provided gallery. It returns an
|
||||
// empty string if there is no gallery assigned to the scene.
|
||||
func GetGalleryChecksum(reader models.GalleryReader, scene *models.Scene) (string, error) {
|
||||
gallery, err := reader.FindBySceneID(scene.ID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error getting scene gallery: %s", err.Error())
|
||||
}
|
||||
|
||||
if gallery != nil {
|
||||
return gallery.Checksum, nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// GetTagNames returns a slice of tag names corresponding to the provided
|
||||
// scene's tags.
|
||||
func GetTagNames(reader models.TagReader, scene *models.Scene) ([]string, error) {
|
||||
|
||||
@@ -307,33 +307,6 @@ var getGalleryChecksumScenarios = []stringTestScenario{
|
||||
},
|
||||
}
|
||||
|
||||
func TestGetGalleryChecksum(t *testing.T) {
|
||||
mockGalleryReader := &mocks.GalleryReaderWriter{}
|
||||
|
||||
galleryErr := errors.New("error getting gallery")
|
||||
|
||||
mockGalleryReader.On("FindBySceneID", sceneID).Return(&models.Gallery{
|
||||
Checksum: galleryChecksum,
|
||||
}, nil).Once()
|
||||
mockGalleryReader.On("FindBySceneID", noGalleryID).Return(nil, nil).Once()
|
||||
mockGalleryReader.On("FindBySceneID", errGalleryID).Return(nil, galleryErr).Once()
|
||||
|
||||
for i, s := range getGalleryChecksumScenarios {
|
||||
scene := s.input
|
||||
json, err := GetGalleryChecksum(mockGalleryReader, &scene)
|
||||
|
||||
if !s.err && err != nil {
|
||||
t.Errorf("[%d] unexpected error: %s", i, err.Error())
|
||||
} else if s.err && err == nil {
|
||||
t.Errorf("[%d] expected error not returned", i)
|
||||
} else {
|
||||
assert.Equal(t, s.expected, json, "[%d]", i)
|
||||
}
|
||||
}
|
||||
|
||||
mockGalleryReader.AssertExpectations(t)
|
||||
}
|
||||
|
||||
type stringSliceTestScenario struct {
|
||||
input models.Scene
|
||||
expected []string
|
||||
|
||||
@@ -25,7 +25,7 @@ type Importer struct {
|
||||
|
||||
ID int
|
||||
scene models.Scene
|
||||
gallery *models.Gallery
|
||||
galleries []*models.Gallery
|
||||
performers []*models.Performer
|
||||
movies []models.MoviesScenes
|
||||
tags []*models.Tag
|
||||
@@ -39,7 +39,7 @@ func (i *Importer) PreImport() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := i.populateGallery(); err != nil {
|
||||
if err := i.populateGalleries(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -174,25 +174,32 @@ func (i *Importer) createStudio(name string) (int, error) {
|
||||
return created.ID, nil
|
||||
}
|
||||
|
||||
func (i *Importer) populateGallery() error {
|
||||
if i.Input.Gallery != "" {
|
||||
gallery, err := i.GalleryWriter.FindByChecksum(i.Input.Gallery)
|
||||
func (i *Importer) populateGalleries() error {
|
||||
if len(i.Input.Galleries) > 0 {
|
||||
checksums := i.Input.Galleries
|
||||
galleries, err := i.GalleryWriter.FindByChecksums(checksums)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error finding gallery: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
if gallery == nil {
|
||||
var pluckedChecksums []string
|
||||
for _, gallery := range galleries {
|
||||
pluckedChecksums = append(pluckedChecksums, gallery.Checksum)
|
||||
}
|
||||
|
||||
missingGalleries := utils.StrFilter(checksums, func(checksum string) bool {
|
||||
return !utils.StrInclude(pluckedChecksums, checksum)
|
||||
})
|
||||
|
||||
if len(missingGalleries) > 0 {
|
||||
if i.MissingRefBehaviour == models.ImportMissingRefEnumFail {
|
||||
return fmt.Errorf("scene gallery '%s' not found", i.Input.Studio)
|
||||
return fmt.Errorf("scene galleries [%s] not found", strings.Join(missingGalleries, ", "))
|
||||
}
|
||||
|
||||
// we don't create galleries - just ignore
|
||||
if i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore || i.MissingRefBehaviour == models.ImportMissingRefEnumCreate {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
i.gallery = gallery
|
||||
}
|
||||
|
||||
i.galleries = galleries
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -333,11 +340,14 @@ func (i *Importer) PostImport(id int) error {
|
||||
}
|
||||
}
|
||||
|
||||
if i.gallery != nil {
|
||||
i.gallery.SceneID = sql.NullInt64{Int64: int64(id), Valid: true}
|
||||
_, err := i.GalleryWriter.Update(*i.gallery)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update gallery: %s", err.Error())
|
||||
if len(i.galleries) > 0 {
|
||||
var galleryIDs []int
|
||||
for _, gallery := range i.galleries {
|
||||
galleryIDs = append(galleryIDs, gallery.ID)
|
||||
}
|
||||
|
||||
if err := i.ReaderWriter.UpdateGalleries(id, galleryIDs); err != nil {
|
||||
return fmt.Errorf("failed to associate galleries: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ const (
|
||||
missingTagName = "missingTagName"
|
||||
|
||||
errPerformersID = 200
|
||||
errGalleriesID = 201
|
||||
|
||||
missingChecksum = "missingChecksum"
|
||||
missingOSHash = "missingOSHash"
|
||||
@@ -164,21 +165,28 @@ func TestImporterPreImportWithGallery(t *testing.T) {
|
||||
i := Importer{
|
||||
GalleryWriter: galleryReaderWriter,
|
||||
Path: path,
|
||||
MissingRefBehaviour: models.ImportMissingRefEnumFail,
|
||||
Input: jsonschema.Scene{
|
||||
Gallery: existingGalleryChecksum,
|
||||
Galleries: []string{
|
||||
existingGalleryChecksum,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
galleryReaderWriter.On("FindByChecksum", existingGalleryChecksum).Return(&models.Gallery{
|
||||
galleryReaderWriter.On("FindByChecksums", []string{existingGalleryChecksum}).Return([]*models.Gallery{
|
||||
{
|
||||
ID: existingGalleryID,
|
||||
Checksum: existingGalleryChecksum,
|
||||
},
|
||||
}, nil).Once()
|
||||
galleryReaderWriter.On("FindByChecksum", existingGalleryErr).Return(nil, errors.New("FindByChecksum error")).Once()
|
||||
|
||||
galleryReaderWriter.On("FindByChecksums", []string{existingGalleryErr}).Return(nil, errors.New("FindByChecksums error")).Once()
|
||||
|
||||
err := i.PreImport()
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, existingGalleryID, i.gallery.ID)
|
||||
assert.Equal(t, existingGalleryID, i.galleries[0].ID)
|
||||
|
||||
i.Input.Gallery = existingGalleryErr
|
||||
i.Input.Galleries = []string{existingGalleryErr}
|
||||
err = i.PreImport()
|
||||
assert.NotNil(t, err)
|
||||
|
||||
@@ -192,12 +200,14 @@ func TestImporterPreImportWithMissingGallery(t *testing.T) {
|
||||
Path: path,
|
||||
GalleryWriter: galleryReaderWriter,
|
||||
Input: jsonschema.Scene{
|
||||
Gallery: missingGalleryChecksum,
|
||||
Galleries: []string{
|
||||
missingGalleryChecksum,
|
||||
},
|
||||
},
|
||||
MissingRefBehaviour: models.ImportMissingRefEnumFail,
|
||||
}
|
||||
|
||||
galleryReaderWriter.On("FindByChecksum", missingGalleryChecksum).Return(nil, nil).Times(3)
|
||||
galleryReaderWriter.On("FindByChecksums", []string{missingGalleryChecksum}).Return(nil, nil).Times(3)
|
||||
|
||||
err := i.PreImport()
|
||||
assert.NotNil(t, err)
|
||||
@@ -205,12 +215,10 @@ func TestImporterPreImportWithMissingGallery(t *testing.T) {
|
||||
i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore
|
||||
err = i.PreImport()
|
||||
assert.Nil(t, err)
|
||||
assert.Nil(t, i.gallery)
|
||||
|
||||
i.MissingRefBehaviour = models.ImportMissingRefEnumCreate
|
||||
err = i.PreImport()
|
||||
assert.Nil(t, err)
|
||||
assert.Nil(t, i.gallery)
|
||||
|
||||
galleryReaderWriter.AssertExpectations(t)
|
||||
}
|
||||
@@ -506,33 +514,30 @@ func TestImporterPostImport(t *testing.T) {
|
||||
readerWriter.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestImporterPostImportUpdateGallery(t *testing.T) {
|
||||
galleryReaderWriter := &mocks.GalleryReaderWriter{}
|
||||
func TestImporterPostImportUpdateGalleries(t *testing.T) {
|
||||
sceneReaderWriter := &mocks.SceneReaderWriter{}
|
||||
|
||||
i := Importer{
|
||||
GalleryWriter: galleryReaderWriter,
|
||||
gallery: &models.Gallery{
|
||||
ReaderWriter: sceneReaderWriter,
|
||||
galleries: []*models.Gallery{
|
||||
{
|
||||
ID: existingGalleryID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
updateErr := errors.New("Update error")
|
||||
updateErr := errors.New("UpdateGalleries error")
|
||||
|
||||
updateArg := *i.gallery
|
||||
updateArg.SceneID = models.NullInt64(sceneID)
|
||||
|
||||
galleryReaderWriter.On("Update", updateArg).Return(nil, nil).Once()
|
||||
|
||||
updateArg.SceneID = models.NullInt64(errGalleryID)
|
||||
galleryReaderWriter.On("Update", updateArg).Return(nil, updateErr).Once()
|
||||
sceneReaderWriter.On("UpdateGalleries", sceneID, []int{existingGalleryID}).Return(nil).Once()
|
||||
sceneReaderWriter.On("UpdateGalleries", errGalleriesID, mock.AnythingOfType("[]int")).Return(updateErr).Once()
|
||||
|
||||
err := i.PostImport(sceneID)
|
||||
assert.Nil(t, err)
|
||||
|
||||
err = i.PostImport(errGalleryID)
|
||||
err = i.PostImport(errGalleriesID)
|
||||
assert.NotNil(t, err)
|
||||
|
||||
galleryReaderWriter.AssertExpectations(t)
|
||||
sceneReaderWriter.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestImporterPostImportUpdatePerformers(t *testing.T) {
|
||||
|
||||
@@ -83,3 +83,23 @@ func AddTag(qb models.SceneReaderWriter, id int, tagID int) (bool, error) {
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func AddGallery(qb models.SceneReaderWriter, id int, galleryID int) (bool, error) {
|
||||
galleryIDs, err := qb.GetGalleryIDs(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
oldLen := len(galleryIDs)
|
||||
galleryIDs = utils.IntAppendUnique(galleryIDs, galleryID)
|
||||
|
||||
if len(galleryIDs) != oldLen {
|
||||
if err := qb.UpdateGalleries(id, galleryIDs); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -146,34 +146,15 @@ type SceneFragment struct {
|
||||
Performers []*PerformerAppearanceFragment "json:\"performers\" graphql:\"performers\""
|
||||
Fingerprints []*FingerprintFragment "json:\"fingerprints\" graphql:\"fingerprints\""
|
||||
}
|
||||
type GalleryFragment struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Title *string "json:\"title\" graphql:\"title\""
|
||||
Details *string "json:\"details\" graphql:\"details\""
|
||||
Duration *int "json:\"duration\" graphql:\"duration\""
|
||||
Date *string "json:\"date\" graphql:\"date\""
|
||||
Urls []*URLFragment "json:\"urls\" graphql:\"urls\""
|
||||
Images []*ImageFragment "json:\"images\" graphql:\"images\""
|
||||
Studio *StudioFragment "json:\"studio\" graphql:\"studio\""
|
||||
Tags []*TagFragment "json:\"tags\" graphql:\"tags\""
|
||||
Performers []*PerformerAppearanceFragment "json:\"performers\" graphql:\"performers\""
|
||||
Fingerprints []*FingerprintFragment "json:\"fingerprints\" graphql:\"fingerprints\""
|
||||
}
|
||||
type FindSceneByFingerprint struct {
|
||||
FindSceneByFingerprint []*SceneFragment "json:\"findSceneByFingerprint\" graphql:\"findSceneByFingerprint\""
|
||||
}
|
||||
type FindScenesByFingerprints struct {
|
||||
FindScenesByFingerprints []*SceneFragment "json:\"findScenesByFingerprints\" graphql:\"findScenesByFingerprints\""
|
||||
}
|
||||
type FindGalleriesByFingerprints struct {
|
||||
FindGalleriesByFingerprints []*GalleryFragment `json:"findGalleriesByFingerprints" graphql:"findGalleriesByFingerprints"`
|
||||
}
|
||||
type SearchScene struct {
|
||||
SearchScene []*SceneFragment "json:\"searchScene\" graphql:\"searchScene\""
|
||||
}
|
||||
type SearchGallery struct {
|
||||
SearchGallery []*GalleryFragment `json:"searchScene" graphql:"searchScene"`
|
||||
}
|
||||
type SubmitFingerprintPayload struct {
|
||||
SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\""
|
||||
}
|
||||
@@ -208,9 +189,15 @@ fragment SceneFragment on Scene {
|
||||
... FingerprintFragment
|
||||
}
|
||||
}
|
||||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
fragment URLFragment on URL {
|
||||
url
|
||||
type
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
performer {
|
||||
... PerformerFragment
|
||||
}
|
||||
}
|
||||
fragment PerformerFragment on Performer {
|
||||
id
|
||||
@@ -245,25 +232,15 @@ fragment PerformerFragment on Performer {
|
||||
... BodyModificationFragment
|
||||
}
|
||||
}
|
||||
fragment BodyModificationFragment on BodyModification {
|
||||
location
|
||||
description
|
||||
}
|
||||
fragment MeasurementsFragment on Measurements {
|
||||
band_size
|
||||
cup_size
|
||||
waist
|
||||
hip
|
||||
fragment FuzzyDateFragment on FuzzyDate {
|
||||
date
|
||||
accuracy
|
||||
}
|
||||
fragment FingerprintFragment on Fingerprint {
|
||||
algorithm
|
||||
hash
|
||||
duration
|
||||
}
|
||||
fragment URLFragment on URL {
|
||||
url
|
||||
type
|
||||
}
|
||||
fragment ImageFragment on Image {
|
||||
id
|
||||
url
|
||||
@@ -280,15 +257,19 @@ fragment StudioFragment on Studio {
|
||||
... ImageFragment
|
||||
}
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
performer {
|
||||
... PerformerFragment
|
||||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
}
|
||||
fragment MeasurementsFragment on Measurements {
|
||||
band_size
|
||||
cup_size
|
||||
waist
|
||||
hip
|
||||
}
|
||||
fragment FuzzyDateFragment on FuzzyDate {
|
||||
date
|
||||
accuracy
|
||||
fragment BodyModificationFragment on BodyModification {
|
||||
location
|
||||
description
|
||||
}
|
||||
`
|
||||
|
||||
@@ -310,61 +291,6 @@ const FindScenesByFingerprintsQuery = `query FindScenesByFingerprints ($fingerpr
|
||||
... SceneFragment
|
||||
}
|
||||
}
|
||||
fragment ImageFragment on Image {
|
||||
id
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
fragment StudioFragment on Studio {
|
||||
name
|
||||
id
|
||||
urls {
|
||||
... URLFragment
|
||||
}
|
||||
images {
|
||||
... ImageFragment
|
||||
}
|
||||
}
|
||||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
}
|
||||
fragment MeasurementsFragment on Measurements {
|
||||
band_size
|
||||
cup_size
|
||||
waist
|
||||
hip
|
||||
}
|
||||
fragment BodyModificationFragment on BodyModification {
|
||||
location
|
||||
description
|
||||
}
|
||||
fragment SceneFragment on Scene {
|
||||
id
|
||||
title
|
||||
details
|
||||
duration
|
||||
date
|
||||
urls {
|
||||
... URLFragment
|
||||
}
|
||||
images {
|
||||
... ImageFragment
|
||||
}
|
||||
studio {
|
||||
... StudioFragment
|
||||
}
|
||||
tags {
|
||||
... TagFragment
|
||||
}
|
||||
performers {
|
||||
... PerformerAppearanceFragment
|
||||
}
|
||||
fingerprints {
|
||||
... FingerprintFragment
|
||||
}
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
performer {
|
||||
@@ -404,19 +330,74 @@ fragment PerformerFragment on Performer {
|
||||
... BodyModificationFragment
|
||||
}
|
||||
}
|
||||
fragment FuzzyDateFragment on FuzzyDate {
|
||||
date
|
||||
accuracy
|
||||
fragment MeasurementsFragment on Measurements {
|
||||
band_size
|
||||
cup_size
|
||||
waist
|
||||
hip
|
||||
}
|
||||
fragment FingerprintFragment on Fingerprint {
|
||||
algorithm
|
||||
hash
|
||||
duration
|
||||
}
|
||||
fragment SceneFragment on Scene {
|
||||
id
|
||||
title
|
||||
details
|
||||
duration
|
||||
date
|
||||
urls {
|
||||
... URLFragment
|
||||
}
|
||||
images {
|
||||
... ImageFragment
|
||||
}
|
||||
studio {
|
||||
... StudioFragment
|
||||
}
|
||||
tags {
|
||||
... TagFragment
|
||||
}
|
||||
performers {
|
||||
... PerformerAppearanceFragment
|
||||
}
|
||||
fingerprints {
|
||||
... FingerprintFragment
|
||||
}
|
||||
}
|
||||
fragment URLFragment on URL {
|
||||
url
|
||||
type
|
||||
}
|
||||
fragment ImageFragment on Image {
|
||||
id
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
}
|
||||
fragment StudioFragment on Studio {
|
||||
name
|
||||
id
|
||||
urls {
|
||||
... URLFragment
|
||||
}
|
||||
images {
|
||||
... ImageFragment
|
||||
}
|
||||
}
|
||||
fragment FuzzyDateFragment on FuzzyDate {
|
||||
date
|
||||
accuracy
|
||||
}
|
||||
fragment BodyModificationFragment on BodyModification {
|
||||
location
|
||||
description
|
||||
}
|
||||
`
|
||||
|
||||
func (c *Client) FindScenesByFingerprints(ctx context.Context, fingerprints []string, httpRequestOptions ...client.HTTPRequestOption) (*FindScenesByFingerprints, error) {
|
||||
@@ -437,6 +418,53 @@ const SearchSceneQuery = `query SearchScene ($term: String!) {
|
||||
... SceneFragment
|
||||
}
|
||||
}
|
||||
fragment URLFragment on URL {
|
||||
url
|
||||
type
|
||||
}
|
||||
fragment ImageFragment on Image {
|
||||
id
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
}
|
||||
fragment PerformerFragment on Performer {
|
||||
id
|
||||
name
|
||||
disambiguation
|
||||
aliases
|
||||
gender
|
||||
urls {
|
||||
... URLFragment
|
||||
}
|
||||
images {
|
||||
... ImageFragment
|
||||
}
|
||||
birthdate {
|
||||
... FuzzyDateFragment
|
||||
}
|
||||
ethnicity
|
||||
country
|
||||
eye_color
|
||||
hair_color
|
||||
height
|
||||
measurements {
|
||||
... MeasurementsFragment
|
||||
}
|
||||
breast_type
|
||||
career_start_year
|
||||
career_end_year
|
||||
tattoos {
|
||||
... BodyModificationFragment
|
||||
}
|
||||
piercings {
|
||||
... BodyModificationFragment
|
||||
}
|
||||
}
|
||||
fragment FuzzyDateFragment on FuzzyDate {
|
||||
date
|
||||
accuracy
|
||||
@@ -447,11 +475,6 @@ fragment MeasurementsFragment on Measurements {
|
||||
waist
|
||||
hip
|
||||
}
|
||||
fragment FingerprintFragment on Fingerprint {
|
||||
algorithm
|
||||
hash
|
||||
duration
|
||||
}
|
||||
fragment SceneFragment on Scene {
|
||||
id
|
||||
title
|
||||
@@ -477,59 +500,6 @@ fragment SceneFragment on Scene {
|
||||
... FingerprintFragment
|
||||
}
|
||||
}
|
||||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
performer {
|
||||
... PerformerFragment
|
||||
}
|
||||
}
|
||||
fragment PerformerFragment on Performer {
|
||||
id
|
||||
name
|
||||
disambiguation
|
||||
aliases
|
||||
gender
|
||||
urls {
|
||||
... URLFragment
|
||||
}
|
||||
images {
|
||||
... ImageFragment
|
||||
}
|
||||
birthdate {
|
||||
... FuzzyDateFragment
|
||||
}
|
||||
ethnicity
|
||||
country
|
||||
eye_color
|
||||
hair_color
|
||||
height
|
||||
measurements {
|
||||
... MeasurementsFragment
|
||||
}
|
||||
breast_type
|
||||
career_start_year
|
||||
career_end_year
|
||||
tattoos {
|
||||
... BodyModificationFragment
|
||||
}
|
||||
piercings {
|
||||
... BodyModificationFragment
|
||||
}
|
||||
}
|
||||
fragment URLFragment on URL {
|
||||
url
|
||||
type
|
||||
}
|
||||
fragment ImageFragment on Image {
|
||||
id
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
fragment StudioFragment on Studio {
|
||||
name
|
||||
id
|
||||
@@ -540,137 +510,21 @@ fragment StudioFragment on Studio {
|
||||
... ImageFragment
|
||||
}
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
performer {
|
||||
... PerformerFragment
|
||||
}
|
||||
}
|
||||
fragment BodyModificationFragment on BodyModification {
|
||||
location
|
||||
description
|
||||
}
|
||||
`
|
||||
|
||||
func (c *Client) FindGalleriesByFingerprints(ctx context.Context, fingerprints []string, httpRequestOptions ...client.HTTPRequestOption) (*FindGalleriesByFingerprints, error) {
|
||||
vars := map[string]interface{}{
|
||||
"fingerprints": fingerprints,
|
||||
}
|
||||
|
||||
var res FindGalleriesByFingerprints
|
||||
if err := c.Client.Post(ctx, FindScenesByFingerprintsQuery, &res, vars, httpRequestOptions...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
const SearchGalleryQuery = `query SearchGallery ($term: String!) {
|
||||
searchGallery(term: $term) {
|
||||
... GalleryFragment
|
||||
}
|
||||
}
|
||||
fragment FuzzyDateFragment on FuzzyDate {
|
||||
date
|
||||
accuracy
|
||||
}
|
||||
fragment MeasurementsFragment on Measurements {
|
||||
band_size
|
||||
cup_size
|
||||
waist
|
||||
hip
|
||||
}
|
||||
fragment FingerprintFragment on Fingerprint {
|
||||
algorithm
|
||||
hash
|
||||
duration
|
||||
}
|
||||
fragment GalleryFragment on Gallery {
|
||||
id
|
||||
title
|
||||
details
|
||||
duration
|
||||
date
|
||||
urls {
|
||||
... URLFragment
|
||||
}
|
||||
images {
|
||||
... ImageFragment
|
||||
}
|
||||
studio {
|
||||
... StudioFragment
|
||||
}
|
||||
tags {
|
||||
... TagFragment
|
||||
}
|
||||
performers {
|
||||
... PerformerAppearanceFragment
|
||||
}
|
||||
fingerprints {
|
||||
... FingerprintFragment
|
||||
}
|
||||
}
|
||||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
}
|
||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||
as
|
||||
performer {
|
||||
... PerformerFragment
|
||||
}
|
||||
}
|
||||
fragment PerformerFragment on Performer {
|
||||
id
|
||||
name
|
||||
disambiguation
|
||||
aliases
|
||||
gender
|
||||
urls {
|
||||
... URLFragment
|
||||
}
|
||||
images {
|
||||
... ImageFragment
|
||||
}
|
||||
birthdate {
|
||||
... FuzzyDateFragment
|
||||
}
|
||||
ethnicity
|
||||
country
|
||||
eye_color
|
||||
hair_color
|
||||
height
|
||||
measurements {
|
||||
... MeasurementsFragment
|
||||
}
|
||||
breast_type
|
||||
career_start_year
|
||||
career_end_year
|
||||
tattoos {
|
||||
... BodyModificationFragment
|
||||
}
|
||||
piercings {
|
||||
... BodyModificationFragment
|
||||
}
|
||||
}
|
||||
fragment URLFragment on URL {
|
||||
url
|
||||
type
|
||||
}
|
||||
fragment ImageFragment on Image {
|
||||
id
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
fragment StudioFragment on Studio {
|
||||
name
|
||||
id
|
||||
urls {
|
||||
... URLFragment
|
||||
}
|
||||
images {
|
||||
... ImageFragment
|
||||
}
|
||||
}
|
||||
fragment BodyModificationFragment on BodyModification {
|
||||
location
|
||||
description
|
||||
}
|
||||
`
|
||||
|
||||
func (c *Client) SearchScene(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchScene, error) {
|
||||
@@ -686,19 +540,6 @@ func (c *Client) SearchScene(ctx context.Context, term string, httpRequestOption
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
func (c *Client) SearchGallery(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchGallery, error) {
|
||||
vars := map[string]interface{}{
|
||||
"term": term,
|
||||
}
|
||||
|
||||
var res SearchGallery
|
||||
if err := c.Client.Post(ctx, SearchGalleryQuery, &res, vars, httpRequestOptions...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
const SubmitFingerprintQuery = `mutation SubmitFingerprint ($input: FingerprintSubmission!) {
|
||||
submitFingerprint(input: $input)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package sqlite
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
@@ -14,6 +13,7 @@ const galleryTable = "galleries"
|
||||
const performersGalleriesTable = "performers_galleries"
|
||||
const galleriesTagsTable = "galleries_tags"
|
||||
const galleriesImagesTable = "galleries_images"
|
||||
const galleriesScenesTable = "scenes_galleries"
|
||||
const galleryIDColumn = "gallery_id"
|
||||
|
||||
type galleryQueryBuilder struct {
|
||||
@@ -73,23 +73,6 @@ func (qb *galleryQueryBuilder) Destroy(id int) error {
|
||||
return qb.destroyExisting([]int{id})
|
||||
}
|
||||
|
||||
type GalleryNullSceneID struct {
|
||||
SceneID sql.NullInt64
|
||||
}
|
||||
|
||||
func (qb *galleryQueryBuilder) ClearGalleryId(sceneID int) error {
|
||||
_, err := qb.tx.NamedExec(
|
||||
`UPDATE galleries SET scene_id = null WHERE scene_id = :sceneid`,
|
||||
GalleryNullSceneID{
|
||||
SceneID: sql.NullInt64{
|
||||
Int64: int64(sceneID),
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (qb *galleryQueryBuilder) Find(id int) (*models.Gallery, error) {
|
||||
var ret models.Gallery
|
||||
if err := qb.get(id, &ret); err != nil {
|
||||
@@ -125,22 +108,29 @@ func (qb *galleryQueryBuilder) FindByChecksum(checksum string) (*models.Gallery,
|
||||
return qb.queryGallery(query, args)
|
||||
}
|
||||
|
||||
func (qb *galleryQueryBuilder) FindByChecksums(checksums []string) ([]*models.Gallery, error) {
|
||||
query := "SELECT * FROM galleries WHERE checksum IN " + getInBinding(len(checksums))
|
||||
var args []interface{}
|
||||
for _, checksum := range checksums {
|
||||
args = append(args, checksum)
|
||||
}
|
||||
return qb.queryGalleries(query, args)
|
||||
}
|
||||
|
||||
func (qb *galleryQueryBuilder) FindByPath(path string) (*models.Gallery, error) {
|
||||
query := "SELECT * FROM galleries WHERE path = ? LIMIT 1"
|
||||
args := []interface{}{path}
|
||||
return qb.queryGallery(query, args)
|
||||
}
|
||||
|
||||
func (qb *galleryQueryBuilder) FindBySceneID(sceneID int) (*models.Gallery, error) {
|
||||
query := "SELECT galleries.* FROM galleries WHERE galleries.scene_id = ? LIMIT 1"
|
||||
func (qb *galleryQueryBuilder) FindBySceneID(sceneID int) ([]*models.Gallery, error) {
|
||||
query := selectAll(galleryTable) + `
|
||||
LEFT JOIN scenes_galleries as scenes_join on scenes_join.gallery_id = galleries.id
|
||||
WHERE scenes_join.scene_id = ?
|
||||
GROUP BY galleries.id
|
||||
`
|
||||
args := []interface{}{sceneID}
|
||||
return qb.queryGallery(query, args)
|
||||
}
|
||||
|
||||
func (qb *galleryQueryBuilder) ValidGalleriesForScenePath(scenePath string) ([]*models.Gallery, error) {
|
||||
sceneDirPath := filepath.Dir(scenePath)
|
||||
query := "SELECT galleries.* FROM galleries WHERE galleries.scene_id IS NULL AND galleries.path LIKE '" + sceneDirPath + "%' ORDER BY path ASC"
|
||||
return qb.queryGalleries(query, nil)
|
||||
return qb.queryGalleries(query, args)
|
||||
}
|
||||
|
||||
func (qb *galleryQueryBuilder) FindByImageID(imageID int) ([]*models.Gallery, error) {
|
||||
@@ -182,6 +172,7 @@ func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, fi
|
||||
query.body = selectDistinctIDs("galleries")
|
||||
query.body += `
|
||||
left join performers_galleries as performers_join on performers_join.gallery_id = galleries.id
|
||||
left join scenes_galleries as scenes_join on scenes_join.gallery_id = galleries.id
|
||||
left join studios as studio on studio.id = galleries.studio_id
|
||||
left join galleries_tags as tags_join on tags_join.gallery_id = galleries.id
|
||||
left join galleries_images as images_join on images_join.gallery_id = galleries.id
|
||||
@@ -189,7 +180,7 @@ func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, fi
|
||||
`
|
||||
|
||||
if q := findFilter.Q; q != nil && *q != "" {
|
||||
searchColumns := []string{"galleries.path", "galleries.checksum"}
|
||||
searchColumns := []string{"galleries.title", "galleries.path", "galleries.checksum"}
|
||||
clause, thisArgs := getSearchBinding(searchColumns, *q, false)
|
||||
query.addWhere(clause)
|
||||
query.addArg(thisArgs...)
|
||||
@@ -221,8 +212,8 @@ func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, fi
|
||||
|
||||
if isMissingFilter := galleryFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" {
|
||||
switch *isMissingFilter {
|
||||
case "scene":
|
||||
query.addWhere("galleries.scene_id IS NULL")
|
||||
case "scenes":
|
||||
query.addWhere("scenes_join.gallery_id IS NULL")
|
||||
case "studio":
|
||||
query.addWhere("galleries.studio_id IS NULL")
|
||||
case "performers":
|
||||
@@ -442,3 +433,23 @@ func (qb *galleryQueryBuilder) UpdateImages(galleryID int, imageIDs []int) error
|
||||
// Delete the existing joins and then create new ones
|
||||
return qb.imagesRepository().replace(galleryID, imageIDs)
|
||||
}
|
||||
|
||||
func (qb *galleryQueryBuilder) scenesRepository() *joinRepository {
|
||||
return &joinRepository{
|
||||
repository: repository{
|
||||
tx: qb.tx,
|
||||
tableName: galleriesScenesTable,
|
||||
idColumn: galleryIDColumn,
|
||||
},
|
||||
fkColumn: sceneIDColumn,
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *galleryQueryBuilder) GetSceneIDs(galleryID int) ([]int, error) {
|
||||
return qb.scenesRepository().getIDs(galleryID)
|
||||
}
|
||||
|
||||
func (qb *galleryQueryBuilder) UpdateScenes(galleryID int, sceneIDs []int) error {
|
||||
// Delete the existing joins and then create new ones
|
||||
return qb.scenesRepository().replace(galleryID, sceneIDs)
|
||||
}
|
||||
|
||||
@@ -94,21 +94,21 @@ func TestGalleryFindBySceneID(t *testing.T) {
|
||||
gqb := r.Gallery()
|
||||
|
||||
sceneID := sceneIDs[sceneIdxWithGallery]
|
||||
gallery, err := gqb.FindBySceneID(sceneID)
|
||||
galleries, err := gqb.FindBySceneID(sceneID)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Error finding gallery: %s", err.Error())
|
||||
}
|
||||
|
||||
assert.Equal(t, getGalleryStringValue(galleryIdxWithScene, "Path"), gallery.Path.String)
|
||||
assert.Equal(t, getGalleryStringValue(galleryIdxWithScene, "Path"), galleries[0].Path.String)
|
||||
|
||||
gallery, err = gqb.FindBySceneID(0)
|
||||
galleries, err = gqb.FindBySceneID(0)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Error finding gallery: %s", err.Error())
|
||||
}
|
||||
|
||||
assert.Nil(t, gallery)
|
||||
assert.Nil(t, galleries)
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -233,7 +233,7 @@ func verifyGalleriesRating(t *testing.T, ratingCriterion models.IntCriterionInpu
|
||||
func TestGalleryQueryIsMissingScene(t *testing.T) {
|
||||
withTxn(func(r models.Repository) error {
|
||||
qb := r.Gallery()
|
||||
isMissing := "scene"
|
||||
isMissing := "scenes"
|
||||
galleryFilter := models.GalleryFilterType{
|
||||
IsMissing: &isMissing,
|
||||
}
|
||||
@@ -265,10 +265,8 @@ func TestGalleryQueryIsMissingScene(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TODO ValidGalleriesForScenePath
|
||||
// TODO Count
|
||||
// TODO All
|
||||
// TODO Query
|
||||
// TODO Update
|
||||
// TODO Destroy
|
||||
// TODO ClearGalleryId
|
||||
|
||||
@@ -13,6 +13,7 @@ const sceneTable = "scenes"
|
||||
const sceneIDColumn = "scene_id"
|
||||
const performersScenesTable = "performers_scenes"
|
||||
const scenesTagsTable = "scenes_tags"
|
||||
const scenesGalleriesTable = "scenes_galleries"
|
||||
const moviesScenesTable = "movies_scenes"
|
||||
|
||||
var scenesForPerformerQuery = selectAll(sceneTable) + `
|
||||
@@ -44,6 +45,12 @@ WHERE scenes_tags.tag_id = ?
|
||||
GROUP BY scenes_tags.scene_id
|
||||
`
|
||||
|
||||
var scenesForGalleryQuery = selectAll(sceneTable) + `
|
||||
LEFT JOIN scenes_galleries as galleries_join on galleries_join.scene_id = scenes.id
|
||||
WHERE galleries_join.gallery_id = ?
|
||||
GROUP BY scenes.id
|
||||
`
|
||||
|
||||
var countScenesForMissingChecksumQuery = `
|
||||
SELECT id FROM scenes
|
||||
WHERE scenes.checksum is null
|
||||
@@ -221,6 +228,11 @@ func (qb *sceneQueryBuilder) FindByPerformerID(performerID int) ([]*models.Scene
|
||||
return qb.queryScenes(scenesForPerformerQuery, args)
|
||||
}
|
||||
|
||||
func (qb *sceneQueryBuilder) FindByGalleryID(galleryID int) ([]*models.Scene, error) {
|
||||
args := []interface{}{galleryID}
|
||||
return qb.queryScenes(scenesForGalleryQuery, args)
|
||||
}
|
||||
|
||||
func (qb *sceneQueryBuilder) CountByPerformerID(performerID int) (int, error) {
|
||||
args := []interface{}{performerID}
|
||||
return qb.runCountQuery(qb.buildCountQuery(countScenesForPerformerQuery), args)
|
||||
@@ -293,7 +305,7 @@ func (qb *sceneQueryBuilder) Query(sceneFilter *models.SceneFilterType, findFilt
|
||||
left join performers_scenes as performers_join on performers_join.scene_id = scenes.id
|
||||
left join movies_scenes as movies_join on movies_join.scene_id = scenes.id
|
||||
left join studios as studio on studio.id = scenes.studio_id
|
||||
left join galleries as gallery on gallery.scene_id = scenes.id
|
||||
left join scenes_galleries as galleries_join on galleries_join.scene_id = scenes.id
|
||||
left join scenes_tags as tags_join on tags_join.scene_id = scenes.id
|
||||
left join scene_stash_ids on scene_stash_ids.scene_id = scenes.id
|
||||
`
|
||||
@@ -368,8 +380,8 @@ func (qb *sceneQueryBuilder) Query(sceneFilter *models.SceneFilterType, findFilt
|
||||
|
||||
if isMissingFilter := sceneFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" {
|
||||
switch *isMissingFilter {
|
||||
case "gallery":
|
||||
query.addWhere("gallery.scene_id IS NULL")
|
||||
case "galleries":
|
||||
query.addWhere("galleries_join.scene_id IS NULL")
|
||||
case "studio":
|
||||
query.addWhere("scenes.studio_id IS NULL")
|
||||
case "movie":
|
||||
@@ -683,6 +695,26 @@ func (qb *sceneQueryBuilder) UpdateTags(id int, tagIDs []int) error {
|
||||
return qb.tagsRepository().replace(id, tagIDs)
|
||||
}
|
||||
|
||||
func (qb *sceneQueryBuilder) galleriesRepository() *joinRepository {
|
||||
return &joinRepository{
|
||||
repository: repository{
|
||||
tx: qb.tx,
|
||||
tableName: scenesGalleriesTable,
|
||||
idColumn: sceneIDColumn,
|
||||
},
|
||||
fkColumn: galleryIDColumn,
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *sceneQueryBuilder) GetGalleryIDs(id int) ([]int, error) {
|
||||
return qb.galleriesRepository().getIDs(id)
|
||||
}
|
||||
|
||||
func (qb *sceneQueryBuilder) UpdateGalleries(id int, galleryIDs []int) error {
|
||||
// Delete the existing joins and then create new ones
|
||||
return qb.galleriesRepository().replace(id, galleryIDs)
|
||||
}
|
||||
|
||||
func (qb *sceneQueryBuilder) stashIDRepository() *stashIDRepository {
|
||||
return &stashIDRepository{
|
||||
repository{
|
||||
|
||||
@@ -494,7 +494,7 @@ func TestSceneQueryHasMarkers(t *testing.T) {
|
||||
func TestSceneQueryIsMissingGallery(t *testing.T) {
|
||||
withTxn(func(r models.Repository) error {
|
||||
sqb := r.Scene()
|
||||
isMissing := "gallery"
|
||||
isMissing := "galleries"
|
||||
sceneFilter := models.SceneFilterType{
|
||||
IsMissing: &isMissing,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package sqlite_test
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
@@ -192,7 +191,7 @@ func populateDB() error {
|
||||
return fmt.Errorf("error creating studios: %s", err.Error())
|
||||
}
|
||||
|
||||
if err := linkSceneGallery(r.Gallery(), sceneIdxWithGallery, galleryIdxWithScene); err != nil {
|
||||
if err := linkSceneGallery(r.Scene(), sceneIdxWithGallery, galleryIdxWithScene); err != nil {
|
||||
return fmt.Errorf("error linking scene to gallery: %s", err.Error())
|
||||
}
|
||||
|
||||
@@ -623,20 +622,8 @@ func linkScenePerformer(qb models.SceneReaderWriter, sceneIndex, performerIndex
|
||||
return err
|
||||
}
|
||||
|
||||
func linkSceneGallery(gqb models.GalleryReaderWriter, sceneIndex, galleryIndex int) error {
|
||||
gallery, err := gqb.Find(galleryIDs[galleryIndex])
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("error finding gallery: %s", err.Error())
|
||||
}
|
||||
|
||||
if gallery == nil {
|
||||
return errors.New("gallery is nil")
|
||||
}
|
||||
|
||||
gallery.SceneID = sql.NullInt64{Int64: int64(sceneIDs[sceneIndex]), Valid: true}
|
||||
_, err = gqb.Update(*gallery)
|
||||
|
||||
func linkSceneGallery(qb models.SceneReaderWriter, sceneIndex, galleryIndex int) error {
|
||||
_, err := scene.AddGallery(qb, sceneIDs[sceneIndex], galleryIDs[galleryIndex])
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#### 💥 Note: After upgrading, all scene file sizes will be 0B until a new [scan](/settings?tab=tasks) is run.
|
||||
|
||||
### ✨ New Features
|
||||
* Add support for multiple galleries per scene, and vice-versa.
|
||||
* Add backup database functionality to Settings/Tasks.
|
||||
* Add gallery wall view.
|
||||
* Add organized flag for scenes, galleries and images.
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useToast } from "src/hooks";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
interface IDeleteGalleryDialogProps {
|
||||
selected: Partial<GQL.GalleryDataFragment>[];
|
||||
selected: Pick<GQL.Gallery, "id">[];
|
||||
onClose: (confirmed: boolean) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ import { TextUtils } from "src/utils";
|
||||
interface IProps {
|
||||
gallery: GQL.GallerySlimDataFragment;
|
||||
selecting?: boolean;
|
||||
selected: boolean | undefined;
|
||||
zoomIndex: number;
|
||||
onSelectedChanged: (selected: boolean, shiftKey: boolean) => void;
|
||||
selected?: boolean | undefined;
|
||||
zoomIndex?: number;
|
||||
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
|
||||
}
|
||||
|
||||
export const GalleryCard: React.FC<IProps> = (props) => {
|
||||
@@ -27,19 +27,18 @@ export const GalleryCard: React.FC<IProps> = (props) => {
|
||||
config?.data?.configuration.interface.showStudioAsText ?? false;
|
||||
|
||||
function maybeRenderScenePopoverButton() {
|
||||
if (!props.gallery.scene) return;
|
||||
if (props.gallery.scenes.length === 0) return;
|
||||
|
||||
const popoverContent = (
|
||||
<TagLink key={props.gallery.scene.id} scene={props.gallery.scene} />
|
||||
);
|
||||
const popoverContent = props.gallery.scenes.map((scene) => (
|
||||
<TagLink key={scene.id} scene={scene} />
|
||||
));
|
||||
|
||||
return (
|
||||
<HoverPopover placement="bottom" content={popoverContent}>
|
||||
<Link to={`/scenes/${props.gallery.scene.id}`}>
|
||||
<Button className="minimal">
|
||||
<Icon icon="play-circle" />
|
||||
<span>{props.gallery.scenes.length}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
@@ -124,7 +123,7 @@ export const GalleryCard: React.FC<IProps> = (props) => {
|
||||
|
||||
function maybeRenderPopoverButtonGroup() {
|
||||
if (
|
||||
props.gallery.scene ||
|
||||
props.gallery.scenes.length > 0 ||
|
||||
props.gallery.performers.length > 0 ||
|
||||
props.gallery.tags.length > 0 ||
|
||||
props.gallery.organized
|
||||
|
||||
@@ -13,6 +13,7 @@ import { DeleteGalleriesDialog } from "../DeleteGalleriesDialog";
|
||||
import { GalleryImagesPanel } from "./GalleryImagesPanel";
|
||||
import { GalleryAddPanel } from "./GalleryAddPanel";
|
||||
import { GalleryFileInfoPanel } from "./GalleryFileInfoPanel";
|
||||
import { GalleryScenesPanel } from "./GalleryScenesPanel";
|
||||
|
||||
interface IGalleryParams {
|
||||
id?: string;
|
||||
@@ -118,6 +119,11 @@ export const Gallery: React.FC = () => {
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="gallery-details-panel">Details</Nav.Link>
|
||||
</Nav.Item>
|
||||
{gallery.scenes.length > 0 && (
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="gallery-scenes-panel">Scenes</Nav.Link>
|
||||
</Nav.Item>
|
||||
)}
|
||||
{gallery.path ? (
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="gallery-file-info-panel">
|
||||
@@ -157,6 +163,11 @@ export const Gallery: React.FC = () => {
|
||||
onDelete={() => setIsDeleteAlertOpen(true)}
|
||||
/>
|
||||
</Tab.Pane>
|
||||
{gallery.scenes.length > 0 && (
|
||||
<Tab.Pane eventKey="gallery-scenes-panel">
|
||||
<GalleryScenesPanel scenes={gallery.scenes} />
|
||||
</Tab.Pane>
|
||||
)}
|
||||
</Tab.Content>
|
||||
</Tab.Container>
|
||||
);
|
||||
|
||||
@@ -12,12 +12,13 @@ import {
|
||||
import {
|
||||
PerformerSelect,
|
||||
TagSelect,
|
||||
SceneSelect,
|
||||
StudioSelect,
|
||||
Icon,
|
||||
LoadingIndicator,
|
||||
} from "src/components/Shared";
|
||||
import { useToast } from "src/hooks";
|
||||
import { FormUtils, EditableTextUtils } from "src/utils";
|
||||
import { FormUtils, EditableTextUtils, TextUtils } from "src/utils";
|
||||
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
|
||||
import { GalleryScrapeDialog } from "./GalleryScrapeDialog";
|
||||
|
||||
@@ -49,6 +50,7 @@ export const GalleryEditPanel: React.FC<
|
||||
const [studioId, setStudioId] = useState<string>();
|
||||
const [performerIds, setPerformerIds] = useState<string[]>();
|
||||
const [tagIds, setTagIds] = useState<string[]>();
|
||||
const [scenes, setScenes] = useState<{ id: string; title: string }[]>([]);
|
||||
|
||||
const Scrapers = useListGalleryScrapers();
|
||||
|
||||
@@ -117,6 +119,12 @@ export const GalleryEditPanel: React.FC<
|
||||
setStudioId(state?.studio?.id ?? undefined);
|
||||
setPerformerIds(perfIds);
|
||||
setTagIds(tIds);
|
||||
setScenes(
|
||||
(state?.scenes ?? []).map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title ?? TextUtils.fileNameFromPath(s.path ?? ""),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -135,6 +143,7 @@ export const GalleryEditPanel: React.FC<
|
||||
studio_id: studioId ?? null,
|
||||
performer_ids: performerIds,
|
||||
tag_ids: tagIds,
|
||||
scene_ids: scenes.map((s) => s.id),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -390,6 +399,23 @@ export const GalleryEditPanel: React.FC<
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="scenes" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: "Scenes",
|
||||
labelProps: {
|
||||
column: true,
|
||||
sm: 3,
|
||||
xl: 12,
|
||||
},
|
||||
})}
|
||||
<Col sm={9} xl={12}>
|
||||
<SceneSelect
|
||||
scenes={scenes}
|
||||
onSelect={(items) => setScenes(items)}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
</div>
|
||||
<div className="col-12 col-lg-6 col-xl-12">
|
||||
<Form.Group controlId="details">
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { SceneCard } from "src/components/Scenes/SceneCard";
|
||||
|
||||
interface IGalleryScenesPanelProps {
|
||||
scenes: GQL.SceneDataFragment[];
|
||||
}
|
||||
|
||||
export const GalleryScenesPanel: React.FC<IGalleryScenesPanelProps> = ({
|
||||
scenes,
|
||||
}) => (
|
||||
<div className="container gallery-scenes">
|
||||
{scenes.map((scene) => (
|
||||
<SceneCard scene={scene} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -1,16 +1,20 @@
|
||||
import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { useFindGallery } from "src/core/StashService";
|
||||
import { useLightbox } from "src/hooks";
|
||||
import { LoadingIndicator } from "src/components/Shared";
|
||||
import "flexbin/flexbin.css";
|
||||
|
||||
interface IProps {
|
||||
gallery: GQL.GalleryDataFragment;
|
||||
galleryId: string;
|
||||
}
|
||||
|
||||
export const GalleryViewer: React.FC<IProps> = ({ gallery }) => {
|
||||
const images = gallery?.images ?? [];
|
||||
export const GalleryViewer: React.FC<IProps> = ({ galleryId }) => {
|
||||
const { data, loading } = useFindGallery(galleryId);
|
||||
const images = data?.findGallery?.images ?? [];
|
||||
const showLightbox = useLightbox({ images, showNavigation: false });
|
||||
|
||||
if (loading) return <LoadingIndicator />;
|
||||
|
||||
const thumbs = images.map((file, index) => (
|
||||
<div
|
||||
role="link"
|
||||
|
||||
@@ -90,6 +90,11 @@ $galleryTabWidth: 450px;
|
||||
&-gallery {
|
||||
height: 22.5rem;
|
||||
}
|
||||
|
||||
&-image {
|
||||
height: 22.5rem;
|
||||
width: 15rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,9 +66,9 @@ export const ScenePreview: React.FC<IScenePreviewProps> = ({
|
||||
interface ISceneCardProps {
|
||||
scene: GQL.SlimSceneDataFragment;
|
||||
selecting?: boolean;
|
||||
selected: boolean | undefined;
|
||||
zoomIndex: number;
|
||||
onSelectedChanged: (selected: boolean, shiftKey: boolean) => void;
|
||||
selected?: boolean | undefined;
|
||||
zoomIndex?: number;
|
||||
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
|
||||
}
|
||||
|
||||
export const SceneCard: React.FC<ISceneCardProps> = (
|
||||
@@ -257,18 +257,21 @@ export const SceneCard: React.FC<ISceneCardProps> = (
|
||||
}
|
||||
|
||||
function maybeRenderGallery() {
|
||||
if (props.scene.gallery) {
|
||||
if (props.scene.galleries.length <= 0) return;
|
||||
|
||||
const popoverContent = props.scene.galleries.map((gallery) => (
|
||||
<TagLink key={gallery.id} gallery={gallery} />
|
||||
));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link to={`/galleries/${props.scene.gallery.id}`}>
|
||||
<HoverPopover placement="bottom" content={popoverContent}>
|
||||
<Button className="minimal">
|
||||
<Icon icon="image" />
|
||||
<span>{props.scene.galleries.length}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderOrganized() {
|
||||
if (props.scene.organized) {
|
||||
@@ -289,7 +292,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
|
||||
props.scene.movies.length > 0 ||
|
||||
props.scene.scene_markers.length > 0 ||
|
||||
props.scene?.o_counter ||
|
||||
props.scene.gallery ||
|
||||
props.scene.galleries.length > 0 ||
|
||||
props.scene.organized
|
||||
) {
|
||||
return (
|
||||
@@ -314,7 +317,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
|
||||
) {
|
||||
const { shiftKey } = event;
|
||||
|
||||
if (props.selecting) {
|
||||
if (props.selecting && props.onSelectedChanged) {
|
||||
props.onSelectedChanged(!props.selected, shiftKey);
|
||||
event.preventDefault();
|
||||
}
|
||||
@@ -331,7 +334,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
|
||||
const ev = event;
|
||||
const shiftKey = false;
|
||||
|
||||
if (props.selecting && !props.selected) {
|
||||
if (props.selecting && props.onSelectedChanged && !props.selected) {
|
||||
props.onSelectedChanged(true, shiftKey);
|
||||
}
|
||||
|
||||
@@ -354,7 +357,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
|
||||
type="checkbox"
|
||||
className="scene-card-check"
|
||||
checked={props.selected}
|
||||
onChange={() => props.onSelectedChanged(!props.selected, shiftKey)}
|
||||
onChange={() => props.onSelectedChanged?.(!props.selected, shiftKey)}
|
||||
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
|
||||
// eslint-disable-next-line prefer-destructuring
|
||||
shiftKey = event.shiftKey;
|
||||
|
||||
@@ -24,6 +24,7 @@ import { SceneEditPanel } from "./SceneEditPanel";
|
||||
import { SceneDetailPanel } from "./SceneDetailPanel";
|
||||
import { OCounterButton } from "./OCounterButton";
|
||||
import { SceneMoviePanel } from "./SceneMoviePanel";
|
||||
import { SceneGalleriesPanel } from "./SceneGalleriesPanel";
|
||||
import { DeleteScenesDialog } from "../DeleteScenesDialog";
|
||||
import { SceneGenerateDialog } from "../SceneGenerateDialog";
|
||||
import { SceneVideoFilterPanel } from "./SceneVideoFilterPanel";
|
||||
@@ -243,13 +244,16 @@ export const Scene: React.FC = () => {
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{scene.gallery ? (
|
||||
{scene.galleries.length === 1 ? (
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="scene-gallery-panel">Gallery</Nav.Link>
|
||||
</Nav.Item>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
) : undefined}
|
||||
{scene.galleries.length > 1 ? (
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="scene-galleries-panel">Galleries</Nav.Link>
|
||||
</Nav.Item>
|
||||
) : undefined}
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="scene-video-filter-panel">Filters</Nav.Link>
|
||||
</Nav.Item>
|
||||
@@ -295,12 +299,15 @@ export const Scene: React.FC = () => {
|
||||
<Tab.Pane eventKey="scene-movie-panel">
|
||||
<SceneMoviePanel scene={scene} />
|
||||
</Tab.Pane>
|
||||
{scene.gallery ? (
|
||||
{scene.galleries.length === 1 && (
|
||||
<Tab.Pane eventKey="scene-gallery-panel">
|
||||
<GalleryViewer gallery={scene.gallery} />
|
||||
<GalleryViewer galleryId={scene.galleries[0].id} />
|
||||
</Tab.Pane>
|
||||
)}
|
||||
{scene.galleries.length > 1 && (
|
||||
<Tab.Pane eventKey="scene-galleries-panel">
|
||||
<SceneGalleriesPanel galleries={scene.galleries} />
|
||||
</Tab.Pane>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<Tab.Pane eventKey="scene-video-filter-panel">
|
||||
<SceneVideoFilterPanel scene={scene} />
|
||||
|
||||
@@ -22,13 +22,13 @@ import {
|
||||
PerformerSelect,
|
||||
TagSelect,
|
||||
StudioSelect,
|
||||
SceneGallerySelect,
|
||||
GallerySelect,
|
||||
Icon,
|
||||
LoadingIndicator,
|
||||
ImageInput,
|
||||
} from "src/components/Shared";
|
||||
import { useToast } from "src/hooks";
|
||||
import { ImageUtils, FormUtils, EditableTextUtils } from "src/utils";
|
||||
import { ImageUtils, FormUtils, EditableTextUtils, TextUtils } from "src/utils";
|
||||
import { MovieSelect } from "src/components/Shared/Select";
|
||||
import { SceneMovieTable, MovieSceneIndexMap } from "./SceneMovieTable";
|
||||
import { RatingStars } from "./RatingStars";
|
||||
@@ -47,7 +47,9 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
||||
const [url, setUrl] = useState<string>();
|
||||
const [date, setDate] = useState<string>();
|
||||
const [rating, setRating] = useState<number>();
|
||||
const [galleryId, setGalleryId] = useState<string>();
|
||||
const [galleries, setGalleries] = useState<{ id: string; title: string }[]>(
|
||||
[]
|
||||
);
|
||||
const [studioId, setStudioId] = useState<string>();
|
||||
const [performerIds, setPerformerIds] = useState<string[]>();
|
||||
const [movieIds, setMovieIds] = useState<string[] | undefined>(undefined);
|
||||
@@ -171,7 +173,12 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
||||
setUrl(state.url ?? undefined);
|
||||
setDate(state.date ?? undefined);
|
||||
setRating(state.rating === null ? NaN : state.rating);
|
||||
setGalleryId(state?.gallery?.id ?? undefined);
|
||||
setGalleries(
|
||||
(state?.galleries ?? []).map((g) => ({
|
||||
id: g.id,
|
||||
title: g.title ?? TextUtils.fileNameFromPath(g.path ?? ""),
|
||||
}))
|
||||
);
|
||||
setStudioId(state?.studio?.id ?? undefined);
|
||||
setMovieIds(moviIds);
|
||||
setMovieSceneIndexes(movieSceneIdx);
|
||||
@@ -196,7 +203,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
||||
url,
|
||||
date,
|
||||
rating: rating ?? null,
|
||||
gallery_id: galleryId ?? null,
|
||||
gallery_ids: galleries.map((g) => g.id),
|
||||
studio_id: studioId ?? null,
|
||||
performer_ids: performerIds,
|
||||
movies: makeMovieInputs(),
|
||||
@@ -596,15 +603,14 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
<Form.Group controlId="gallery" as={Row}>
|
||||
<Form.Group controlId="galleries" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: "Gallery",
|
||||
title: "Galleries",
|
||||
})}
|
||||
<Col xs={9}>
|
||||
<SceneGallerySelect
|
||||
sceneId={props.scene.id}
|
||||
gallery={props.scene.gallery ?? undefined}
|
||||
onSelect={(item) => setGalleryId(item ? item.id : undefined)}
|
||||
<GallerySelect
|
||||
galleries={galleries}
|
||||
onSelect={(items) => setGalleries(items)}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { GalleryCard } from "src/components/Galleries/GalleryCard";
|
||||
|
||||
interface ISceneGalleriesPanelProps {
|
||||
galleries: GQL.GallerySlimDataFragment[];
|
||||
}
|
||||
|
||||
export const SceneGalleriesPanel: React.FC<ISceneGalleriesPanelProps> = ({
|
||||
galleries,
|
||||
}) => {
|
||||
const cards = galleries.map((gallery) => (
|
||||
<GalleryCard key={gallery.id} gallery={gallery} selecting={false} />
|
||||
));
|
||||
|
||||
return <div className="row justify-content-center">{cards}</div>;
|
||||
};
|
||||
@@ -14,11 +14,9 @@ import {
|
||||
useTagCreate,
|
||||
useStudioCreate,
|
||||
usePerformerCreate,
|
||||
useFindGalleries,
|
||||
} from "src/core/StashService";
|
||||
import { useToast } from "src/hooks";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { FilterMode } from "src/models/list-filter/types";
|
||||
import { TextUtils } from "src/utils";
|
||||
|
||||
export type ValidTypes =
|
||||
| GQL.SlimPerformerDataFragment
|
||||
@@ -72,23 +70,28 @@ interface IFilterComponentProps extends IFilterProps {
|
||||
interface IFilterSelectProps<T extends boolean>
|
||||
extends Omit<ISelectProps<T>, "onChange" | "items" | "onCreateOption"> {}
|
||||
|
||||
interface ISceneGallerySelect {
|
||||
gallery?: Pick<GQL.Gallery, "title" | "path" | "id">;
|
||||
sceneId: string;
|
||||
onSelect: (
|
||||
item:
|
||||
| GQL.ValidGalleriesForSceneQuery["validGalleriesForScene"][0]
|
||||
| undefined
|
||||
) => void;
|
||||
type Gallery = { id: string; title: string };
|
||||
interface IGallerySelect {
|
||||
galleries: Gallery[];
|
||||
onSelect: (items: Gallery[]) => void;
|
||||
}
|
||||
|
||||
const getSelectedValues = (selectedItems: ValueType<Option, boolean>) =>
|
||||
type Scene = { id: string; title: string };
|
||||
interface ISceneSelect {
|
||||
scenes: Scene[];
|
||||
onSelect: (items: Scene[]) => void;
|
||||
}
|
||||
|
||||
const getSelectedItems = (selectedItems: ValueType<Option, boolean>) =>
|
||||
selectedItems
|
||||
? (Array.isArray(selectedItems) ? selectedItems : [selectedItems]).map(
|
||||
(item) => item.value
|
||||
)
|
||||
? Array.isArray(selectedItems)
|
||||
? selectedItems
|
||||
: [selectedItems]
|
||||
: [];
|
||||
|
||||
const getSelectedValues = (selectedItems: ValueType<Option, boolean>) =>
|
||||
getSelectedItems(selectedItems).map((item) => item.value);
|
||||
|
||||
const SelectComponent = <T extends boolean>({
|
||||
type,
|
||||
initialIds,
|
||||
@@ -231,53 +234,104 @@ const FilterSelectComponent = <T extends boolean>(
|
||||
);
|
||||
};
|
||||
|
||||
export const SceneGallerySelect: React.FC<ISceneGallerySelect> = (props) => {
|
||||
export const GallerySelect: React.FC<IGallerySelect> = (props) => {
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const { data, loading } = useFindGalleries(getFilter());
|
||||
const [selectedOption, setSelectedOption] = useState<Option>();
|
||||
const { data, loading } = GQL.useFindGalleriesQuery({
|
||||
skip: query === "",
|
||||
variables: {
|
||||
filter: {
|
||||
q: query,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const galleries = data?.findGalleries.galleries ?? [];
|
||||
const items = galleries.map((g) => ({
|
||||
label: g.title ?? g.path ?? "",
|
||||
label: g.title ?? TextUtils.fileNameFromPath(g.path ?? ""),
|
||||
value: g.id,
|
||||
}));
|
||||
|
||||
function getFilter() {
|
||||
const ret = new ListFilterModel(FilterMode.Galleries);
|
||||
ret.searchTerm = query;
|
||||
return ret;
|
||||
}
|
||||
|
||||
const onInputChange = debounce((input: string) => {
|
||||
setQuery(input);
|
||||
}, 500);
|
||||
|
||||
const onChange = (selectedItem: ValueType<Option, false>) => {
|
||||
setSelectedOption(selectedItem ?? undefined);
|
||||
const onChange = (selectedItems: ValueType<Option, boolean>) => {
|
||||
const selected = getSelectedItems(selectedItems);
|
||||
props.onSelect(
|
||||
selectedItem
|
||||
? galleries.find((g) => g.id === selectedItem.value)
|
||||
: undefined
|
||||
selected.map((s) => ({
|
||||
id: s.value,
|
||||
title: s.label,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const option =
|
||||
selectedOption ??
|
||||
(props.gallery
|
||||
? {
|
||||
value: props.gallery.id,
|
||||
label: props.gallery.title ?? props.gallery.path ?? "Unknown",
|
||||
}
|
||||
: undefined);
|
||||
const options = props.galleries.map((g) => ({
|
||||
value: g.id,
|
||||
label: g.title ?? "Unknown",
|
||||
}));
|
||||
|
||||
return (
|
||||
<SelectComponent
|
||||
isMulti={false}
|
||||
onChange={onChange}
|
||||
onInputChange={onInputChange}
|
||||
isLoading={loading}
|
||||
items={items}
|
||||
selectedOptions={option}
|
||||
selectedOptions={options}
|
||||
isMulti
|
||||
placeholder="Search for gallery..."
|
||||
noOptionsMessage={query === "" ? null : "No galleries found."}
|
||||
showDropdown={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const SceneSelect: React.FC<ISceneSelect> = (props) => {
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const { data, loading } = GQL.useFindScenesQuery({
|
||||
skip: query === "",
|
||||
variables: {
|
||||
filter: {
|
||||
q: query,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const scenes = data?.findScenes.scenes ?? [];
|
||||
const items = scenes.map((s) => ({
|
||||
label: s.title ?? TextUtils.fileNameFromPath(s.path ?? ""),
|
||||
value: s.id,
|
||||
}));
|
||||
|
||||
const onInputChange = debounce((input: string) => {
|
||||
setQuery(input);
|
||||
}, 500);
|
||||
|
||||
const onChange = (selectedItems: ValueType<Option, true>) => {
|
||||
const selected = getSelectedItems(selectedItems);
|
||||
props.onSelect(
|
||||
(selected ?? []).map((s) => ({
|
||||
id: s.value,
|
||||
title: s.label,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const options = props.scenes.map((s) => ({
|
||||
value: s.id,
|
||||
label: s.title,
|
||||
}));
|
||||
|
||||
return (
|
||||
<SelectComponent
|
||||
onChange={onChange}
|
||||
onInputChange={onInputChange}
|
||||
isLoading={loading}
|
||||
items={items}
|
||||
selectedOptions={options}
|
||||
isMulti
|
||||
placeholder="Search for scene..."
|
||||
noOptionsMessage={query === "" ? null : "No scenes found."}
|
||||
showDropdown={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
export {
|
||||
SceneGallerySelect,
|
||||
GallerySelect,
|
||||
ScrapePerformerSuggest,
|
||||
MarkerTitleSuggest,
|
||||
FilterSelect,
|
||||
PerformerSelect,
|
||||
StudioSelect,
|
||||
TagSelect,
|
||||
SceneSelect,
|
||||
} from "./Select";
|
||||
|
||||
export { default as Icon } from "./Icon";
|
||||
|
||||
@@ -33,13 +33,13 @@
|
||||
padding: 1rem 0;
|
||||
|
||||
&:hover {
|
||||
background-color: hsl(204, 20, 30);
|
||||
background-color: hsl(204, 20%, 30%);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-result {
|
||||
background-color: hsl(204, 20, 30);
|
||||
background-color: hsl(204, 20%, 30%);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -264,10 +264,6 @@ export const useAllPerformersForFilter = () =>
|
||||
GQL.useAllPerformersForFilterQuery();
|
||||
export const useAllStudiosForFilter = () => GQL.useAllStudiosForFilterQuery();
|
||||
export const useAllMoviesForFilter = () => GQL.useAllMoviesForFilterQuery();
|
||||
export const useValidGalleriesForScene = (sceneId: string) =>
|
||||
GQL.useValidGalleriesForSceneQuery({
|
||||
variables: { scene_id: sceneId },
|
||||
});
|
||||
export const useStats = () => GQL.useStatsQuery();
|
||||
export const useVersion = () => GQL.useVersionQuery();
|
||||
export const useLatestVersion = () =>
|
||||
|
||||
@@ -15,7 +15,7 @@ export class SceneIsMissingCriterion extends IsMissingCriterion {
|
||||
"details",
|
||||
"url",
|
||||
"date",
|
||||
"gallery",
|
||||
"galleries",
|
||||
"studio",
|
||||
"movie",
|
||||
"performers",
|
||||
@@ -83,7 +83,7 @@ export class GalleryIsMissingCriterion extends IsMissingCriterion {
|
||||
"studio",
|
||||
"performers",
|
||||
"tags",
|
||||
"scene",
|
||||
"scenes",
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user