From 75f22042b7c16ed54eb8e5c9172fdedb3b1eb54d Mon Sep 17 00:00:00 2001 From: Flashy78 <90150289+Flashy78@users.noreply.github.com> Date: Sun, 16 Apr 2023 22:21:13 -0700 Subject: [PATCH 01/81] Sort case insensitive, date by newest first (#3560) * Case insensitive search * Fix not adding extra sort when no sort specified. * Using newer version of fvbommel/sortorder package --- go.mod | 2 +- go.sum | 4 +- pkg/sqlite/driver.go | 8 +- pkg/sqlite/gallery.go | 7 +- pkg/sqlite/image.go | 7 +- pkg/sqlite/movies.go | 11 +- pkg/sqlite/performer.go | 27 +-- pkg/sqlite/scene.go | 7 +- pkg/sqlite/sql.go | 4 +- pkg/sqlite/studio.go | 13 +- pkg/sqlite/tag.go | 31 +-- ui/v2.5/src/models/list-filter/filter.ts | 40 +++- .../github.com/fvbommel/sortorder/.gitignore | 19 -- .../github.com/fvbommel/sortorder/README.md | 5 - .../fvbommel/sortorder/casefolded/README.md | 12 ++ .../fvbommel/sortorder/casefolded/natsort.go | 194 ++++++++++++++++++ vendor/github.com/fvbommel/sortorder/doc.go | 5 - .../github.com/fvbommel/sortorder/natsort.go | 76 ------- vendor/modules.txt | 4 +- 19 files changed, 311 insertions(+), 165 deletions(-) delete mode 100644 vendor/github.com/fvbommel/sortorder/.gitignore delete mode 100644 vendor/github.com/fvbommel/sortorder/README.md create mode 100644 vendor/github.com/fvbommel/sortorder/casefolded/README.md create mode 100644 vendor/github.com/fvbommel/sortorder/casefolded/natsort.go delete mode 100644 vendor/github.com/fvbommel/sortorder/doc.go delete mode 100644 vendor/github.com/fvbommel/sortorder/natsort.go diff --git a/go.mod b/go.mod index 1fbf6858a..44a54616a 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/chromedp/chromedp v0.7.3 github.com/corona10/goimagehash v1.0.3 github.com/disintegration/imaging v1.6.0 - github.com/fvbommel/sortorder v1.0.2 + github.com/fvbommel/sortorder v1.1.0 github.com/go-chi/chi v4.0.2+incompatible github.com/golang-jwt/jwt/v4 v4.0.0 github.com/golang-migrate/migrate/v4 v4.15.0-beta.1 diff --git a/go.sum b/go.sum index 14bf5606a..75b2d679e 100644 --- a/go.sum +++ b/go.sum @@ -233,8 +233,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/fsouza/fake-gcs-server v1.17.0/go.mod h1:D1rTE4YCyHFNa99oyJJ5HyclvN/0uQR+pM/VdlL83bw= -github.com/fvbommel/sortorder v1.0.2 h1:mV4o8B2hKboCdkJm+a7uX/SIpZob4JzUpc5GGnM45eo= -github.com/fvbommel/sortorder v1.0.2/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= +github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw= +github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= diff --git a/pkg/sqlite/driver.go b/pkg/sqlite/driver.go index 5712c77c7..c67379d1b 100644 --- a/pkg/sqlite/driver.go +++ b/pkg/sqlite/driver.go @@ -5,7 +5,7 @@ import ( "database/sql/driver" "fmt" - "github.com/fvbommel/sortorder" + "github.com/fvbommel/sortorder/casefolded" sqlite3 "github.com/mattn/go-sqlite3" ) @@ -37,9 +37,9 @@ func (d *CustomSQLiteDriver) Open(dsn string) (driver.Conn, error) { } } - // COLLATE NATURAL_CS - Case sensitive natural sort - err := conn.RegisterCollation("NATURAL_CS", func(s string, s2 string) int { - if sortorder.NaturalLess(s, s2) { + // COLLATE NATURAL_CI - Case insensitive natural sort + err := conn.RegisterCollation("NATURAL_CI", func(s string, s2 string) int { + if casefolded.NaturalLess(s, s2) { return -1 } else { return 1 diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 590586b94..de840b283 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -1128,7 +1128,7 @@ func (qb *GalleryStore) setGallerySort(query *queryBuilder, findFilter *models.F // special handling for path addFileTable() addFolderTable() - query.sortAndPagination += fmt.Sprintf(" ORDER BY folders.path %s, file_folder.path %[1]s, files.basename %[1]s", direction) + query.sortAndPagination += fmt.Sprintf(" ORDER BY COALESCE(folders.path, '') || COALESCE(file_folder.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI %s", direction) case "file_mod_time": sort = "mod_time" addFileTable() @@ -1136,10 +1136,13 @@ func (qb *GalleryStore) setGallerySort(query *queryBuilder, findFilter *models.F case "title": addFileTable() addFolderTable() - query.sortAndPagination += " ORDER BY COALESCE(galleries.title, files.basename, basename(COALESCE(folders.path, ''))) COLLATE NATURAL_CS " + direction + ", file_folder.path " + direction + query.sortAndPagination += " ORDER BY COALESCE(galleries.title, files.basename, basename(COALESCE(folders.path, ''))) COLLATE NATURAL_CI " + direction + ", file_folder.path COLLATE NATURAL_CI " + direction default: query.sortAndPagination += getSort(sort, direction, "galleries") } + + // Whatever the sorting, always use title/id as a final sort + query.sortAndPagination += ", COALESCE(galleries.title, galleries.id) COLLATE NATURAL_CI ASC" } func (qb *GalleryStore) filesRepository() *filesRepository { diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index d5bb4e852..2648c523d 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -1026,7 +1026,7 @@ func (qb *ImageStore) setImageSortAndPagination(q *queryBuilder, findFilter *mod case "path": addFilesJoin() addFolderJoin() - sortClause = " ORDER BY folders.path " + direction + ", files.basename " + direction + sortClause = " ORDER BY COALESCE(folders.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI " + direction case "file_count": sortClause = getCountSort(imageTable, imagesFilesTable, imageIDColumn, direction) case "tag_count": @@ -1039,10 +1039,13 @@ func (qb *ImageStore) setImageSortAndPagination(q *queryBuilder, findFilter *mod case "title": addFilesJoin() addFolderJoin() - sortClause = " ORDER BY COALESCE(images.title, files.basename) COLLATE NATURAL_CS " + direction + ", folders.path " + direction + sortClause = " ORDER BY COALESCE(images.title, files.basename) COLLATE NATURAL_CI " + direction + ", folders.path COLLATE NATURAL_CI " + direction default: sortClause = getSort(sort, direction, "images") } + + // Whatever the sorting, always use title/id as a final sort + sortClause += ", COALESCE(images.title, images.id) COLLATE NATURAL_CI ASC" } q.sortAndPagination = sortClause + getPagination(findFilter) diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index 1c591614d..212f350b7 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -310,14 +310,17 @@ func (qb *movieQueryBuilder) getMovieSort(findFilter *models.FindFilterType) str direction = findFilter.GetDirection() } + sortQuery := "" switch sort { - case "name": // #943 - override name sorting to use natural sort - return " ORDER BY " + getColumn("movies", sort) + " COLLATE NATURAL_CS " + direction case "scenes_count": // generic getSort won't work for this - return getCountSort(movieTable, moviesScenesTable, movieIDColumn, direction) + sortQuery += getCountSort(movieTable, moviesScenesTable, movieIDColumn, direction) default: - return getSort(sort, direction, "movies") + sortQuery += getSort(sort, direction, "movies") } + + // Whatever the sorting, always use name/id as a final sort + sortQuery += ", COALESCE(movies.name, movies.id) COLLATE NATURAL_CI ASC" + return sortQuery } func (qb *movieQueryBuilder) queryMovie(ctx context.Context, query string, args []interface{}) (*models.Movie, error) { diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index f288401d3..2cd9b7356 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -893,20 +893,23 @@ func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) st direction = findFilter.GetDirection() } - if sort == "tag_count" { - return getCountSort(performerTable, performersTagsTable, performerIDColumn, direction) - } - if sort == "scenes_count" { - return getCountSort(performerTable, performersScenesTable, performerIDColumn, direction) - } - if sort == "images_count" { - return getCountSort(performerTable, performersImagesTable, performerIDColumn, direction) - } - if sort == "galleries_count" { - return getCountSort(performerTable, performersGalleriesTable, performerIDColumn, direction) + sortQuery := "" + switch sort { + case "tag_count": + sortQuery += getCountSort(performerTable, performersTagsTable, performerIDColumn, direction) + case "scenes_count": + sortQuery += getCountSort(performerTable, performersScenesTable, performerIDColumn, direction) + case "images_count": + sortQuery += getCountSort(performerTable, performersImagesTable, performerIDColumn, direction) + case "galleries_count": + sortQuery += getCountSort(performerTable, performersGalleriesTable, performerIDColumn, direction) + default: + sortQuery += getSort(sort, direction, "performers") } - return getSort(sort, direction, "performers") + // Whatever the sorting, always use name/id as a final sort + sortQuery += ", COALESCE(performers.name, performers.id) COLLATE NATURAL_CI ASC" + return sortQuery } func (qb *PerformerStore) tagsRepository() *joinRepository { diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index a5e903653..ee58cb0e2 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -1435,7 +1435,7 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF // special handling for path addFileTable() addFolderTable() - query.sortAndPagination += fmt.Sprintf(" ORDER BY folders.path %s, files.basename %[1]s", direction) + query.sortAndPagination += fmt.Sprintf(" ORDER BY COALESCE(folders.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI %s", direction) case "perceptual_similarity": // special handling for phash addFileTable() @@ -1472,13 +1472,16 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF case "title": addFileTable() addFolderTable() - query.sortAndPagination += " ORDER BY COALESCE(scenes.title, files.basename) COLLATE NATURAL_CS " + direction + ", folders.path " + direction + query.sortAndPagination += " ORDER BY COALESCE(scenes.title, files.basename) COLLATE NATURAL_CI " + direction + ", folders.path COLLATE NATURAL_CI " + direction case "play_count": // handle here since getSort has special handling for _count suffix query.sortAndPagination += " ORDER BY scenes.play_count " + direction default: query.sortAndPagination += getSort(sort, direction, "scenes") } + + // Whatever the sorting, always use title/id as a final sort + query.sortAndPagination += ", COALESCE(scenes.title, scenes.id) COLLATE NATURAL_CI ASC" } func (qb *SceneStore) getPlayCount(ctx context.Context, id int) (int, error) { diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index af864df01..334c3eca1 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -82,10 +82,10 @@ func getSort(sort string, direction string, tableName string) string { colName = sort } if strings.Compare(sort, "name") == 0 { - return " ORDER BY " + colName + " COLLATE NOCASE " + direction + return " ORDER BY " + colName + " COLLATE NATURAL_CI " + direction } if strings.Compare(sort, "title") == 0 { - return " ORDER BY " + colName + " COLLATE NATURAL_CS " + direction + return " ORDER BY " + colName + " COLLATE NATURAL_CI " + direction } return " ORDER BY " + colName + " " + direction diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index b8f783de1..0b5ed7f2f 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -414,16 +414,21 @@ func (qb *studioQueryBuilder) getStudioSort(findFilter *models.FindFilterType) s direction = findFilter.GetDirection() } + sortQuery := "" switch sort { case "scenes_count": - return getCountSort(studioTable, sceneTable, studioIDColumn, direction) + sortQuery += getCountSort(studioTable, sceneTable, studioIDColumn, direction) case "images_count": - return getCountSort(studioTable, imageTable, studioIDColumn, direction) + sortQuery += getCountSort(studioTable, imageTable, studioIDColumn, direction) case "galleries_count": - return getCountSort(studioTable, galleryTable, studioIDColumn, direction) + sortQuery += getCountSort(studioTable, galleryTable, studioIDColumn, direction) default: - return getSort(sort, direction, "studios") + sortQuery += getSort(sort, direction, "studios") } + + // Whatever the sorting, always use name/id as a final sort + sortQuery += ", COALESCE(studios.name, studios.id) COLLATE NATURAL_CI ASC" + return sortQuery } func (qb *studioQueryBuilder) queryStudio(ctx context.Context, query string, args []interface{}) (*models.Studio, error) { diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 9ad4abcaf..71edf1297 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -609,22 +609,25 @@ func (qb *tagQueryBuilder) getTagSort(query *queryBuilder, findFilter *models.Fi direction = findFilter.GetDirection() } - if findFilter.Sort != nil { - switch *findFilter.Sort { - case "scenes_count": - return getCountSort(tagTable, scenesTagsTable, tagIDColumn, direction) - case "scene_markers_count": - return fmt.Sprintf(" ORDER BY (SELECT COUNT(*) FROM scene_markers_tags WHERE tags.id = scene_markers_tags.tag_id)+(SELECT COUNT(*) FROM scene_markers WHERE tags.id = scene_markers.primary_tag_id) %s", getSortDirection(direction)) - case "images_count": - return getCountSort(tagTable, imagesTagsTable, tagIDColumn, direction) - case "galleries_count": - return getCountSort(tagTable, galleriesTagsTable, tagIDColumn, direction) - case "performers_count": - return getCountSort(tagTable, performersTagsTable, tagIDColumn, direction) - } + sortQuery := "" + switch sort { + case "scenes_count": + sortQuery += getCountSort(tagTable, scenesTagsTable, tagIDColumn, direction) + case "scene_markers_count": + sortQuery += fmt.Sprintf(" ORDER BY (SELECT COUNT(*) FROM scene_markers_tags WHERE tags.id = scene_markers_tags.tag_id)+(SELECT COUNT(*) FROM scene_markers WHERE tags.id = scene_markers.primary_tag_id) %s", getSortDirection(direction)) + case "images_count": + sortQuery += getCountSort(tagTable, imagesTagsTable, tagIDColumn, direction) + case "galleries_count": + sortQuery += getCountSort(tagTable, galleriesTagsTable, tagIDColumn, direction) + case "performers_count": + sortQuery += getCountSort(tagTable, performersTagsTable, tagIDColumn, direction) + default: + sortQuery += getSort(sort, direction, "tags") } - return getSort(sort, direction, "tags") + // Whatever the sorting, always use name/id as a final sort + sortQuery += ", COALESCE(tags.name, tags.id) COLLATE NATURAL_CI ASC" + return sortQuery } func (qb *tagQueryBuilder) queryTag(ctx context.Context, query string, args []interface{}) (*models.Tag, error) { diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index e20fd5935..726c83b6f 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -44,7 +44,7 @@ export class ListFilterModel { public searchTerm: string = ""; public currentPage = DEFAULT_PARAMS.currentPage; public itemsPerPage = DEFAULT_PARAMS.itemsPerPage; - public sortDirection: SortDirectionEnum = SortDirectionEnum.Asc; + public sortDirection: SortDirectionEnum = DEFAULT_PARAMS.sortDirection; public sortBy?: string; public displayMode: DisplayMode = DEFAULT_PARAMS.displayMode; public zoomIndex: number = 1; @@ -62,6 +62,9 @@ export class ListFilterModel { this.mode = mode; this.config = config; this.sortBy = defaultSort; + if (this.sortBy === "date") { + this.sortDirection = SortDirectionEnum.Desc; + } if (defaultDisplayMode !== undefined) { this.displayMode = defaultDisplayMode; } @@ -95,12 +98,19 @@ export class ListFilterModel { this.randomSeed = Number.parseInt(match[1], 10); } } - // #3193 - sortdir undefined means asc - this.sortDirection = - params.sortdir === "desc" - ? SortDirectionEnum.Desc - : SortDirectionEnum.Asc; - + if (params.sortdir !== undefined) { + this.sortDirection = + params.sortdir === "desc" + ? SortDirectionEnum.Desc + : SortDirectionEnum.Asc; + } else { + // #3193 - sortdir undefined means asc + // #3559 - unless sortby is date, then desc + this.sortDirection = + params.sortby === "date" + ? SortDirectionEnum.Desc + : SortDirectionEnum.Asc; + } if (params.disp !== undefined) { this.displayMode = params.disp; } @@ -294,7 +304,13 @@ export class ListFilterModel { : undefined, sortby: this.getSortBy(), sortdir: - this.sortDirection === SortDirectionEnum.Desc ? "desc" : undefined, + this.sortBy === "date" + ? this.sortDirection === SortDirectionEnum.Asc + ? "asc" + : undefined + : this.sortDirection === SortDirectionEnum.Desc + ? "desc" + : undefined, disp: this.displayMode !== DEFAULT_PARAMS.displayMode ? String(this.displayMode) @@ -321,7 +337,13 @@ export class ListFilterModel { perPage: this.itemsPerPage, sortby: this.getSortBy(), sortdir: - this.sortDirection === SortDirectionEnum.Desc ? "desc" : undefined, + this.sortBy === "date" + ? this.sortDirection === SortDirectionEnum.Asc + ? "asc" + : undefined + : this.sortDirection === SortDirectionEnum.Desc + ? "desc" + : undefined, disp: this.displayMode, q: this.searchTerm || undefined, z: this.zoomIndex, diff --git a/vendor/github.com/fvbommel/sortorder/.gitignore b/vendor/github.com/fvbommel/sortorder/.gitignore deleted file mode 100644 index c021733e2..000000000 --- a/vendor/github.com/fvbommel/sortorder/.gitignore +++ /dev/null @@ -1,19 +0,0 @@ -# Compiled Object files, Static and Dynamic libs (Shared Objects) -*.o -*.a -*.so -# Folders -_obj -_test -# Architecture specific extensions/prefixes -*.[568vq] -[568vq].out -*.cgo1.go -*.cgo2.c -_cgo_defun.c -_cgo_gotypes.go -_cgo_export.* -_testmain.go -*.exe -*.test -*.prof diff --git a/vendor/github.com/fvbommel/sortorder/README.md b/vendor/github.com/fvbommel/sortorder/README.md deleted file mode 100644 index 7ebcab1d1..000000000 --- a/vendor/github.com/fvbommel/sortorder/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# sortorder [![PkgGoDev](https://pkg.go.dev/badge/github.com/fvbommel/sortorder)](https://pkg.go.dev/github.com/fvbommel/sortorder) - - import "github.com/fvbommel/sortorder" - -Sort orders and comparison functions. diff --git a/vendor/github.com/fvbommel/sortorder/casefolded/README.md b/vendor/github.com/fvbommel/sortorder/casefolded/README.md new file mode 100644 index 000000000..fd2d84f2e --- /dev/null +++ b/vendor/github.com/fvbommel/sortorder/casefolded/README.md @@ -0,0 +1,12 @@ +# casefolded [![PkgGoDev](https://pkg.go.dev/badge/github.com/fvbommel/sortorder/casefolded)](https://pkg.go.dev/github.com/fvbommel/sortorder/casefolded) + + import "github.com/fvbommel/sortorder/casefolded" + +Case-folded sort orders and comparison functions. + +These sort characters as the lowest unicode value that is equivalent to that character, ignoring case. + +Not all Unicode special cases are supported. + +This is a separate sub-package because this needs to pull in the Unicode tables in the standard library, +which can add significantly to the size of binaries. diff --git a/vendor/github.com/fvbommel/sortorder/casefolded/natsort.go b/vendor/github.com/fvbommel/sortorder/casefolded/natsort.go new file mode 100644 index 000000000..90deddac2 --- /dev/null +++ b/vendor/github.com/fvbommel/sortorder/casefolded/natsort.go @@ -0,0 +1,194 @@ +package casefolded + +import ( + "unicode" + "unicode/utf8" +) + +// Natural implements sort.Interface to sort strings in natural order. This +// means that e.g. "abc2" < "abc12". +// +// This is the simple case-folded version, +// which means that letters are considered equal if strings.SimpleFold says they are. +// For example, "abc2" < "ABC12" < "abc100" and 'k' == '\u212a' (the Kelvin symbol). +// +// Non-digit sequences and numbers are compared separately. +// The former are compared rune-by-rune using the lowest equivalent runes, +// while digits are compared numerically +// (except that the number of leading zeros is used as a tie-breaker, so e.g. "2" < "02") +// +// Limitations: +// - only ASCII digits (0-9) are considered. +// - comparisons are done on a rune-by-rune basis, +// so some special case equivalences like 'ß' == 'SS" are not supported. +// - Special cases like Turkish 'i' == 'İ' (and not regular dotless 'I') +// are not supported either. +type Natural []string + +func (n Natural) Len() int { return len(n) } +func (n Natural) Swap(i, j int) { n[i], n[j] = n[j], n[i] } +func (n Natural) Less(i, j int) bool { return NaturalLess(n[i], n[j]) } + +func isDigit(b rune) bool { return '0' <= b && b <= '9' } + +// caseFold returns the lowest-numbered rune equivalent to the parameter. +func caseFold(r rune) rune { + // Iterate until SimpleFold returns a lower value. + // This will be the lowest-numbered equivalent rune. + var prev rune = -1 + for r > prev { + prev, r = r, unicode.SimpleFold(r) + } + return r +} + +// NaturalLess compares two strings using natural ordering. This means that e.g. +// "abc2" < "abc12". +// +// This is the simple case-folded version, +// which means that letters are considered equal if strings.SimpleFold says they are. +// For example, "abc2" < "ABC12" < "abc100" and 'k' == '\u212a' (the Kelvin symbol). +// +// Non-digit sequences and numbers are compared separately. +// The former are compared rune-by-rune using the lowest equivalent runes, +// while digits are compared numerically +// (except that the number of leading zeros is used as a tie-breaker, so e.g. "2" < "02") +// +// Limitations: +// - only ASCII digits (0-9) are considered. +// - comparisons are done on a rune-by-rune basis, +// so some special case equivalences like 'ß' == 'SS" are not supported. +// - Special cases like Turkish 'i' == 'İ' (and not regular dotless 'I') +// are not supported either. +func NaturalLess(str1, str2 string) bool { + // ASCII fast path. + idx1, idx2 := 0, 0 + for idx1 < len(str1) && idx2 < len(str2) { + c1, c2 := rune(str1[idx1]), rune(str2[idx2]) + + // Bail out to full Unicode support? + if c1|c2 >= utf8.RuneSelf { + goto hasUnicode + } + + dig1, dig2 := isDigit(c1), isDigit(c2) + switch { + case dig1 != dig2: // Digits before other characters. + return dig1 // True if LHS is a digit, false if the RHS is one. + case !dig1: // && !dig2, because dig1 == dig2 + // For ASCII it suffices to normalize letters to upper-case, + // because upper-cased ASCII compares lexicographically. + // Note: this does not account for regional special cases + // like Turkish dotted capital 'İ'. + + // Canonicalize to upper-case. + c1 = unicode.ToUpper(c1) + c2 = unicode.ToUpper(c2) + // Identical upper-cased ASCII runes are equal. + if c1 == c2 { + idx1++ + idx2++ + continue + } + return c1 < c2 + default: // Digits + // Eat zeros. + for ; idx1 < len(str1) && str1[idx1] == '0'; idx1++ { + } + for ; idx2 < len(str2) && str2[idx2] == '0'; idx2++ { + } + // Eat all digits. + nonZero1, nonZero2 := idx1, idx2 + for ; idx1 < len(str1) && isDigit(rune(str1[idx1])); idx1++ { + } + for ; idx2 < len(str2) && isDigit(rune(str2[idx2])); idx2++ { + } + // If lengths of numbers with non-zero prefix differ, the shorter + // one is less. + if len1, len2 := idx1-nonZero1, idx2-nonZero2; len1 != len2 { + return len1 < len2 + } + // If they're equally long, string comparison is correct. + if nr1, nr2 := str1[nonZero1:idx1], str2[nonZero2:idx2]; nr1 != nr2 { + return nr1 < nr2 + } + // Otherwise, the one with less zeros is less. + // Because everything up to the number is equal, comparing the index + // after the zeros is sufficient. + if nonZero1 != nonZero2 { + return nonZero1 < nonZero2 + } + } + // They're identical so far, so continue comparing. + } + // So far they are identical. At least one is ended. If the other continues, + // it sorts last. + return len(str1) < len(str2) + +hasUnicode: + for idx1 < len(str1) && idx2 < len(str2) { + c1, delta1 := utf8.DecodeRuneInString(str1[idx1:]) + c2, delta2 := utf8.DecodeRuneInString(str2[idx2:]) + + dig1, dig2 := isDigit(c1), isDigit(c2) + switch { + case dig1 != dig2: // Digits before other characters. + return dig1 // True if LHS is a digit, false if the RHS is one. + case !dig1: // && !dig2, because dig1 == dig2 + idx1 += delta1 + idx2 += delta2 + // Fast path: identical runes are equal. + if c1 == c2 { + continue + } + // ASCII fast path: ASCII characters compare by their upper-case equivalent (if any) + // because 'A' < 'a', so upper-case them. + if c1 <= unicode.MaxASCII && c2 <= unicode.MaxASCII { + c1 = unicode.ToUpper(c1) + c2 = unicode.ToUpper(c2) + if c1 != c2 { + return c1 < c2 + } + continue + } + // Compare lowest equivalent characters. + c1 = caseFold(c1) + c2 = caseFold(c2) + if c1 == c2 { + continue + } + return c1 < c2 + default: // Digits + // Eat zeros. + for ; idx1 < len(str1) && str1[idx1] == '0'; idx1++ { + } + for ; idx2 < len(str2) && str2[idx2] == '0'; idx2++ { + } + // Eat all digits. + nonZero1, nonZero2 := idx1, idx2 + for ; idx1 < len(str1) && isDigit(rune(str1[idx1])); idx1++ { + } + for ; idx2 < len(str2) && isDigit(rune(str2[idx2])); idx2++ { + } + // If lengths of numbers with non-zero prefix differ, the shorter + // one is less. + if len1, len2 := idx1-nonZero1, idx2-nonZero2; len1 != len2 { + return len1 < len2 + } + // If they're equally long, string comparison is correct. + if nr1, nr2 := str1[nonZero1:idx1], str2[nonZero2:idx2]; nr1 != nr2 { + return nr1 < nr2 + } + // Otherwise, the one with less zeros is less. + // Because everything up to the number is equal, comparing the index + // after the zeros is sufficient. + if nonZero1 != nonZero2 { + return nonZero1 < nonZero2 + } + } + // They're identical so far, so continue comparing. + } + // So far they are identical. At least one is ended. If the other continues, + // it sorts last. + return len(str1[idx1:]) < len(str2[idx2:]) +} diff --git a/vendor/github.com/fvbommel/sortorder/doc.go b/vendor/github.com/fvbommel/sortorder/doc.go deleted file mode 100644 index a7dd9585d..000000000 --- a/vendor/github.com/fvbommel/sortorder/doc.go +++ /dev/null @@ -1,5 +0,0 @@ -// Package sortorder implements sort orders and comparison functions. -// -// Currently, it only implements so-called "natural order", where integers -// embedded in strings are compared by value. -package sortorder // import "github.com/fvbommel/sortorder" diff --git a/vendor/github.com/fvbommel/sortorder/natsort.go b/vendor/github.com/fvbommel/sortorder/natsort.go deleted file mode 100644 index 66a52c712..000000000 --- a/vendor/github.com/fvbommel/sortorder/natsort.go +++ /dev/null @@ -1,76 +0,0 @@ -package sortorder - -// Natural implements sort.Interface to sort strings in natural order. This -// means that e.g. "abc2" < "abc12". -// -// Non-digit sequences and numbers are compared separately. The former are -// compared bytewise, while the latter are compared numerically (except that -// the number of leading zeros is used as a tie-breaker, so e.g. "2" < "02") -// -// Limitation: only ASCII digits (0-9) are considered. -type Natural []string - -func (n Natural) Len() int { return len(n) } -func (n Natural) Swap(i, j int) { n[i], n[j] = n[j], n[i] } -func (n Natural) Less(i, j int) bool { return NaturalLess(n[i], n[j]) } - -func isdigit(b byte) bool { return '0' <= b && b <= '9' } - -// NaturalLess compares two strings using natural ordering. This means that e.g. -// "abc2" < "abc12". -// -// Non-digit sequences and numbers are compared separately. The former are -// compared bytewise, while the latter are compared numerically (except that -// the number of leading zeros is used as a tie-breaker, so e.g. "2" < "02") -// -// Limitation: only ASCII digits (0-9) are considered. -func NaturalLess(str1, str2 string) bool { - idx1, idx2 := 0, 0 - for idx1 < len(str1) && idx2 < len(str2) { - c1, c2 := str1[idx1], str2[idx2] - dig1, dig2 := isdigit(c1), isdigit(c2) - switch { - case dig1 != dig2: // Digits before other characters. - return dig1 // True if LHS is a digit, false if the RHS is one. - case !dig1: // && !dig2, because dig1 == dig2 - // UTF-8 compares bytewise-lexicographically, no need to decode - // codepoints. - if c1 != c2 { - return c1 < c2 - } - idx1++ - idx2++ - default: // Digits - // Eat zeros. - for ; idx1 < len(str1) && str1[idx1] == '0'; idx1++ { - } - for ; idx2 < len(str2) && str2[idx2] == '0'; idx2++ { - } - // Eat all digits. - nonZero1, nonZero2 := idx1, idx2 - for ; idx1 < len(str1) && isdigit(str1[idx1]); idx1++ { - } - for ; idx2 < len(str2) && isdigit(str2[idx2]); idx2++ { - } - // If lengths of numbers with non-zero prefix differ, the shorter - // one is less. - if len1, len2 := idx1-nonZero1, idx2-nonZero2; len1 != len2 { - return len1 < len2 - } - // If they're equal, string comparison is correct. - if nr1, nr2 := str1[nonZero1:idx1], str2[nonZero2:idx2]; nr1 != nr2 { - return nr1 < nr2 - } - // Otherwise, the one with less zeros is less. - // Because everything up to the number is equal, comparing the index - // after the zeros is sufficient. - if nonZero1 != nonZero2 { - return nonZero1 < nonZero2 - } - } - // They're identical so far, so continue comparing. - } - // So far they are identical. At least one is ended. If the other continues, - // it sorts last. - return len(str1) < len(str2) -} diff --git a/vendor/modules.txt b/vendor/modules.txt index bc780c6a5..c7b53d4f0 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -143,9 +143,9 @@ github.com/doug-martin/goqu/v9/sqlgen # github.com/fsnotify/fsnotify v1.5.1 ## explicit; go 1.13 github.com/fsnotify/fsnotify -# github.com/fvbommel/sortorder v1.0.2 +# github.com/fvbommel/sortorder v1.1.0 ## explicit; go 1.13 -github.com/fvbommel/sortorder +github.com/fvbommel/sortorder/casefolded # github.com/go-chi/chi v4.0.2+incompatible ## explicit github.com/go-chi/chi From 32cefea524c74daa7e84f1442d3228d5cbbf5309 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 17 Apr 2023 15:27:25 +1000 Subject: [PATCH 02/81] Log errors returned from graphql (#3562) * Add func methods to logger * Log errors returned from the graphql interface * Log authentication * Log when credentials changed --- internal/api/error.go | 39 ++++++++++++++++++ internal/api/resolver_mutation_configure.go | 12 +++++- internal/api/server.go | 2 + internal/api/session.go | 10 ++++- internal/log/logger.go | 30 ++++++++++++++ pkg/logger/basic.go | 25 ++++++++++++ pkg/logger/logger.go | 45 +++++++++++++++++++++ pkg/session/session.go | 18 ++++++++- 8 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 internal/api/error.go diff --git a/internal/api/error.go b/internal/api/error.go new file mode 100644 index 000000000..85d9cde28 --- /dev/null +++ b/internal/api/error.go @@ -0,0 +1,39 @@ +package api + +import ( + "context" + "encoding/json" + + "github.com/99designs/gqlgen/graphql" + "github.com/stashapp/stash/pkg/logger" + "github.com/vektah/gqlparser/v2/gqlerror" +) + +func gqlErrorHandler(ctx context.Context, e error) *gqlerror.Error { + // log all errors - for now just log the error message + // we can potentially add more context later + fc := graphql.GetFieldContext(ctx) + if fc != nil { + logger.Errorf("%s: %v", fc.Path(), e) + + // log the args in debug level + logger.DebugFunc(func() (string, []interface{}) { + var args interface{} + args = fc.Args + + s, _ := json.Marshal(args) + if len(s) > 0 { + args = string(s) + } + + return "%s: %v", []interface{}{ + fc.Path(), + args, + } + }) + } + + // we may also want to transform the error message for the response + // for now just return the original error + return graphql.DefaultErrorPresenter(ctx, e) +} diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index fc46bc323..824f9e6d7 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -228,8 +228,13 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen c.Set(config.GalleryCoverRegex, *input.GalleryCoverRegex) } - if input.Username != nil { + if input.Username != nil && *input.Username != c.GetUsername() { c.Set(config.Username, input.Username) + if *input.Password == "" { + logger.Info("Username cleared") + } else { + logger.Info("Username changed") + } } if input.Password != nil { @@ -238,6 +243,11 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen currentPWHash := c.GetPasswordHash() if *input.Password != currentPWHash { + if *input.Password == "" { + logger.Info("Password cleared") + } else { + logger.Info("Password changed") + } c.SetPassword(*input.Password) } } diff --git a/internal/api/server.go b/internal/api/server.go index c8e8a7b28..26d81d5db 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -120,6 +120,8 @@ func Start() error { gqlSrv.SetQueryCache(gqlLru.New(1000)) gqlSrv.Use(gqlExtension.Introspection{}) + gqlSrv.SetErrorPresenter(gqlErrorHandler) + gqlHandlerFunc := func(w http.ResponseWriter, r *http.Request) { gqlSrv.ServeHTTP(w, r) } diff --git a/internal/api/session.go b/internal/api/session.go index 785d93381..8bdb680e3 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -9,6 +9,7 @@ import ( "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/session" ) @@ -61,7 +62,14 @@ func handleLogin(loginUIBox embed.FS) http.HandlerFunc { } err := manager.GetInstance().SessionStore.Login(w, r) - if errors.Is(err, session.ErrInvalidCredentials) { + if err != nil { + // always log the error + logger.Errorf("Error logging in: %v", err) + } + + var invalidCredentialsError *session.InvalidCredentialsError + + if errors.As(err, &invalidCredentialsError) { // redirect back to the login page with an error redirectToLogin(loginUIBox, w, url, "Username or password is invalid") return diff --git a/internal/log/logger.go b/internal/log/logger.go index fab6a070c..50f5a42b4 100644 --- a/internal/log/logger.go +++ b/internal/log/logger.go @@ -235,6 +235,13 @@ func (log *Logger) Tracef(format string, args ...interface{}) { log.addLogItem(l) } +func (log *Logger) TraceFunc(fn func() (string, []interface{})) { + if log.logger.Level >= logrus.TraceLevel { + msg, args := fn() + log.Tracef(msg, args...) + } +} + func (log *Logger) Debug(args ...interface{}) { log.logger.Debug(args...) l := &LogItem{ @@ -253,6 +260,17 @@ func (log *Logger) Debugf(format string, args ...interface{}) { log.addLogItem(l) } +func (log *Logger) logFunc(level logrus.Level, logFn func(format string, args ...interface{}), fn func() (string, []interface{})) { + if log.logger.Level >= level { + msg, args := fn() + logFn(msg, args...) + } +} + +func (log *Logger) DebugFunc(fn func() (string, []interface{})) { + log.logFunc(logrus.DebugLevel, log.logger.Debugf, fn) +} + func (log *Logger) Info(args ...interface{}) { log.logger.Info(args...) l := &LogItem{ @@ -271,6 +289,10 @@ func (log *Logger) Infof(format string, args ...interface{}) { log.addLogItem(l) } +func (log *Logger) InfoFunc(fn func() (string, []interface{})) { + log.logFunc(logrus.InfoLevel, log.logger.Infof, fn) +} + func (log *Logger) Warn(args ...interface{}) { log.logger.Warn(args...) l := &LogItem{ @@ -289,6 +311,10 @@ func (log *Logger) Warnf(format string, args ...interface{}) { log.addLogItem(l) } +func (log *Logger) WarnFunc(fn func() (string, []interface{})) { + log.logFunc(logrus.WarnLevel, log.logger.Warnf, fn) +} + func (log *Logger) Error(args ...interface{}) { log.logger.Error(args...) l := &LogItem{ @@ -307,6 +333,10 @@ func (log *Logger) Errorf(format string, args ...interface{}) { log.addLogItem(l) } +func (log *Logger) ErrorFunc(fn func() (string, []interface{})) { + log.logFunc(logrus.ErrorLevel, log.logger.Errorf, fn) +} + func (log *Logger) Fatal(args ...interface{}) { log.logger.Fatal(args...) } diff --git a/pkg/logger/basic.go b/pkg/logger/basic.go index d872777d5..3995eddfe 100644 --- a/pkg/logger/basic.go +++ b/pkg/logger/basic.go @@ -31,6 +31,11 @@ func (log *BasicLogger) Tracef(format string, args ...interface{}) { log.printf("Trace", format, args...) } +func (log *BasicLogger) TraceFunc(fn func() (string, []interface{})) { + format, args := fn() + log.printf("Trace", format, args...) +} + func (log *BasicLogger) Debug(args ...interface{}) { log.print("Debug", args...) } @@ -39,6 +44,11 @@ func (log *BasicLogger) Debugf(format string, args ...interface{}) { log.printf("Debug", format, args...) } +func (log *BasicLogger) DebugFunc(fn func() (string, []interface{})) { + format, args := fn() + log.printf("Debug", format, args...) +} + func (log *BasicLogger) Info(args ...interface{}) { log.print("Info", args...) } @@ -47,6 +57,11 @@ func (log *BasicLogger) Infof(format string, args ...interface{}) { log.printf("Info", format, args...) } +func (log *BasicLogger) InfoFunc(fn func() (string, []interface{})) { + format, args := fn() + log.printf("Info", format, args...) +} + func (log *BasicLogger) Warn(args ...interface{}) { log.print("Warn", args...) } @@ -55,6 +70,11 @@ func (log *BasicLogger) Warnf(format string, args ...interface{}) { log.printf("Warn", format, args...) } +func (log *BasicLogger) WarnFunc(fn func() (string, []interface{})) { + format, args := fn() + log.printf("Warn", format, args...) +} + func (log *BasicLogger) Error(args ...interface{}) { log.print("Error", args...) } @@ -63,6 +83,11 @@ func (log *BasicLogger) Errorf(format string, args ...interface{}) { log.printf("Error", format, args...) } +func (log *BasicLogger) ErrorFunc(fn func() (string, []interface{})) { + format, args := fn() + log.printf("Error", format, args...) +} + func (log *BasicLogger) Fatal(args ...interface{}) { log.print("Fatal", args...) os.Exit(1) diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index f97faf9f8..12ddd5053 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -16,18 +16,23 @@ type LoggerImpl interface { Trace(args ...interface{}) Tracef(format string, args ...interface{}) + TraceFunc(fn func() (string, []interface{})) Debug(args ...interface{}) Debugf(format string, args ...interface{}) + DebugFunc(fn func() (string, []interface{})) Info(args ...interface{}) Infof(format string, args ...interface{}) + InfoFunc(fn func() (string, []interface{})) Warn(args ...interface{}) Warnf(format string, args ...interface{}) + WarnFunc(fn func() (string, []interface{})) Error(args ...interface{}) Errorf(format string, args ...interface{}) + ErrorFunc(fn func() (string, []interface{})) Fatal(args ...interface{}) Fatalf(format string, args ...interface{}) @@ -61,6 +66,14 @@ func Tracef(format string, args ...interface{}) { } } +// TraceFunc calls TraceFunc with the Logger registered using RegisterLogger. +// If no logger has been registered, then this function is a no-op. +func TraceFunc(fn func() (string, []interface{})) { + if Logger != nil { + Logger.TraceFunc(fn) + } +} + // Debug calls Debug with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func Debug(args ...interface{}) { @@ -77,6 +90,14 @@ func Debugf(format string, args ...interface{}) { } } +// DebugFunc calls DebugFunc with the Logger registered using RegisterLogger. +// If no logger has been registered, then this function is a no-op. +func DebugFunc(fn func() (string, []interface{})) { + if Logger != nil { + Logger.DebugFunc(fn) + } +} + // Info calls Info with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func Info(args ...interface{}) { @@ -93,6 +114,14 @@ func Infof(format string, args ...interface{}) { } } +// InfoFunc calls InfoFunc with the Logger registered using RegisterLogger. +// If no logger has been registered, then this function is a no-op. +func InfoFunc(fn func() (string, []interface{})) { + if Logger != nil { + Logger.InfoFunc(fn) + } +} + // Warn calls Warn with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func Warn(args ...interface{}) { @@ -109,6 +138,14 @@ func Warnf(format string, args ...interface{}) { } } +// WarnFunc calls WarnFunc with the Logger registered using RegisterLogger. +// If no logger has been registered, then this function is a no-op. +func WarnFunc(fn func() (string, []interface{})) { + if Logger != nil { + Logger.WarnFunc(fn) + } +} + // Error calls Error with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func Error(args ...interface{}) { @@ -125,6 +162,14 @@ func Errorf(format string, args ...interface{}) { } } +// ErrorFunc calls ErrorFunc with the Logger registered using RegisterLogger. +// If no logger has been registered, then this function is a no-op. +func ErrorFunc(fn func() (string, []interface{})) { + if Logger != nil { + Logger.ErrorFunc(fn) + } +} + // Fatal calls Fatal with the Logger registered using RegisterLogger. // If no logger has been registered, then this function is a no-op. func Fatal(args ...interface{}) { diff --git a/pkg/session/session.go b/pkg/session/session.go index 76a0c0520..9fcb87549 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -34,7 +34,15 @@ const ( passwordFormKey = "password" ) -var ErrInvalidCredentials = errors.New("invalid username or password") +type InvalidCredentialsError struct { + Username string +} + +func (e InvalidCredentialsError) Error() string { + // don't leak the username + return "invalid credentials" +} + var ErrUnauthorized = errors.New("unauthorized") type Store struct { @@ -63,9 +71,12 @@ func (s *Store) Login(w http.ResponseWriter, r *http.Request) error { // authenticate the user if !s.config.ValidateCredentials(username, password) { - return ErrInvalidCredentials + return &InvalidCredentialsError{Username: username} } + // since we only have one user, don't leak the name + logger.Info("User logged in") + newSession.Values[userIDKey] = username err := newSession.Save(r, w) @@ -90,6 +101,9 @@ func (s *Store) Logout(w http.ResponseWriter, r *http.Request) error { return err } + // since we only have one user, don't leak the name + logger.Infof("User logged out") + return nil } From 6a6545305c3f413be708729f7e912bb2d6ce509c Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 17 Apr 2023 15:28:00 +1000 Subject: [PATCH 03/81] Use 100% width on cards in mobile (#3576) --- ui/v2.5/src/components/Galleries/styles.scss | 4 ++++ ui/v2.5/src/components/Images/styles.scss | 4 ++++ ui/v2.5/src/components/Performers/styles.scss | 4 ++++ ui/v2.5/src/components/Scenes/styles.scss | 8 ++++++++ ui/v2.5/src/components/Tags/styles.scss | 4 ++++ 5 files changed, 24 insertions(+) diff --git a/ui/v2.5/src/components/Galleries/styles.scss b/ui/v2.5/src/components/Galleries/styles.scss index 7aa1bf052..cb88a360e 100644 --- a/ui/v2.5/src/components/Galleries/styles.scss +++ b/ui/v2.5/src/components/Galleries/styles.scss @@ -26,6 +26,10 @@ overflow: hidden; padding: 0; padding-bottom: 1rem; + + @media (max-width: 576px) { + width: 100%; + } } .card-section { diff --git a/ui/v2.5/src/components/Images/styles.scss b/ui/v2.5/src/components/Images/styles.scss index 9216bc92d..891943b15 100644 --- a/ui/v2.5/src/components/Images/styles.scss +++ b/ui/v2.5/src/components/Images/styles.scss @@ -17,6 +17,10 @@ &.card { overflow: hidden; padding: 0; + + @media (max-width: 576px) { + width: 100%; + } } .rating-banner { diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index a314ea991..ecf2ffbe1 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -68,6 +68,10 @@ .performer-card { width: 20rem; + @media (max-width: 576px) { + width: 100%; + } + .thumbnail-section { position: relative; } diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index 53b1a205b..31e5de8d1 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -89,6 +89,10 @@ textarea.scene-description { .studio-card { padding: 0.5rem; + @media (max-width: 576px) { + width: 100%; + } + &-header { height: 150px; line-height: 150px; @@ -241,6 +245,10 @@ textarea.scene-description { overflow: hidden; padding: 0; + @media (max-width: 576px) { + width: 100%; + } + &.fileless { background-color: darken($card-bg, 5%); } diff --git a/ui/v2.5/src/components/Tags/styles.scss b/ui/v2.5/src/components/Tags/styles.scss index ba325207e..d5aeccc7c 100644 --- a/ui/v2.5/src/components/Tags/styles.scss +++ b/ui/v2.5/src/components/Tags/styles.scss @@ -22,6 +22,10 @@ .tag-card { padding: 0.5rem; + @media (max-width: 576px) { + width: 100%; + } + &-image { display: block; margin: 0 auto; From aebb8b07dfed72613b223306ce183ac79bcb6ca5 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 17 Apr 2023 15:28:32 +1000 Subject: [PATCH 04/81] Embed default icons (#3577) * Move tag svg to embed * Update doc * Embed default studio image --- internal/api/routes_studio.go | 11 ++- internal/api/routes_tag.go | 11 ++- internal/static/embed.go | 6 ++ internal/static/studio/studio.svg | 7 ++ internal/static/tag/tag.svg | 67 ++++++++++++++++ pkg/models/model_studio.go | 2 - pkg/models/model_tag.go | 129 ------------------------------ pkg/sqlite/setup_test.go | 2 +- 8 files changed, 101 insertions(+), 134 deletions(-) create mode 100644 internal/static/studio/studio.svg create mode 100644 internal/static/tag/tag.svg diff --git a/internal/api/routes_studio.go b/internal/api/routes_studio.go index a77763e8d..85c66e199 100644 --- a/internal/api/routes_studio.go +++ b/internal/api/routes_studio.go @@ -3,10 +3,12 @@ package api import ( "context" "errors" + "io" "net/http" "strconv" "github.com/go-chi/chi" + "github.com/stashapp/stash/internal/static" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/studio" @@ -55,7 +57,14 @@ func (rs studioRoutes) Image(w http.ResponseWriter, r *http.Request) { } if len(image) == 0 { - image, _ = utils.ProcessBase64Image(models.DefaultStudioImage) + const defaultStudioImage = "studio/studio.svg" + + // fall back to static image + f, _ := static.Studio.Open(defaultStudioImage) + defer f.Close() + stat, _ := f.Stat() + http.ServeContent(w, r, "studio.svg", stat.ModTime(), f.(io.ReadSeeker)) + return } if err := utils.ServeImage(image, w, r); err != nil { diff --git a/internal/api/routes_tag.go b/internal/api/routes_tag.go index e3ee439e9..4c0ff43b8 100644 --- a/internal/api/routes_tag.go +++ b/internal/api/routes_tag.go @@ -3,10 +3,12 @@ package api import ( "context" "errors" + "io" "net/http" "strconv" "github.com/go-chi/chi" + "github.com/stashapp/stash/internal/static" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/tag" @@ -55,7 +57,14 @@ func (rs tagRoutes) Image(w http.ResponseWriter, r *http.Request) { } if len(image) == 0 { - image = models.DefaultTagImage + const defaultTagImage = "tag/tag.svg" + + // fall back to static image + f, _ := static.Tag.Open(defaultTagImage) + defer f.Close() + stat, _ := f.Stat() + http.ServeContent(w, r, "tag.svg", stat.ModTime(), f.(io.ReadSeeker)) + return } if err := utils.ServeImage(image, w, r); err != nil { diff --git a/internal/static/embed.go b/internal/static/embed.go index bc49aec12..9be76afa4 100644 --- a/internal/static/embed.go +++ b/internal/static/embed.go @@ -13,3 +13,9 @@ var Scene embed.FS //go:embed image var Image embed.FS + +//go:embed tag +var Tag embed.FS + +//go:embed studio +var Studio embed.FS diff --git a/internal/static/studio/studio.svg b/internal/static/studio/studio.svg new file mode 100644 index 000000000..bea97aa9f --- /dev/null +++ b/internal/static/studio/studio.svg @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/internal/static/tag/tag.svg b/internal/static/tag/tag.svg new file mode 100644 index 000000000..3e5eb4999 --- /dev/null +++ b/internal/static/tag/tag.svg @@ -0,0 +1,67 @@ + + + + + + + + image/svg+xml + + + + + + + + + \ No newline at end of file diff --git a/pkg/models/model_studio.go b/pkg/models/model_studio.go index 989415293..fed4fafa3 100644 --- a/pkg/models/model_studio.go +++ b/pkg/models/model_studio.go @@ -37,8 +37,6 @@ type StudioPartial struct { IgnoreAutoTag *bool `db:"ignore_auto_tag" json:"ignore_auto_tag"` } -var DefaultStudioImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA3XAAAN1wFCKJt4AAAAB3RJTUUH4wgVBQsJl1CMZAAAASJJREFUeNrt3N0JwyAYhlEj3cj9R3Cm5rbkqtAP+qrnGaCYHPwJpLlaa++mmLpbAERAgAgIEAEBIiBABERAgAgIEAEBIiBABERAgAgIEAHZuVflj40x4i94zhk9vqsVvEq6AsQqMP1EjORx20OACAgQRRx7T+zzcFBxcjNDfoB4ntQqTm5Awo7MlqywZxcgYQ+RlqywJ3ozJAQCSBiEJSsQA0gYBpDAgAARECACAkRAgAgIEAERECACAmSjUv6eAOSB8m8YIGGzBUjYbAESBgMkbBkDEjZbgITBAClcxiqQvEoatreYIWEBASIgJ4Gkf11ntXH3nS9uxfGWfJ5J9hAgAgJEQAQEiIAAERAgAgJEQAQEiIAAERAgAgJEQAQEiL7qBuc6RKLHxr0CAAAAAElFTkSuQmCC" - func NewStudio(name string) *Studio { currentTime := time.Now() return &Studio{ diff --git a/pkg/models/model_tag.go b/pkg/models/model_tag.go index b12574155..f57bf199e 100644 --- a/pkg/models/model_tag.go +++ b/pkg/models/model_tag.go @@ -58,132 +58,3 @@ func (t *TagPaths) Append(o interface{}) { func (t *TagPaths) New() interface{} { return &TagPath{} } - -// Original Tag image from: https://fontawesome.com/icons/tag?style=solid -// Modified to change color and rotate -// Licensed under CC Attribution 4.0: https://fontawesome.com/license -var DefaultTagImage = []byte(` - - - - - - image/svg+xml - - - - - - - - -`) - -// var DefaultTagImage = []byte(` -// -// -// -// -// -// image/svg+xml -// -// -// -// -// -// -// -// -// `) diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 4300111cf..affe3cd72 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1726,5 +1726,5 @@ func linkTagsParent(ctx context.Context, qb models.TagReaderWriter) error { } func addTagImage(ctx context.Context, qb models.TagWriter, tagIndex int) error { - return qb.UpdateImage(ctx, tagIDs[tagIndex], models.DefaultTagImage) + return qb.UpdateImage(ctx, tagIDs[tagIndex], []byte("image")) } From 5711ff6d21a10d66847ee0686b0280d3eed03d0d Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 17 Apr 2023 15:29:01 +1000 Subject: [PATCH 05/81] Require source selector click to show (#3578) --- ui/v2.5/src/components/ScenePlayer/styles.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/v2.5/src/components/ScenePlayer/styles.scss b/ui/v2.5/src/components/ScenePlayer/styles.scss index 8e717892f..7d324fc69 100644 --- a/ui/v2.5/src/components/ScenePlayer/styles.scss +++ b/ui/v2.5/src/components/ScenePlayer/styles.scss @@ -170,6 +170,10 @@ $sceneTabWidth: 450px; } .vjs-source-selector { + &.vjs-hover .vjs-menu { + display: none; + } + .vjs-menu li { font-size: 0.8em; } From 62a1bc22c997e9c42da6a4a5feece09c96cd5506 Mon Sep 17 00:00:00 2001 From: halorrr Date: Mon, 17 Apr 2023 01:30:00 -0400 Subject: [PATCH 06/81] add conditionals to avoid url base duplication on stashbox submit (#3579) --- pkg/scraper/stashbox/stash_box.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index 9d8e65d0c..b8eadfd1b 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -10,6 +10,7 @@ import ( "io" "mime/multipart" "net/http" + "regexp" "strconv" "strings" @@ -1046,10 +1047,20 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf var urls []string if len(strings.TrimSpace(performer.Twitter)) > 0 { - urls = append(urls, "https://twitter.com/"+strings.TrimSpace(performer.Twitter)) + reg := regexp.MustCompile(`https?:\/\/(?:www\.)?twitter\.com`) + if reg.MatchString(performer.Twitter) { + urls = append(urls, strings.TrimSpace(performer.Twitter)) + } else { + urls = append(urls, "https://twitter.com/"+strings.TrimSpace(performer.Twitter)) + } } if len(strings.TrimSpace(performer.Instagram)) > 0 { - urls = append(urls, "https://instagram.com/"+strings.TrimSpace(performer.Instagram)) + reg := regexp.MustCompile(`https?:\/\/(?:www\.)?instagram\.com`) + if reg.MatchString(performer.Instagram) { + urls = append(urls, strings.TrimSpace(performer.Instagram)) + } else { + urls = append(urls, "https://instagram.com/"+strings.TrimSpace(performer.Instagram)) + } } if len(strings.TrimSpace(performer.URL)) > 0 { urls = append(urls, strings.TrimSpace(performer.URL)) From dcc73c487363ef512880f34df1264132e2bd0b68 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 17 Apr 2023 15:36:51 +1000 Subject: [PATCH 07/81] Phash distance filter (#3596) * Add phash_distance filter criterion * Add distance to phash filter in UI --- graphql/schema/types/filters.graphql | 10 ++- pkg/models/filter.go | 6 ++ pkg/models/scene.go | 2 + pkg/sqlite/driver.go | 1 + pkg/sqlite/phash.go | 10 +++ pkg/sqlite/scene.go | 52 ++++++++++++--- .../src/components/List/CriterionEditor.tsx | 7 +++ .../components/List/Filters/PhashFilter.tsx | 63 +++++++++++++++++++ .../models/list-filter/criteria/criterion.ts | 4 +- .../models/list-filter/criteria/factory.ts | 4 +- .../src/models/list-filter/criteria/phash.ts | 35 +++++++++-- ui/v2.5/src/models/list-filter/types.ts | 5 ++ ui/v2.5/src/utils/navigation.ts | 2 +- 13 files changed, 184 insertions(+), 17 deletions(-) create mode 100644 pkg/sqlite/phash.go create mode 100644 ui/v2.5/src/components/List/Filters/PhashFilter.tsx diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 9e124e49e..75d1e6f2d 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -165,7 +165,9 @@ input SceneFilterType { """Filter by file checksum""" checksum: StringCriterionInput """Filter by file phash""" - phash: StringCriterionInput + phash: StringCriterionInput @deprecated(reason: "Use phash_distance instead") + """Filter by file phash distance""" + phash_distance: PhashDistanceCriterionInput """Filter by path""" path: StringCriterionInput """Filter by file count""" @@ -527,6 +529,12 @@ input TimestampCriterionInput { modifier: CriterionModifier! } +input PhashDistanceCriterionInput { + value: String! + modifier: CriterionModifier! + distance: Int +} + enum FilterMode { SCENES, PERFORMERS, diff --git a/pkg/models/filter.go b/pkg/models/filter.go index d614f262e..47e93f237 100644 --- a/pkg/models/filter.go +++ b/pkg/models/filter.go @@ -136,3 +136,9 @@ type TimestampCriterionInput struct { Value2 *string `json:"value2"` Modifier CriterionModifier `json:"modifier"` } + +type PhashDistanceCriterionInput struct { + Value string `json:"value"` + Modifier CriterionModifier `json:"modifier"` + Distance *int `json:"distance"` +} diff --git a/pkg/models/scene.go b/pkg/models/scene.go index 55a27606a..cc503fa92 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -27,6 +27,8 @@ type SceneFilterType struct { Checksum *StringCriterionInput `json:"checksum"` // Filter by file phash Phash *StringCriterionInput `json:"phash"` + // Filter by phash distance + PhashDistance *PhashDistanceCriterionInput `json:"phash_distance"` // Filter by path Path *StringCriterionInput `json:"path"` // Filter by file count diff --git a/pkg/sqlite/driver.go b/pkg/sqlite/driver.go index c67379d1b..d70676813 100644 --- a/pkg/sqlite/driver.go +++ b/pkg/sqlite/driver.go @@ -29,6 +29,7 @@ func (d *CustomSQLiteDriver) Open(dsn string) (driver.Conn, error) { "regexp": regexFn, "durationToTinyInt": durationToTinyIntFn, "basename": basenameFn, + "phash_distance": phashDistanceFn, } for name, fn := range funcs { diff --git a/pkg/sqlite/phash.go b/pkg/sqlite/phash.go new file mode 100644 index 000000000..ceda69bd4 --- /dev/null +++ b/pkg/sqlite/phash.go @@ -0,0 +1,10 @@ +package sqlite + +import "github.com/corona10/goimagehash" + +func phashDistanceFn(phash1 int64, phash2 int64) (int64, error) { + hash1 := goimagehash.NewImageHash(uint64(phash1), goimagehash.PHash) + hash2 := goimagehash.NewImageHash(uint64(phash2), goimagehash.PHash) + distance, _ := hash1.Distance(hash2) + return int64(distance), nil +} diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index ee58cb0e2..e478e4477 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -882,17 +882,16 @@ func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneF query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if sceneFilter.Phash != nil { - qb.addSceneFilesTable(f) - f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") - - value, _ := utils.StringToPhash(sceneFilter.Phash.Value) - intCriterionHandler(&models.IntCriterionInput{ - Value: int(value), + // backwards compatibility + scenePhashDistanceCriterionHandler(qb, &models.PhashDistanceCriterionInput{ + Value: sceneFilter.Phash.Value, Modifier: sceneFilter.Phash.Modifier, - }, "fingerprints_phash.fingerprint", nil)(ctx, f) + })(ctx, f) } })) + query.handleCriterion(ctx, scenePhashDistanceCriterionHandler(qb, sceneFilter.PhashDistance)) + query.handleCriterion(ctx, intCriterionHandler(sceneFilter.Rating100, "scenes.rating", nil)) // legacy rating handler query.handleCriterion(ctx, rating5CriterionHandler(sceneFilter.Rating, "scenes.rating", nil)) @@ -1382,6 +1381,45 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id } } +func scenePhashDistanceCriterionHandler(qb *SceneStore, phashDistance *models.PhashDistanceCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if phashDistance != nil { + qb.addSceneFilesTable(f) + f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") + + value, _ := utils.StringToPhash(phashDistance.Value) + distance := 0 + if phashDistance.Distance != nil { + distance = *phashDistance.Distance + } + + if distance == 0 { + // use the default handler + intCriterionHandler(&models.IntCriterionInput{ + Value: int(value), + Modifier: phashDistance.Modifier, + }, "fingerprints_phash.fingerprint", nil)(ctx, f) + } + + switch { + case phashDistance.Modifier == models.CriterionModifierEquals && distance > 0: + // needed to avoid a type mismatch + f.addWhere("typeof(fingerprints_phash.fingerprint) = 'integer'") + f.addWhere("phash_distance(fingerprints_phash.fingerprint, ?) < ?", value, distance) + case phashDistance.Modifier == models.CriterionModifierNotEquals && distance > 0: + // needed to avoid a type mismatch + f.addWhere("typeof(fingerprints_phash.fingerprint) = 'integer'") + f.addWhere("phash_distance(fingerprints_phash.fingerprint, ?) > ?", value, distance) + default: + intCriterionHandler(&models.IntCriterionInput{ + Value: int(value), + Modifier: phashDistance.Modifier, + }, "fingerprints_phash.fingerprint", nil)(ctx, f) + } + } + } +} + func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindFilterType) { if findFilter == nil || findFilter.Sort == nil || *findFilter.Sort == "" { return diff --git a/ui/v2.5/src/components/List/CriterionEditor.tsx b/ui/v2.5/src/components/List/CriterionEditor.tsx index 7e7e5c636..aac0c00f8 100644 --- a/ui/v2.5/src/components/List/CriterionEditor.tsx +++ b/ui/v2.5/src/components/List/CriterionEditor.tsx @@ -38,6 +38,8 @@ import { RatingFilter } from "./Filters/RatingFilter"; import { BooleanFilter } from "./Filters/BooleanFilter"; import { OptionsListFilter } from "./Filters/OptionsListFilter"; import { PathFilter } from "./Filters/PathFilter"; +import { PhashCriterion } from "src/models/list-filter/criteria/phash"; +import { PhashFilter } from "./Filters/PhashFilter"; interface IGenericCriterionEditor { criterion: Criterion; @@ -172,6 +174,11 @@ const GenericCriterionEditor: React.FC = ({ ); } + if (criterion instanceof PhashCriterion) { + return ( + + ); + } if ( criterion instanceof CountryCriterion && (criterion.modifier === CriterionModifier.Equals || diff --git a/ui/v2.5/src/components/List/Filters/PhashFilter.tsx b/ui/v2.5/src/components/List/Filters/PhashFilter.tsx new file mode 100644 index 000000000..988f813b9 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/PhashFilter.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; +import { IPhashDistanceValue } from "../../../models/list-filter/types"; +import { Criterion } from "../../../models/list-filter/criteria/criterion"; +import { CriterionModifier } from "src/core/generated-graphql"; + +interface IPhashFilterProps { + criterion: Criterion; + onValueChanged: (value: IPhashDistanceValue) => void; +} + +export const PhashFilter: React.FC = ({ + criterion, + onValueChanged, +}) => { + const intl = useIntl(); + const { value } = criterion; + + function valueChanged(event: React.ChangeEvent) { + onValueChanged({ + value: event.target.value, + distance: criterion.value.distance, + }); + } + + function distanceChanged(event: React.ChangeEvent) { + let distance = parseInt(event.target.value); + if (distance < 0 || isNaN(distance)) { + distance = 0; + } + + onValueChanged({ + distance, + value: criterion.value.value, + }); + } + + return ( +
+ + + + {criterion.modifier !== CriterionModifier.IsNull && + criterion.modifier !== CriterionModifier.NotNull && ( + + + + )} +
+ ); +}; diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index a4b53dec7..7dc299a77 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -22,6 +22,7 @@ import { IStashIDValue, IDateValue, ITimestampValue, + IPhashDistanceValue, } from "../types"; export type Option = string | number | IOptionType; @@ -32,7 +33,8 @@ export type CriterionValue = | INumberValue | IStashIDValue | IDateValue - | ITimestampValue; + | ITimestampValue + | IPhashDistanceValue; const modifierMessageIDs = { [CriterionModifier.Equals]: "criterion_modifier.equals", diff --git a/ui/v2.5/src/models/list-filter/criteria/factory.ts b/ui/v2.5/src/models/list-filter/criteria/factory.ts index 28bec371b..f6c96cab8 100644 --- a/ui/v2.5/src/models/list-filter/criteria/factory.ts +++ b/ui/v2.5/src/models/list-filter/criteria/factory.ts @@ -48,7 +48,7 @@ import { MoviesCriterionOption } from "./movies"; import { GalleriesCriterion } from "./galleries"; import { CriterionType } from "../types"; import { InteractiveCriterion } from "./interactive"; -import { DuplicatedCriterion, PhashCriterionOption } from "./phash"; +import { DuplicatedCriterion, PhashCriterion } from "./phash"; import { CaptionCriterion } from "./captions"; import { RatingCriterion } from "./rating"; import { CountryCriterion } from "./country"; @@ -167,7 +167,7 @@ export function makeCriteria( new StringCriterionOption("media_info.checksum", type, "checksum") ); case "phash": - return new StringCriterion(PhashCriterionOption); + return new PhashCriterion(); case "duplicated": return new DuplicatedCriterion(); case "country": diff --git a/ui/v2.5/src/models/list-filter/criteria/phash.ts b/ui/v2.5/src/models/list-filter/criteria/phash.ts index 25bc8f6e7..b8dc48f21 100644 --- a/ui/v2.5/src/models/list-filter/criteria/phash.ts +++ b/ui/v2.5/src/models/list-filter/criteria/phash.ts @@ -1,15 +1,19 @@ -import { CriterionModifier } from "src/core/generated-graphql"; +import { + CriterionModifier, + PhashDistanceCriterionInput, +} from "src/core/generated-graphql"; +import { IPhashDistanceValue } from "../types"; import { BooleanCriterionOption, + Criterion, CriterionOption, PhashDuplicateCriterion, - StringCriterion, } from "./criterion"; export const PhashCriterionOption = new CriterionOption({ messageID: "media_info.phash", type: "phash", - parameterName: "phash", + parameterName: "phash_distance", inputType: "text", modifierOptions: [ CriterionModifier.Equals, @@ -19,9 +23,30 @@ export const PhashCriterionOption = new CriterionOption({ ], }); -export class PhashCriterion extends StringCriterion { +export class PhashCriterion extends Criterion { constructor() { - super(PhashCriterionOption); + super(PhashCriterionOption, { value: "", distance: 0 }); + } + + public getLabelValue() { + const { value, distance } = this.value; + if ( + (this.modifier === CriterionModifier.Equals || + this.modifier === CriterionModifier.NotEquals) && + distance + ) { + return `${value} (${distance})`; + } else { + return `${value}`; + } + } + + protected toCriterionInput(): PhashDistanceCriterionInput { + return { + value: this.value.value, + modifier: this.modifier, + distance: this.value.distance, + }; } } diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 3dd9e589c..e105e8ab8 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -48,6 +48,11 @@ export interface ITimestampValue { value2: string | undefined; } +export interface IPhashDistanceValue { + value: string; + distance?: number; +} + export function criterionIsHierarchicalLabelValue( // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index e9a0bb324..d33a00a74 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -289,7 +289,7 @@ const makeScenesPHashMatchUrl = (phash: GQL.Maybe | undefined) => { if (!phash) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); const criterion = new PhashCriterion(); - criterion.value = phash; + criterion.value = { value: phash }; filter.criteria.push(criterion); return `/scenes?${filter.makeQueryParameters()}`; }; From 22e2ce48381e5e5b1f29db1582dc41f78c9b3338 Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Mon, 17 Apr 2023 07:40:12 +0200 Subject: [PATCH 08/81] Use ReadTxn for performer aliases (#3620) --- internal/api/resolver_model_performer.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/api/resolver_model_performer.go b/internal/api/resolver_model_performer.go index 8aac29022..6b39c9f94 100644 --- a/internal/api/resolver_model_performer.go +++ b/internal/api/resolver_model_performer.go @@ -19,7 +19,7 @@ func (r *performerResolver) Checksum(ctx context.Context, obj *models.Performer) func (r *performerResolver) Aliases(ctx context.Context, obj *models.Performer) (*string, error) { if !obj.Aliases.Loaded() { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadAliases(ctx, r.repository.Performer) }); err != nil { return nil, err @@ -32,7 +32,7 @@ func (r *performerResolver) Aliases(ctx context.Context, obj *models.Performer) func (r *performerResolver) AliasList(ctx context.Context, obj *models.Performer) ([]string, error) { if !obj.Aliases.Loaded() { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadAliases(ctx, r.repository.Performer) }); err != nil { return nil, err From c859fa6bf893449544feb93c09c5e032ba4e688c Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Mon, 17 Apr 2023 08:42:00 +0300 Subject: [PATCH 09/81] Typo in Settings > System > Transcoding (#3616) --- ui/v2.5/src/locales/en-GB.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 858215286..cfb528d86 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -306,8 +306,8 @@ }, "transcode": { "input_args": { - "desc": "Advanced: Additional arguments to pass to ffmpeg before the input field when live transcoding video.", - "heading": "FFmpeg Live Transcode Input Args" + "desc": "Advanced: Additional arguments to pass to ffmpeg before the input field when generating video.", + "heading": "FFmpeg Transcode Input Args" }, "output_args": { "desc": "Advanced: Additional arguments to pass to ffmpeg before the output field when generating video.", @@ -1234,4 +1234,4 @@ "weight_kg": "Weight (kg)", "years_old": "years old", "zip_file_count": "Zip File Count" -} +} \ No newline at end of file From b85c5d928aa214ce0c02d9faa7bab6284937ecbc Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Mon, 17 Apr 2023 08:55:35 +0300 Subject: [PATCH 10/81] Update Help > Tasks manual (#3629) - Adds currently missing options - Removes the options that were removed in previous versions - Updates task names to match exactly --- ui/v2.5/src/docs/en/Manual/Tasks.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ui/v2.5/src/docs/en/Manual/Tasks.md b/ui/v2.5/src/docs/en/Manual/Tasks.md index 6f30b5fcd..f7df798f9 100644 --- a/ui/v2.5/src/docs/en/Manual/Tasks.md +++ b/ui/v2.5/src/docs/en/Manual/Tasks.md @@ -14,13 +14,12 @@ The scan task accepts the following options: | Option | Description | |--------|-------------| +| Generate scene covers | Generates scene covers for video files. | | Generate previews | Generates video previews which play when hovering over a scene. | | Generate animated image previews | Generates animated webp previews. Only required if the Preview Type is set to Animated Image. Requires Generate previews to be enabled. | -| Generate sprites | Generates sprites for the scene scrubber. | +| Generate scrubber sprites | Generates sprites for the scene scrubber. | | Generate perceptual hashes | Generates perceptual hashes for scene deduplication and identification. | | Generate thumbnails for images | Generates thumbnails for image files. | -| Don't include file extension in title | By default, scenes, images and galleries have their title created using the file basename. When the flag is enabled, the file extension is stripped when setting the title. | -| Set name, date, details from embedded file metadata. | Parse the video file metadata (where supported) and set the scene attributes accordingly. It has previously been noted that this information is frequently incorrect, so only use this option where you are certain that the metadata is correct in the files. | # Auto Tagging See the [Auto Tagging](/help/AutoTagging.md) page. @@ -42,6 +41,7 @@ The generate task accepts the following options: | Option | Description | |--------|-------------| +| Scene covers | Generates scene covers for video files. | | Previews | Generates video previews which play when hovering over a scene. | | Animated image previews | Generates animated webp previews. Only required if the Preview Type is set to Animated Image. Requires Generate previews to be enabled. | | Scene Scrubber Sprites | Generates sprites for the scene scrubber. | @@ -49,7 +49,8 @@ The generate task accepts the following options: | Marker Animated Image Previews | Generates animated webp previews for markers. Only required if the Preview Type is set to Animated Image. Requires Markers to be enabled. | | Marker Screenshots | Generates static JPG images for markers. Only required if Preview Type is set to Static Image. Requires Marker Previews to be enabled. | | Transcodes | MP4 conversions of unsupported video formats. Allows direct streaming instead of live transcoding. | -| Perceptual hashes | Generates perceptual hashes for scene deduplication and identification. | +| Perceptual hashes (for deduplication) | Generates perceptual hashes for scene deduplication and identification. | +| Generate heatmaps and speeds for interactive scenes | Generates heatmaps and speeds for interactive scenes. | | Overwrite existing generated files | By default, where a generated file exists, it is not regenerated. When this flag is enabled, then the generated files are regenerated. | ## Transcodes From 2cf73ded8351974cef06260057f09e94996f710e Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 17 Apr 2023 16:37:19 +1000 Subject: [PATCH 11/81] Add 0.21 changelog (#3668) --- ui/v2.5/src/components/Changelog/Changelog.tsx | 10 ++++++++-- ui/v2.5/src/docs/en/Changelog/v0210.md | 13 +++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 ui/v2.5/src/docs/en/Changelog/v0210.md diff --git a/ui/v2.5/src/components/Changelog/Changelog.tsx b/ui/v2.5/src/components/Changelog/Changelog.tsx index d959019a7..865fc4acd 100644 --- a/ui/v2.5/src/components/Changelog/Changelog.tsx +++ b/ui/v2.5/src/components/Changelog/Changelog.tsx @@ -25,6 +25,7 @@ import V0170 from "src/docs/en/Changelog/v0170.md"; import V0180 from "src/docs/en/Changelog/v0180.md"; import V0190 from "src/docs/en/Changelog/v0190.md"; import V0200 from "src/docs/en/Changelog/v0200.md"; +import V0210 from "src/docs/en/Changelog/v0210.md"; import { MarkdownPage } from "../Shared/MarkdownPage"; const Changelog: React.FC = () => { @@ -60,9 +61,9 @@ const Changelog: React.FC = () => { // after new release: // add entry to releases, using the current* fields // then update the current fields. - const currentVersion = stashVersion || "v0.20.0"; + const currentVersion = stashVersion || "v0.21.0"; const currentDate = buildDate; - const currentPage = V0200; + const currentPage = V0210; const releases: IStashRelease[] = [ { @@ -71,6 +72,11 @@ const Changelog: React.FC = () => { page: currentPage, defaultOpen: true, }, + { + version: "v0.20.2", + date: "2023-04-08", + page: V0200, + }, { version: "v0.19.1", date: "2023-02-21", diff --git a/ui/v2.5/src/docs/en/Changelog/v0210.md b/ui/v2.5/src/docs/en/Changelog/v0210.md new file mode 100644 index 000000000..a44dd5f04 --- /dev/null +++ b/ui/v2.5/src/docs/en/Changelog/v0210.md @@ -0,0 +1,13 @@ +### ✨ New Features +* Added distance parameter to phash filter. ([#3596](https://github.com/stashapp/stash/pull/3596)) + +### 🎨 Improvements +* Changed source selector menu to require click instead of mouseover. ([#3578](https://github.com/stashapp/stash/pull/3578)) +* Updated default studio icon to be consistent with other icons. ([#3577](https://github.com/stashapp/stash/pull/3577)) +* Make cards use up the full width of the screen on mobile. ([#3576](https://github.com/stashapp/stash/pull/3576)) +* Log errors when a graphql request fails. ([#3562](https://github.com/stashapp/stash/pull/3562)) +* Use case insensitive sorting for text based sorting. ([#3560](https://github.com/stashapp/stash/pull/3560)) +* Default date sorting in descending order. ([#3560](https://github.com/stashapp/stash/pull/3560)) + +### 🐛 Bug fixes +* Fixed incorrect Twitter/Instagram URLs sent to stash-box. ([#3579](https://github.com/stashapp/stash/pull/3579)) From 87abe8c38ceb9728c65daac3384ead9866ab8087 Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Wed, 19 Apr 2023 03:59:56 +0200 Subject: [PATCH 12/81] Add opt-in UI sourcemaps (#3603) * Add opt-in UI sourcemap support * Cleanup Makefile * Add STASH_NOLEGACY --- Makefile | 92 ++++++++++++++++++++++++++++-------------- ui/v2.5/vite.config.js | 59 ++++++++++++++++----------- 2 files changed, 97 insertions(+), 54 deletions(-) diff --git a/Makefile b/Makefile index b2630a4f0..79551c2ba 100644 --- a/Makefile +++ b/Makefile @@ -7,21 +7,15 @@ ifeq (${SHELL}, cmd) endif ifdef IS_WIN_SHELL - SEPARATOR := && - SET := set RM := del /s /q RMDIR := rmdir /s /q - PWD := $(shell echo %cd%) else - SEPARATOR := ; - SET := export RM := rm -f RMDIR := rm -rf endif # set LDFLAGS environment variable to any extra ldflags required # set OUTPUT to generate a specific binary name - LDFLAGS := $(LDFLAGS) ifdef OUTPUT OUTPUT := -o $(OUTPUT) @@ -34,10 +28,16 @@ export CGO_ENABLED = 1 GO_BUILD_TAGS_WINDOWS := sqlite_omit_load_extension sqlite_stat4 osusergo GO_BUILD_TAGS_DEFAULT = $(GO_BUILD_TAGS_WINDOWS) netgo -.PHONY: release pre-build +# set STASH_NOLEGACY environment variable or uncomment to disable legacy browser support +# STASH_NOLEGACY := true +# set STASH_SOURCEMAPS environment variable or uncomment to enable UI sourcemaps +# STASH_SOURCEMAPS := true + +.PHONY: release release: pre-ui generate ui build-release +.PHONY: pre-build pre-build: ifndef BUILD_DATE $(eval BUILD_DATE := $(shell go run -mod=vendor scripts/getDate.go)) @@ -55,29 +55,37 @@ ifndef OFFICIAL_BUILD $(eval OFFICIAL_BUILD := false) endif +.PHONY: build-flags +build-flags: pre-build + $(eval LDFLAGS := $(LDFLAGS) -X 'github.com/stashapp/stash/internal/api.buildstamp=$(BUILD_DATE)') + $(eval LDFLAGS := $(LDFLAGS) -X 'github.com/stashapp/stash/internal/api.githash=$(GITHASH)') + $(eval LDFLAGS := $(LDFLAGS) -X 'github.com/stashapp/stash/internal/api.version=$(STASH_VERSION)') + $(eval LDFLAGS := $(LDFLAGS) -X 'github.com/stashapp/stash/internal/manager/config.officialBuild=$(OFFICIAL_BUILD)') ifndef GO_BUILD_TAGS $(eval GO_BUILD_TAGS := $(GO_BUILD_TAGS_DEFAULT)) endif - + $(eval BUILD_FLAGS := -mod=vendor -v -tags "$(GO_BUILD_TAGS)" $(GO_BUILD_FLAGS) -ldflags "$(LDFLAGS) $(EXTRA_LDFLAGS)") # NOTE: the build target still includes netgo because we cannot detect # Windows easily from the Makefile. -build: pre-build +.PHONY: build +build: build-flags build: - $(eval LDFLAGS := $(LDFLAGS) -X 'github.com/stashapp/stash/internal/api.version=$(STASH_VERSION)' -X 'github.com/stashapp/stash/internal/api.buildstamp=$(BUILD_DATE)' -X 'github.com/stashapp/stash/internal/api.githash=$(GITHASH)') - $(eval LDFLAGS := $(LDFLAGS) -X 'github.com/stashapp/stash/internal/manager/config.officialBuild=$(OFFICIAL_BUILD)') - go build $(OUTPUT) -mod=vendor -v -tags "$(GO_BUILD_TAGS)" $(GO_BUILD_FLAGS) -ldflags "$(LDFLAGS) $(EXTRA_LDFLAGS) $(PLATFORM_SPECIFIC_LDFLAGS)" ./cmd/stash + go build $(OUTPUT) $(BUILD_FLAGS) ./cmd/stash # strips debug symbols from the release build +.PHONY: build-release build-release: EXTRA_LDFLAGS := -s -w build-release: GO_BUILD_FLAGS := -trimpath build-release: build +.PHONY: build-release-static build-release-static: EXTRA_LDFLAGS := -extldflags=-static -s -w build-release-static: GO_BUILD_FLAGS := -trimpath build-release-static: build # cross-compile- targets should be run within the compiler docker container +.PHONY: cross-compile-windows cross-compile-windows: export GOOS := windows cross-compile-windows: export GOARCH := amd64 cross-compile-windows: export CC := x86_64-w64-mingw32-gcc @@ -86,6 +94,7 @@ cross-compile-windows: OUTPUT := -o dist/stash-win.exe cross-compile-windows: GO_BUILD_TAGS := $(GO_BUILD_TAGS_WINDOWS) cross-compile-windows: build-release-static +.PHONY: cross-compile-macos-intel cross-compile-macos-intel: export GOOS := darwin cross-compile-macos-intel: export GOARCH := amd64 cross-compile-macos-intel: export CC := o64-clang @@ -95,6 +104,7 @@ cross-compile-macos-intel: GO_BUILD_TAGS := $(GO_BUILD_TAGS_DEFAULT) # can't use static build for OSX cross-compile-macos-intel: build-release +.PHONY: cross-compile-macos-applesilicon cross-compile-macos-applesilicon: export GOOS := darwin cross-compile-macos-applesilicon: export GOARCH := arm64 cross-compile-macos-applesilicon: export CC := oa64e-clang @@ -104,6 +114,7 @@ cross-compile-macos-applesilicon: GO_BUILD_TAGS := $(GO_BUILD_TAGS_DEFAULT) # can't use static build for OSX cross-compile-macos-applesilicon: build-release +.PHONY: cross-compile-macos cross-compile-macos: rm -rf dist/Stash.app dist/Stash-macos.zip make cross-compile-macos-applesilicon @@ -118,18 +129,21 @@ cross-compile-macos: cd dist && zip -r Stash-macos.zip Stash.app && cd .. rm -rf dist/Stash.app +.PHONY: cross-compile-freebsd cross-compile-freebsd: export GOOS := freebsd cross-compile-freebsd: export GOARCH := amd64 cross-compile-freebsd: OUTPUT := -o dist/stash-freebsd cross-compile-freebsd: GO_BUILD_TAGS += netgo cross-compile-freebsd: build-release-static +.PHONY: cross-compile-linux cross-compile-linux: export GOOS := linux cross-compile-linux: export GOARCH := amd64 cross-compile-linux: OUTPUT := -o dist/stash-linux cross-compile-linux: GO_BUILD_TAGS := $(GO_BUILD_TAGS_DEFAULT) cross-compile-linux: build-release-static +.PHONY: cross-compile-linux-arm64v8 cross-compile-linux-arm64v8: export GOOS := linux cross-compile-linux-arm64v8: export GOARCH := arm64 cross-compile-linux-arm64v8: export CC := aarch64-linux-gnu-gcc @@ -137,6 +151,7 @@ cross-compile-linux-arm64v8: OUTPUT := -o dist/stash-linux-arm64v8 cross-compile-linux-arm64v8: GO_BUILD_TAGS := $(GO_BUILD_TAGS_DEFAULT) cross-compile-linux-arm64v8: build-release-static +.PHONY: cross-compile-linux-arm32v7 cross-compile-linux-arm32v7: export GOOS := linux cross-compile-linux-arm32v7: export GOARCH := arm cross-compile-linux-arm32v7: export GOARM := 7 @@ -145,6 +160,7 @@ cross-compile-linux-arm32v7: OUTPUT := -o dist/stash-linux-arm32v7 cross-compile-linux-arm32v7: GO_BUILD_TAGS := $(GO_BUILD_TAGS_DEFAULT) cross-compile-linux-arm32v7: build-release-static +.PHONY: cross-compile-linux-arm32v6 cross-compile-linux-arm32v6: export GOOS := linux cross-compile-linux-arm32v6: export GOARCH := arm cross-compile-linux-arm32v6: export GOARM := 6 @@ -153,6 +169,7 @@ cross-compile-linux-arm32v6: OUTPUT := -o dist/stash-linux-arm32v6 cross-compile-linux-arm32v6: GO_BUILD_TAGS := $(GO_BUILD_TAGS_DEFAULT) cross-compile-linux-arm32v6: build-release-static +.PHONY: cross-compile-all cross-compile-all: make cross-compile-windows make cross-compile-macos-intel @@ -164,15 +181,16 @@ cross-compile-all: .PHONY: touch-ui touch-ui: -ifndef IS_WIN_SHELL - @mkdir -p ui/v2.5/build - @touch ui/v2.5/build/index.html -else +ifdef IS_WIN_SHELL @if not exist "ui\\v2.5\\build" mkdir ui\\v2.5\\build @type nul >> ui/v2.5/build/index.html +else + @mkdir -p ui/v2.5/build + @touch ui/v2.5/build/index.html endif # Regenerates GraphQL files +.PHONY: generate generate: generate-backend generate-frontend .PHONY: generate-frontend @@ -219,14 +237,14 @@ generate-test-mocks: # runs server # sets the config file to use the local dev config .PHONY: server-start -server-start: export STASH_CONFIG_FILE=config.yml -server-start: -ifndef IS_WIN_SHELL - @mkdir -p .local -else +server-start: export STASH_CONFIG_FILE := config.yml +server-start: build-flags +ifdef IS_WIN_SHELL @if not exist ".local" mkdir .local +else + @mkdir -p .local endif - cd .local && go run ../cmd/stash + cd .local && go run $(BUILD_FLAGS) ../cmd/stash # removes local dev config files .PHONY: server-clean @@ -239,18 +257,32 @@ server-clean: pre-ui: cd ui/v2.5 && yarn install --frozen-lockfile +.PHONY: ui-env +ui-env: pre-build + $(eval export VITE_APP_DATE := $(BUILD_DATE)) + $(eval export VITE_APP_GITHASH := $(GITHASH)) + $(eval export VITE_APP_STASH_VERSION := $(STASH_VERSION)) +ifdef STASH_NOLEGACY + $(eval export VITE_APP_NOLEGACY := true) +endif +ifdef STASH_SOURCEMAPS + $(eval export VITE_APP_SOURCEMAPS := true) +endif + .PHONY: ui -ui: pre-build - $(SET) VITE_APP_DATE="$(BUILD_DATE)" $(SEPARATOR) \ - $(SET) VITE_APP_GITHASH=$(GITHASH) $(SEPARATOR) \ - $(SET) VITE_APP_STASH_VERSION=$(STASH_VERSION) $(SEPARATOR) \ +ui: ui-env cd ui/v2.5 && yarn build +.PHONY: ui-nolegacy +ui-nolegacy: STASH_NOLEGACY := true +ui-nolegacy: ui + +.PHONY: ui-sourcemaps +ui-sourcemaps: STASH_SOURCEMAPS := true +ui-sourcemaps: ui + .PHONY: ui-start -ui-start: pre-build - $(SET) VITE_APP_DATE="$(BUILD_DATE)" $(SEPARATOR) \ - $(SET) VITE_APP_GITHASH=$(GITHASH) $(SEPARATOR) \ - $(SET) VITE_APP_STASH_VERSION=$(STASH_VERSION) $(SEPARATOR) \ +ui-start: ui-env cd ui/v2.5 && yarn start --host .PHONY: fmt-ui diff --git a/ui/v2.5/vite.config.js b/ui/v2.5/vite.config.js index 544d2f67b..1a3ab9ef4 100644 --- a/ui/v2.5/vite.config.js +++ b/ui/v2.5/vite.config.js @@ -4,36 +4,47 @@ import legacy from "@vitejs/plugin-legacy"; import tsconfigPaths from "vite-tsconfig-paths"; import viteCompression from "vite-plugin-compression"; +const nolegacy = process.env.VITE_APP_NOLEGACY === "true"; +const sourcemap = process.env.VITE_APP_SOURCEMAPS === "true"; + // https://vitejs.dev/config/ -export default defineConfig({ - base: "", - build: { - outDir: "build", - reportCompressedSize: false, - rollupOptions: { - output: { - experimentalDeepDynamicChunkOptimization: true, - }, - }, - }, - optimizeDeps: { - entries: "src/index.tsx", - }, - server: { - port: 3000, - cors: false, - }, - publicDir: "public", - assetsInclude: ["**/*.md"], - plugins: [ +export default defineConfig(() => { + let plugins = [ react(), - legacy(), tsconfigPaths(), viteCompression({ algorithm: "gzip", - disable: false, deleteOriginFile: true, + threshold: 0, filter: /\.(js|json|css|svg|md)$/i, }), - ], + ]; + + if (!nolegacy) { + plugins = [...plugins, legacy()]; + } + + return { + base: "", + build: { + outDir: "build", + sourcemap: sourcemap, + reportCompressedSize: false, + rollupOptions: { + output: { + experimentalDeepDynamicChunkOptimization: true, + }, + }, + }, + optimizeDeps: { + entries: "src/index.tsx", + }, + server: { + port: 3000, + cors: false, + }, + publicDir: "public", + assetsInclude: ["**/*.md"], + plugins, + }; }); From b4b7cf02b63a61db6fad9aed333aedea4a28fc36 Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Wed, 19 Apr 2023 05:01:32 +0200 Subject: [PATCH 13/81] Improve caching, HTTP headers and URL handling (#3594) * Fix relative URLs * Improve login base URL and redirects * Prevent duplicate customlocales requests * Improve UI base URL handling * Improve UI embedding * Improve CSP header * Add Cache-Control headers to all responses * Improve CORS responses * Improve authentication handler * Add back media timestamp suffixes * Fix default image handling * Add default param to other image URLs --- cmd/stash/main.go | 2 +- go.mod | 2 +- go.sum | 4 +- internal/api/authentication.go | 79 ++---- internal/api/resolver_model_movie.go | 37 +-- internal/api/resolver_model_performer.go | 11 +- internal/api/resolver_model_scene.go | 6 +- internal/api/resolver_model_scene_marker.go | 9 +- internal/api/resolver_model_studio.go | 10 +- internal/api/resolver_model_tag.go | 11 +- internal/api/resolver_query_scene.go | 2 +- internal/api/routes_image.go | 20 +- internal/api/routes_movie.go | 8 +- internal/api/routes_performer.go | 6 +- internal/api/routes_scene.go | 79 +++--- internal/api/routes_studio.go | 4 +- internal/api/routes_tag.go | 4 +- internal/api/server.go | 244 +++++++++--------- internal/api/session.go | 58 +++-- internal/api/urlbuilders/gallery.go | 19 -- internal/api/urlbuilders/image.go | 4 +- internal/api/urlbuilders/movie.go | 10 +- internal/api/urlbuilders/performer.go | 8 +- internal/api/urlbuilders/scene.go | 31 +-- internal/api/urlbuilders/scene_markers.go | 33 +++ internal/api/urlbuilders/studio.go | 8 +- internal/api/urlbuilders/tag.go | 8 +- internal/manager/downloads.go | 1 + internal/manager/favicon.go | 28 -- internal/manager/manager.go | 6 +- internal/manager/running_streams.go | 25 +- pkg/ffmpeg/stream_segmented.go | 9 +- pkg/ffmpeg/stream_transcode.go | 1 + pkg/file/file.go | 26 +- pkg/models/mocks/MovieReaderWriter.go | 21 ++ pkg/models/mocks/PerformerReaderWriter.go | 21 ++ pkg/models/mocks/TagReaderWriter.go | 21 ++ pkg/models/movie.go | 1 + pkg/models/performer.go | 1 + pkg/models/tag.go | 1 + pkg/sqlite/movies.go | 4 + pkg/sqlite/performer.go | 4 + pkg/sqlite/tag.go | 4 + pkg/utils/http.go | 41 +++ pkg/utils/image.go | 26 +- ui/login/login.html | 2 +- ui/ui.go | 43 ++- ui/v2.5/index.html | 3 - ui/v2.5/src/App.tsx | 25 +- .../Galleries/GalleryRecommendationRow.tsx | 5 +- ui/v2.5/src/components/Help/context.tsx | 7 +- .../Images/ImageRecommendationRow.tsx | 5 +- ui/v2.5/src/components/MainNavbar.tsx | 3 +- .../components/Movies/MovieDetails/Movie.tsx | 6 +- .../Movies/MovieRecommendationRow.tsx | 5 +- .../Performers/PerformerDetails/Performer.tsx | 36 ++- .../Performers/PerformerRecommendationRow.tsx | 5 +- ui/v2.5/src/components/Scenes/SceneCard.tsx | 34 +-- .../Scenes/SceneRecommendationRow.tsx | 5 +- .../Studios/StudioDetails/Studio.tsx | 6 +- .../Studios/StudioRecommendationRow.tsx | 5 +- .../src/components/Tags/TagDetails/Tag.tsx | 6 +- .../components/Tags/TagRecommendationRow.tsx | 5 +- ui/v2.5/src/core/createClient.ts | 14 +- ui/v2.5/src/globals.d.ts | 2 - ui/v2.5/src/index.tsx | 4 +- vendor/github.com/go-chi/cors/LICENSE | 21 ++ vendor/github.com/go-chi/cors/README.md | 39 +++ vendor/github.com/{rs => go-chi}/cors/cors.go | 189 ++++++-------- .../github.com/{rs => go-chi}/cors/utils.go | 9 +- vendor/github.com/rs/cors/.travis.yml | 8 - vendor/github.com/rs/cors/LICENSE | 19 -- vendor/github.com/rs/cors/README.md | 115 --------- vendor/modules.txt | 6 +- 74 files changed, 808 insertions(+), 782 deletions(-) delete mode 100644 internal/api/urlbuilders/gallery.go create mode 100644 internal/api/urlbuilders/scene_markers.go delete mode 100644 internal/manager/favicon.go create mode 100644 pkg/utils/http.go create mode 100644 vendor/github.com/go-chi/cors/LICENSE create mode 100644 vendor/github.com/go-chi/cors/README.md rename vendor/github.com/{rs => go-chi}/cors/cors.go (71%) rename vendor/github.com/{rs => go-chi}/cors/utils.go (89%) delete mode 100644 vendor/github.com/rs/cors/.travis.yml delete mode 100644 vendor/github.com/rs/cors/LICENSE delete mode 100644 vendor/github.com/rs/cors/README.md diff --git a/cmd/stash/main.go b/cmd/stash/main.go index 7b1adeb26..4aadf4fb1 100644 --- a/cmd/stash/main.go +++ b/cmd/stash/main.go @@ -34,7 +34,7 @@ func main() { }() go handleSignals() - desktop.Start(manager.GetInstance(), &manager.FaviconProvider{UIBox: ui.UIBox}) + desktop.Start(manager.GetInstance(), &ui.FaviconProvider) blockForever() } diff --git a/go.mod b/go.mod index 44a54616a..d39d21b98 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,6 @@ require ( github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/remeh/sizedwaitgroup v1.0.0 github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac - github.com/rs/cors v1.6.0 github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f github.com/sirupsen/logrus v1.8.1 github.com/spf13/afero v1.8.2 // indirect @@ -48,6 +47,7 @@ require ( require ( github.com/asticode/go-astisub v0.20.0 github.com/doug-martin/goqu/v9 v9.18.0 + github.com/go-chi/cors v1.2.1 github.com/go-chi/httplog v0.2.1 github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 github.com/hashicorp/golang-lru v0.5.4 diff --git a/go.sum b/go.sum index 75b2d679e..83456f972 100644 --- a/go.sum +++ b/go.sum @@ -242,6 +242,8 @@ github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAU github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-chi/chi/v5 v5.0.0 h1:DBPx88FjZJH3FsICfDAfIfnb7XxKIYVGG6lOPlhENAg= github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httplog v0.2.1 h1:KgCtIUkYNlfIsUPzE3utxd1KDKOvCrnAKaqdo0rmrh0= github.com/go-chi/httplog v0.2.1/go.mod h1:JyHOFO9twSfGoTin/RoP25Lx2a9Btq10ug+sgxe0+bo= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= @@ -668,8 +670,6 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rs/cors v1.6.0 h1:G9tHG9lebljV9mfp9SNPDL36nCDxmo3zTlAf1YgvzmI= -github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= diff --git a/internal/api/authentication.go b/internal/api/authentication.go index d02f98b13..94b5328f5 100644 --- a/internal/api/authentication.go +++ b/internal/api/authentication.go @@ -5,6 +5,7 @@ import ( "net" "net/http" "net/url" + "path" "strings" "github.com/stashapp/stash/internal/manager" @@ -13,11 +14,6 @@ import ( "github.com/stashapp/stash/pkg/session" ) -const ( - loginEndPoint = "/login" - logoutEndPoint = "/logout" -) - const ( tripwireActivatedErrMsg = "Stash is exposed to the public internet without authentication, and is not serving any more content to protect your privacy. " + "More information and fixes are available at https://docs.stashapp.cc/networking/authentication-required-when-accessing-stash-from-the-internet" @@ -30,7 +26,7 @@ const ( func allowUnauthenticated(r *http.Request) bool { // #2715 - allow access to UI files - return strings.HasPrefix(r.URL.Path, loginEndPoint) || r.URL.Path == logoutEndPoint || r.URL.Path == "/css" || strings.HasPrefix(r.URL.Path, "/assets") + return strings.HasPrefix(r.URL.Path, loginEndpoint) || r.URL.Path == logoutEndpoint || r.URL.Path == "/css" || strings.HasPrefix(r.URL.Path, "/assets") } func authenticateHandler() func(http.Handler) http.Handler { @@ -38,38 +34,41 @@ func authenticateHandler() func(http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c := config.GetInstance() - if !checkSecurityTripwireActivated(c, w) { + // error if external access tripwire activated + if accessErr := session.CheckExternalAccessTripwire(c); accessErr != nil { + http.Error(w, tripwireActivatedErrMsg, http.StatusForbidden) return } userID, err := manager.GetInstance().SessionStore.Authenticate(w, r) if err != nil { if errors.Is(err, session.ErrUnauthorized) { - w.WriteHeader(http.StatusInternalServerError) - _, err = w.Write([]byte(err.Error())) - if err != nil { - logger.Error(err) - } + http.Error(w, err.Error(), http.StatusInternalServerError) return } // unauthorized error - w.Header().Add("WWW-Authenticate", `FormBased`) + w.Header().Add("WWW-Authenticate", "FormBased") w.WriteHeader(http.StatusUnauthorized) return } if err := session.CheckAllowPublicWithoutAuth(c, r); err != nil { - var externalAccess session.ExternalAccessError - switch { - case errors.As(err, &externalAccess): - securityActivateTripwireAccessedFromInternetWithoutAuth(c, externalAccess, w) - return - default: + var accessErr session.ExternalAccessError + if errors.As(err, &accessErr) { + session.LogExternalAccessError(accessErr) + + err := c.ActivatePublicAccessTripwire(net.IP(accessErr).String()) + if err != nil { + logger.Errorf("Error activating public access tripwire: %v", err) + } + + http.Error(w, externalAccessErrMsg, http.StatusForbidden) + } else { logger.Errorf("Error checking external access security: %v", err) w.WriteHeader(http.StatusInternalServerError) - return } + return } ctx := r.Context() @@ -77,15 +76,15 @@ func authenticateHandler() func(http.Handler) http.Handler { if c.HasCredentials() { // authentication is required if userID == "" && !allowUnauthenticated(r) { - // authentication was not received, redirect - // if graphql was requested, we just return a forbidden error - if r.URL.Path == "/graphql" { - w.Header().Add("WWW-Authenticate", `FormBased`) + // if graphql or a non-webpage was requested, we just return a forbidden error + ext := path.Ext(r.URL.Path) + if r.URL.Path == gqlEndpoint || (ext != "" && ext != ".html") { + w.Header().Add("WWW-Authenticate", "FormBased") w.WriteHeader(http.StatusUnauthorized) return } - prefix := getProxyPrefix(r.Header) + prefix := getProxyPrefix(r) // otherwise redirect to the login page returnURL := url.URL{ @@ -95,7 +94,7 @@ func authenticateHandler() func(http.Handler) http.Handler { q := make(url.Values) q.Set(returnURLParam, returnURL.String()) u := url.URL{ - Path: prefix + "/login", + Path: prefix + loginEndpoint, RawQuery: q.Encode(), } http.Redirect(w, r, u.String(), http.StatusFound) @@ -111,31 +110,3 @@ func authenticateHandler() func(http.Handler) http.Handler { }) } } - -func checkSecurityTripwireActivated(c *config.Instance, w http.ResponseWriter) bool { - if accessErr := session.CheckExternalAccessTripwire(c); accessErr != nil { - w.WriteHeader(http.StatusForbidden) - _, err := w.Write([]byte(tripwireActivatedErrMsg)) - if err != nil { - logger.Error(err) - } - return false - } - - return true -} - -func securityActivateTripwireAccessedFromInternetWithoutAuth(c *config.Instance, accessErr session.ExternalAccessError, w http.ResponseWriter) { - session.LogExternalAccessError(accessErr) - - err := c.ActivatePublicAccessTripwire(net.IP(accessErr).String()) - if err != nil { - logger.Error(err) - } - - w.WriteHeader(http.StatusForbidden) - _, err = w.Write([]byte(externalAccessErrMsg)) - if err != nil { - logger.Error(err) - } -} diff --git a/internal/api/resolver_model_movie.go b/internal/api/resolver_model_movie.go index 9967ef323..fea2276ea 100644 --- a/internal/api/resolver_model_movie.go +++ b/internal/api/resolver_model_movie.go @@ -86,33 +86,38 @@ func (r *movieResolver) Synopsis(ctx context.Context, obj *models.Movie) (*strin } func (r *movieResolver) FrontImagePath(ctx context.Context, obj *models.Movie) (*string, error) { - baseURL, _ := ctx.Value(BaseURLCtxKey).(string) - frontimagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj).GetMovieFrontImageURL() - return &frontimagePath, nil -} - -func (r *movieResolver) BackImagePath(ctx context.Context, obj *models.Movie) (*string, error) { - // don't return any thing if there is no back image - hasImage := false + var hasImage bool if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error - hasImage, err = r.repository.Movie.HasBackImage(ctx, obj.ID) - if err != nil { - return err - } - - return nil + hasImage, err = r.repository.Movie.HasFrontImage(ctx, obj.ID) + return err }); err != nil { return nil, err } + baseURL, _ := ctx.Value(BaseURLCtxKey).(string) + imagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj).GetMovieFrontImageURL(hasImage) + return &imagePath, nil +} + +func (r *movieResolver) BackImagePath(ctx context.Context, obj *models.Movie) (*string, error) { + var hasImage bool + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + var err error + hasImage, err = r.repository.Movie.HasBackImage(ctx, obj.ID) + return err + }); err != nil { + return nil, err + } + + // don't return anything if there is no back image if !hasImage { return nil, nil } baseURL, _ := ctx.Value(BaseURLCtxKey).(string) - backimagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj).GetMovieBackImageURL() - return &backimagePath, nil + imagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj).GetMovieBackImageURL() + return &imagePath, nil } func (r *movieResolver) SceneCount(ctx context.Context, obj *models.Movie) (ret *int, err error) { diff --git a/internal/api/resolver_model_performer.go b/internal/api/resolver_model_performer.go index 6b39c9f94..0fb8f6518 100644 --- a/internal/api/resolver_model_performer.go +++ b/internal/api/resolver_model_performer.go @@ -63,8 +63,17 @@ func (r *performerResolver) Birthdate(ctx context.Context, obj *models.Performer } func (r *performerResolver) ImagePath(ctx context.Context, obj *models.Performer) (*string, error) { + var hasImage bool + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + var err error + hasImage, err = r.repository.Performer.HasImage(ctx, obj.ID) + return err + }); err != nil { + return nil, err + } + baseURL, _ := ctx.Value(BaseURLCtxKey).(string) - imagePath := urlbuilders.NewPerformerURLBuilder(baseURL, obj).GetPerformerImageURL() + imagePath := urlbuilders.NewPerformerURLBuilder(baseURL, obj).GetPerformerImageURL(hasImage) return &imagePath, nil } diff --git a/internal/api/resolver_model_scene.go b/internal/api/resolver_model_scene.go index a5c70fadc..99f42e64f 100644 --- a/internal/api/resolver_model_scene.go +++ b/internal/api/resolver_model_scene.go @@ -178,8 +178,8 @@ func formatFingerprint(fp interface{}) string { func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*ScenePathsType, error) { baseURL, _ := ctx.Value(BaseURLCtxKey).(string) config := manager.GetInstance().Config - builder := urlbuilders.NewSceneURLBuilder(baseURL, obj.ID) - screenshotPath := builder.GetScreenshotURL(obj.UpdatedAt) + builder := urlbuilders.NewSceneURLBuilder(baseURL, obj) + screenshotPath := builder.GetScreenshotURL() previewPath := builder.GetStreamPreviewURL() streamPath := builder.GetStreamURL(config.GetAPIKey()).String() webpPath := builder.GetStreamPreviewImageURL() @@ -370,7 +370,7 @@ func (r *sceneResolver) SceneStreams(ctx context.Context, obj *models.Scene) ([] config := manager.GetInstance().Config baseURL, _ := ctx.Value(BaseURLCtxKey).(string) - builder := urlbuilders.NewSceneURLBuilder(baseURL, obj.ID) + builder := urlbuilders.NewSceneURLBuilder(baseURL, obj) apiKey := config.GetAPIKey() return manager.GetSceneStreamPaths(obj, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize()) diff --git a/internal/api/resolver_model_scene_marker.go b/internal/api/resolver_model_scene_marker.go index 0057db4e8..3e6ab4030 100644 --- a/internal/api/resolver_model_scene_marker.go +++ b/internal/api/resolver_model_scene_marker.go @@ -48,20 +48,17 @@ func (r *sceneMarkerResolver) Tags(ctx context.Context, obj *models.SceneMarker) func (r *sceneMarkerResolver) Stream(ctx context.Context, obj *models.SceneMarker) (string, error) { baseURL, _ := ctx.Value(BaseURLCtxKey).(string) - sceneID := int(obj.SceneID.Int64) - return urlbuilders.NewSceneURLBuilder(baseURL, sceneID).GetSceneMarkerStreamURL(obj.ID), nil + return urlbuilders.NewSceneMarkerURLBuilder(baseURL, obj).GetStreamURL(), nil } func (r *sceneMarkerResolver) Preview(ctx context.Context, obj *models.SceneMarker) (string, error) { baseURL, _ := ctx.Value(BaseURLCtxKey).(string) - sceneID := int(obj.SceneID.Int64) - return urlbuilders.NewSceneURLBuilder(baseURL, sceneID).GetSceneMarkerStreamPreviewURL(obj.ID), nil + return urlbuilders.NewSceneMarkerURLBuilder(baseURL, obj).GetPreviewURL(), nil } func (r *sceneMarkerResolver) Screenshot(ctx context.Context, obj *models.SceneMarker) (string, error) { baseURL, _ := ctx.Value(BaseURLCtxKey).(string) - sceneID := int(obj.SceneID.Int64) - return urlbuilders.NewSceneURLBuilder(baseURL, sceneID).GetSceneMarkerStreamScreenshotURL(obj.ID), nil + return urlbuilders.NewSceneMarkerURLBuilder(baseURL, obj).GetScreenshotURL(), nil } func (r *sceneMarkerResolver) CreatedAt(ctx context.Context, obj *models.SceneMarker) (*time.Time, error) { diff --git a/internal/api/resolver_model_studio.go b/internal/api/resolver_model_studio.go index 4d689df77..10bc577f3 100644 --- a/internal/api/resolver_model_studio.go +++ b/internal/api/resolver_model_studio.go @@ -27,9 +27,6 @@ func (r *studioResolver) URL(ctx context.Context, obj *models.Studio) (*string, } func (r *studioResolver) ImagePath(ctx context.Context, obj *models.Studio) (*string, error) { - baseURL, _ := ctx.Value(BaseURLCtxKey).(string) - imagePath := urlbuilders.NewStudioURLBuilder(baseURL, obj).GetStudioImageURL() - var hasImage bool if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error @@ -39,11 +36,8 @@ func (r *studioResolver) ImagePath(ctx context.Context, obj *models.Studio) (*st return nil, err } - // indicate that image is missing by setting default query param to true - if !hasImage { - imagePath += "?default=true" - } - + baseURL, _ := ctx.Value(BaseURLCtxKey).(string) + imagePath := urlbuilders.NewStudioURLBuilder(baseURL, obj).GetStudioImageURL(hasImage) return &imagePath, nil } diff --git a/internal/api/resolver_model_tag.go b/internal/api/resolver_model_tag.go index 70fee39e0..f2c677b87 100644 --- a/internal/api/resolver_model_tag.go +++ b/internal/api/resolver_model_tag.go @@ -111,8 +111,17 @@ func (r *tagResolver) PerformerCount(ctx context.Context, obj *models.Tag) (ret } func (r *tagResolver) ImagePath(ctx context.Context, obj *models.Tag) (*string, error) { + var hasImage bool + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + var err error + hasImage, err = r.repository.Performer.HasImage(ctx, obj.ID) + return err + }); err != nil { + return nil, err + } + baseURL, _ := ctx.Value(BaseURLCtxKey).(string) - imagePath := urlbuilders.NewTagURLBuilder(baseURL, obj).GetTagImageURL() + imagePath := urlbuilders.NewTagURLBuilder(baseURL, obj).GetTagImageURL(hasImage) return &imagePath, nil } diff --git a/internal/api/resolver_query_scene.go b/internal/api/resolver_query_scene.go index 120998d71..e7f16604b 100644 --- a/internal/api/resolver_query_scene.go +++ b/internal/api/resolver_query_scene.go @@ -34,7 +34,7 @@ func (r *queryResolver) SceneStreams(ctx context.Context, id *string) ([]*manage config := manager.GetInstance().Config baseURL, _ := ctx.Value(BaseURLCtxKey).(string) - builder := urlbuilders.NewSceneURLBuilder(baseURL, scene.ID) + builder := urlbuilders.NewSceneURLBuilder(baseURL, scene) apiKey := config.GetAPIKey() return manager.GetSceneStreamPaths(scene, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize()) diff --git a/internal/api/routes_image.go b/internal/api/routes_image.go index 7ac8c99ae..2685a7a76 100644 --- a/internal/api/routes_image.go +++ b/internal/api/routes_image.go @@ -8,7 +8,6 @@ import ( "net/http" "os/exec" "strconv" - "syscall" "github.com/go-chi/chi" "github.com/stashapp/stash/internal/manager" @@ -19,6 +18,7 @@ import ( "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/txn" + "github.com/stashapp/stash/pkg/utils" ) type ImageFinder interface { @@ -51,12 +51,10 @@ func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) { img := r.Context().Value(imageKey).(*models.Image) filepath := manager.GetInstance().Paths.Generated.GetThumbnailPath(img.Checksum, models.DefaultGthumbWidth) - w.Header().Add("Cache-Control", "max-age=604800000") - // if the thumbnail doesn't exist, encode on the fly exists, _ := fsutil.FileExists(filepath) if exists { - http.ServeFile(w, r, filepath) + utils.ServeStaticFile(w, r, filepath) } else { const useDefault = true @@ -88,13 +86,13 @@ func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) { // write the generated thumbnail to disk if enabled if manager.GetInstance().Config.IsWriteImageThumbnails() { logger.Debugf("writing thumbnail to disk: %s", img.Path) - if err := fsutil.WriteFile(filepath, data); err != nil { - logger.Errorf("error writing thumbnail for image %s: %v", img.Path, err) + if err := fsutil.WriteFile(filepath, data); err == nil { + utils.ServeStaticFile(w, r, filepath) + return } + logger.Errorf("error writing thumbnail for image %s: %v", img.Path, err) } - if n, err := w.Write(data); err != nil && !errors.Is(err, syscall.EPIPE) { - logger.Errorf("error serving thumbnail (wrote %v bytes out of %v): %v", n, len(data), err) - } + utils.ServeStaticContent(w, r, data) } } @@ -131,8 +129,8 @@ func (rs imageRoutes) serveImage(w http.ResponseWriter, r *http.Request, i *mode // fall back to static image f, _ := static.Image.Open(defaultImageImage) defer f.Close() - stat, _ := f.Stat() - http.ServeContent(w, r, "image.svg", stat.ModTime(), f.(io.ReadSeeker)) + image, _ := io.ReadAll(f) + utils.ServeImage(w, r, image) } // endregion diff --git a/internal/api/routes_movie.go b/internal/api/routes_movie.go index 7b77586a6..a64aae76c 100644 --- a/internal/api/routes_movie.go +++ b/internal/api/routes_movie.go @@ -58,9 +58,7 @@ func (rs movieRoutes) FrontImage(w http.ResponseWriter, r *http.Request) { image, _ = utils.ProcessBase64Image(models.DefaultMovieImage) } - if err := utils.ServeImage(image, w, r); err != nil { - logger.Warnf("error serving movie front image: %v", err) - } + utils.ServeImage(w, r, image) } func (rs movieRoutes) BackImage(w http.ResponseWriter, r *http.Request) { @@ -85,9 +83,7 @@ func (rs movieRoutes) BackImage(w http.ResponseWriter, r *http.Request) { image, _ = utils.ProcessBase64Image(models.DefaultMovieImage) } - if err := utils.ServeImage(image, w, r); err != nil { - logger.Warnf("error serving movie back image: %v", err) - } + utils.ServeImage(w, r, image) } func (rs movieRoutes) MovieCtx(next http.Handler) http.Handler { diff --git a/internal/api/routes_performer.go b/internal/api/routes_performer.go index 1717e99f9..e7631de5b 100644 --- a/internal/api/routes_performer.go +++ b/internal/api/routes_performer.go @@ -54,13 +54,11 @@ func (rs performerRoutes) Image(w http.ResponseWriter, r *http.Request) { } } - if len(image) == 0 || defaultParam == "true" { + if len(image) == 0 { image, _ = getRandomPerformerImageUsingName(performer.Name, performer.Gender, config.GetInstance().GetCustomPerformerImageLocation()) } - if err := utils.ServeImage(image, w, r); err != nil { - logger.Warnf("error serving performer image: %v", err) - } + utils.ServeImage(w, r, image) } func (rs performerRoutes) PerformerCtx(next http.Handler) http.Handler { diff --git a/internal/api/routes_scene.go b/internal/api/routes_scene.go index c01c43104..9a5e81496 100644 --- a/internal/api/routes_scene.go +++ b/internal/api/routes_scene.go @@ -88,24 +88,12 @@ func (rs sceneRoutes) Routes() chi.Router { // region Handlers func (rs sceneRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) { - scene := r.Context().Value(sceneKey).(*models.Scene) - // #3526 - return 404 if the scene does not have any files - if scene.Path == "" { - w.WriteHeader(http.StatusNotFound) - return + ss := manager.SceneServer{ + TxnManager: rs.txnManager, + SceneCoverGetter: rs.sceneFinder, } - - sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()) - - filepath := manager.GetInstance().Paths.Scene.GetStreamPath(scene.Path, sceneHash) - streamRequestCtx := ffmpeg.NewStreamRequestContext(w, r) - - // #2579 - hijacking and closing the connection here causes video playback to fail in Safari - // We trust that the request context will be closed, so we don't need to call Cancel on the - // returned context here. - _ = manager.GetInstance().ReadLockManager.ReadLock(streamRequestCtx, filepath) - http.ServeFile(w, r, filepath) + ss.StreamSceneDirect(scene, w, r) } func (rs sceneRoutes) StreamMp4(w http.ResponseWriter, r *http.Request) { @@ -266,22 +254,16 @@ func (rs sceneRoutes) Preview(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()) filepath := manager.GetInstance().Paths.Scene.GetVideoPreviewPath(sceneHash) - serveFileNoCache(w, r, filepath) -} -// serveFileNoCache serves the provided file, ensuring that the response -// contains headers to prevent caching. -func serveFileNoCache(w http.ResponseWriter, r *http.Request, filepath string) { - w.Header().Add("Cache-Control", "no-cache") - - http.ServeFile(w, r, filepath) + utils.ServeStaticFile(w, r, filepath) } func (rs sceneRoutes) Webp(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()) filepath := manager.GetInstance().Paths.Scene.GetWebpPreviewPath(sceneHash) - http.ServeFile(w, r, filepath) + + utils.ServeStaticFile(w, r, filepath) } func (rs sceneRoutes) getChapterVttTitle(ctx context.Context, marker *models.SceneMarker) (*string, error) { @@ -355,7 +337,7 @@ func (rs sceneRoutes) VttChapter(w http.ResponseWriter, r *http.Request) { vtt := strings.Join(vttLines, "\n") w.Header().Set("Content-Type", "text/vtt") - _, _ = w.Write([]byte(vtt)) + utils.ServeStaticContent(w, r, []byte(vtt)) } func (rs sceneRoutes) VttThumbs(w http.ResponseWriter, r *http.Request) { @@ -366,9 +348,10 @@ func (rs sceneRoutes) VttThumbs(w http.ResponseWriter, r *http.Request) { } else { sceneHash = chi.URLParam(r, "sceneHash") } - w.Header().Set("Content-Type", "text/vtt") filepath := manager.GetInstance().Paths.Scene.GetSpriteVttFilePath(sceneHash) - http.ServeFile(w, r, filepath) + + w.Header().Set("Content-Type", "text/vtt") + utils.ServeStaticFile(w, r, filepath) } func (rs sceneRoutes) VttSprite(w http.ResponseWriter, r *http.Request) { @@ -379,23 +362,24 @@ func (rs sceneRoutes) VttSprite(w http.ResponseWriter, r *http.Request) { } else { sceneHash = chi.URLParam(r, "sceneHash") } - w.Header().Set("Content-Type", "image/jpeg") filepath := manager.GetInstance().Paths.Scene.GetSpriteImageFilePath(sceneHash) - http.ServeFile(w, r, filepath) + + utils.ServeStaticFile(w, r, filepath) } func (rs sceneRoutes) Funscript(w http.ResponseWriter, r *http.Request) { s := r.Context().Value(sceneKey).(*models.Scene) - funscript := video.GetFunscriptPath(s.Path) - serveFileNoCache(w, r, funscript) + filepath := video.GetFunscriptPath(s.Path) + + utils.ServeStaticFile(w, r, filepath) } func (rs sceneRoutes) InteractiveHeatmap(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()) - w.Header().Set("Content-Type", "image/png") filepath := manager.GetInstance().Paths.Scene.GetInteractiveHeatmapPath(sceneHash) - http.ServeFile(w, r, filepath) + + utils.ServeStaticFile(w, r, filepath) } func (rs sceneRoutes) Caption(w http.ResponseWriter, r *http.Request, lang string, ext string) { @@ -434,16 +418,17 @@ func (rs sceneRoutes) Caption(w http.ResponseWriter, r *http.Request, lang strin return } - var b bytes.Buffer - err = sub.WriteToWebVTT(&b) + var buf bytes.Buffer + + err = sub.WriteToWebVTT(&buf) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/vtt") - w.Header().Add("Cache-Control", "no-cache") - _, _ = b.WriteTo(w) + utils.ServeStaticContent(w, r, buf.Bytes()) + return } } @@ -483,7 +468,7 @@ func (rs sceneRoutes) SceneMarkerStream(w http.ResponseWriter, r *http.Request) } filepath := manager.GetInstance().Paths.SceneMarkers.GetVideoPreviewPath(sceneHash, int(sceneMarker.Seconds)) - http.ServeFile(w, r, filepath) + utils.ServeStaticFile(w, r, filepath) } func (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request) { @@ -516,12 +501,10 @@ func (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request) exists, _ := fsutil.FileExists(filepath) if !exists { w.Header().Set("Content-Type", "image/png") - w.Header().Set("Cache-Control", "no-store") - _, _ = w.Write(utils.PendingGenerateResource) - return + utils.ServeStaticContent(w, r, utils.PendingGenerateResource) + } else { + utils.ServeStaticFile(w, r, filepath) } - - http.ServeFile(w, r, filepath) } func (rs sceneRoutes) SceneMarkerScreenshot(w http.ResponseWriter, r *http.Request) { @@ -554,12 +537,10 @@ func (rs sceneRoutes) SceneMarkerScreenshot(w http.ResponseWriter, r *http.Reque exists, _ := fsutil.FileExists(filepath) if !exists { w.Header().Set("Content-Type", "image/png") - w.Header().Set("Cache-Control", "no-store") - _, _ = w.Write(utils.PendingGenerateResource) - return + utils.ServeStaticContent(w, r, utils.PendingGenerateResource) + } else { + utils.ServeStaticFile(w, r, filepath) } - - http.ServeFile(w, r, filepath) } // endregion diff --git a/internal/api/routes_studio.go b/internal/api/routes_studio.go index 85c66e199..ca4e580f6 100644 --- a/internal/api/routes_studio.go +++ b/internal/api/routes_studio.go @@ -67,9 +67,7 @@ func (rs studioRoutes) Image(w http.ResponseWriter, r *http.Request) { return } - if err := utils.ServeImage(image, w, r); err != nil { - logger.Warnf("error serving studio image: %v", err) - } + utils.ServeImage(w, r, image) } func (rs studioRoutes) StudioCtx(next http.Handler) http.Handler { diff --git a/internal/api/routes_tag.go b/internal/api/routes_tag.go index 4c0ff43b8..d8837da80 100644 --- a/internal/api/routes_tag.go +++ b/internal/api/routes_tag.go @@ -67,9 +67,7 @@ func (rs tagRoutes) Image(w http.ResponseWriter, r *http.Request) { return } - if err := utils.ServeImage(image, w, r); err != nil { - logger.Warnf("error serving tag image: %v", err) - } + utils.ServeImage(w, r, image) } func (rs tagRoutes) TagCtx(next http.Handler) http.Handler { diff --git a/internal/api/server.go b/internal/api/server.go index 26d81d5db..cfc57b3dd 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -27,17 +27,25 @@ import ( "github.com/gorilla/websocket" "github.com/vearutop/statigz" + "github.com/go-chi/cors" "github.com/go-chi/httplog" - "github.com/rs/cors" "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/plugin" + "github.com/stashapp/stash/pkg/utils" "github.com/stashapp/stash/ui" ) +const ( + loginEndpoint = "/login" + logoutEndpoint = "/logout" + gqlEndpoint = "/graphql" + playgroundEndpoint = "/playground" +) + var version string var buildstamp string var githash string @@ -51,6 +59,7 @@ func Start() error { r := chi.NewRouter() r.Use(middleware.Heartbeat("/healthz")) + r.Use(cors.AllowAll().Handler) r.Use(authenticateHandler()) visitedPluginHandler := manager.GetInstance().SessionStore.VisitedPluginHandler() r.Use(visitedPluginHandler) @@ -67,7 +76,6 @@ func Start() error { r.Use(SecurityHeadersMiddleware) r.Use(middleware.DefaultCompress) r.Use(middleware.StripSlashes) - r.Use(cors.AllowAll().Handler) r.Use(BaseURLMiddleware) recoverFunc := func(ctx context.Context, err interface{}) error { @@ -123,6 +131,7 @@ func Start() error { gqlSrv.SetErrorPresenter(gqlErrorHandler) gqlHandlerFunc := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") gqlSrv.ServeHTTP(w, r) } @@ -132,14 +141,12 @@ func Start() error { gqlHandler := visitedPluginHandler(dataloaders.Middleware(http.HandlerFunc(gqlHandlerFunc))) manager.GetInstance().PluginCache.RegisterGQLHandler(gqlHandler) - r.HandleFunc("/graphql", gqlHandlerFunc) - r.HandleFunc("/playground", gqlPlayground.Handler("GraphQL playground", "/graphql")) - - // session handlers - r.Post(loginEndPoint, handleLogin(loginUIBox)) - r.Get(logoutEndPoint, handleLogout(loginUIBox)) - - r.Get(loginEndPoint, getLoginHandler(loginUIBox)) + r.HandleFunc(gqlEndpoint, gqlHandlerFunc) + r.HandleFunc(playgroundEndpoint, func(w http.ResponseWriter, r *http.Request) { + setPageSecurityHeaders(w, r) + endpoint := getProxyPrefix(r) + gqlEndpoint + gqlPlayground.Handler("GraphQL playground", endpoint)(w, r) + }) r.Mount("/performer", performerRoutes{ txnManager: txnManager, @@ -174,36 +181,17 @@ func Start() error { r.HandleFunc("/css", cssHandler(c, pluginCache)) r.HandleFunc("/javascript", javascriptHandler(c, pluginCache)) - r.HandleFunc("/customlocales", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if c.GetCustomLocalesEnabled() { - // search for custom-locales.json in current directory, then $HOME/.stash - fn := c.GetCustomLocalesPath() - exists, _ := fsutil.FileExists(fn) - if exists { - http.ServeFile(w, r, fn) - return - } - } - _, _ = w.Write([]byte("{}")) - }) + r.HandleFunc("/customlocales", customLocalesHandler(c)) - r.HandleFunc("/login*", func(w http.ResponseWriter, r *http.Request) { - ext := path.Ext(r.URL.Path) - if ext == ".html" || ext == "" { - prefix := getProxyPrefix(r.Header) + staticLoginUI := statigz.FileServer(loginUIBox.(fs.ReadDirFS)) - data := getLoginPage(loginUIBox) - baseURLIndex := strings.Replace(string(data), "%BASE_URL%", prefix+"/", 2) - _, _ = w.Write([]byte(baseURLIndex)) - } else { - r.URL.Path = strings.Replace(r.URL.Path, loginEndPoint, "", 1) - loginRoot, err := fs.Sub(loginUIBox, loginRootDir) - if err != nil { - panic(err) - } - http.FileServer(http.FS(loginRoot)).ServeHTTP(w, r) - } + r.Get(loginEndpoint, handleLogin(loginUIBox)) + r.Post(loginEndpoint, handleLoginPost(loginUIBox)) + r.Get(logoutEndpoint, handleLogout()) + r.HandleFunc(loginEndpoint+"/*", func(w http.ResponseWriter, r *http.Request) { + r.URL.Path = strings.TrimPrefix(r.URL.Path, loginEndpoint) + w.Header().Set("Cache-Control", "no-cache") + staticLoginUI.ServeHTTP(w, r) }) // Serve static folders @@ -215,12 +203,10 @@ func Start() error { } customUILocation := c.GetCustomUILocation() - static := statigz.FileServer(uiBox) + staticUI := statigz.FileServer(uiBox.(fs.ReadDirFS)) // Serve the web app r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { - const uiRootDir = "v2.5/build" - ext := path.Ext(r.URL.Path) if customUILocation != "" { @@ -234,29 +220,29 @@ func Start() error { if ext == ".html" || ext == "" { themeColor := c.GetThemeColor() - data, err := uiBox.ReadFile(uiRootDir + "/index.html") + data, err := fs.ReadFile(uiBox, "index.html") if err != nil { panic(err) } + indexHtml := string(data) - prefix := getProxyPrefix(r.Header) - baseURLIndex := strings.ReplaceAll(string(data), "%COLOR%", themeColor) - baseURLIndex = strings.ReplaceAll(baseURLIndex, "/%BASE_URL%", prefix) - baseURLIndex = strings.Replace(baseURLIndex, "base href=\"/\"", fmt.Sprintf("base href=\"%s\"", prefix+"/"), 1) - _, _ = w.Write([]byte(baseURLIndex)) + prefix := getProxyPrefix(r) + indexHtml = strings.ReplaceAll(indexHtml, "%COLOR%", themeColor) + indexHtml = strings.Replace(indexHtml, ` - + Login diff --git a/ui/ui.go b/ui/ui.go index 87d9e7f0e..89ea75a44 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -1,9 +1,46 @@ package ui -import "embed" +import ( + "embed" + "io/fs" + "runtime" +) //go:embed v2.5/build -var UIBox embed.FS +var uiBox embed.FS +var UIBox fs.FS //go:embed login -var LoginUIBox embed.FS +var loginUIBox embed.FS +var LoginUIBox fs.FS + +func init() { + var err error + UIBox, err = fs.Sub(uiBox, "v2.5/build") + if err != nil { + panic(err) + } + + LoginUIBox, err = fs.Sub(loginUIBox, "login") + if err != nil { + panic(err) + } +} + +type faviconProvider struct{} + +var FaviconProvider = faviconProvider{} + +func (p *faviconProvider) GetFavicon() []byte { + if runtime.GOOS == "windows" { + ret, _ := fs.ReadFile(UIBox, "favicon.ico") + return ret + } + + return p.GetFaviconPng() +} + +func (p *faviconProvider) GetFaviconPng() []byte { + ret, _ := fs.ReadFile(UIBox, "favicon.png") + return ret +} diff --git a/ui/v2.5/index.html b/ui/v2.5/index.html index 11bbb270d..4134a27f0 100755 --- a/ui/v2.5/index.html +++ b/ui/v2.5/index.html @@ -12,9 +12,6 @@ Stash - diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index 4a374065c..a522f3624 100644 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -94,6 +94,20 @@ export const App: React.FC = () => { // use en-GB as default messages if any messages aren't found in the chosen language const [messages, setMessages] = useState<{}>(); + const [customMessages, setCustomMessages] = useState<{}>(); + + useEffect(() => { + (async () => { + try { + const res = await fetch(getPlatformURL() + "customlocales"); + if (res.ok) { + setCustomMessages(await res.json()); + } + } catch (err) { + console.log(err); + } + })(); + }, []); useEffect(() => { const setLocale = async () => { @@ -106,15 +120,6 @@ export const App: React.FC = () => { const defaultMessages = (await locales[defaultMessageLanguage]()).default; const mergedMessages = cloneDeep(Object.assign({}, defaultMessages)); const chosenMessages = (await locales[messageLanguage]()).default; - let customMessages = {}; - try { - const res = await fetch(getPlatformURL() + "customlocales"); - if (res.ok) { - customMessages = await res.json(); - } - } catch (err) { - console.log(err); - } mergeWith( mergedMessages, @@ -142,7 +147,7 @@ export const App: React.FC = () => { }; setLocale(); - }, [language]); + }, [customMessages, language]); const location = useLocation(); const history = useHistory(); diff --git a/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx b/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx index dbd10e090..ee94d6da2 100644 --- a/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryRecommendationRow.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { Link } from "react-router-dom"; import { useFindGalleries } from "src/core/StashService"; import Slider from "@ant-design/react-slick"; import { GalleryCard } from "./GalleryCard"; @@ -26,9 +27,9 @@ export const GalleryRecommendationRow: React.FC = (props) => { className="gallery-recommendations" header={props.header} link={ - + - + } > import("./Manual")); @@ -48,14 +49,14 @@ export const ManualLink: React.FC = ({ tab, children }) => { const { openManual } = React.useContext(ManualStateContext); return ( - { openManual(`${tab}.md`); e.preventDefault(); }} > {children} - + ); }; diff --git a/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx b/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx index 36f13b8d4..f0fc84493 100644 --- a/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx +++ b/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { Link } from "react-router-dom"; import { useFindImages } from "src/core/StashService"; import Slider from "@ant-design/react-slick"; import { ListFilterModel } from "src/models/list-filter/filter"; @@ -26,9 +27,9 @@ export const ImageRecommendationRow: React.FC = (props: IProps) => { className="images-recommendations" header={props.header} link={ - + - + } > { return ( + ); + } + } const renderTabs = () => ( @@ -402,13 +416,7 @@ const PerformerPage: React.FC = ({ performer }) => { {encodingImage ? ( ) : ( - + renderImage() )}
diff --git a/ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx b/ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx index 40611967a..3c094f7ad 100644 --- a/ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx +++ b/ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { Link } from "react-router-dom"; import { useFindPerformers } from "src/core/StashService"; import Slider from "@ant-design/react-slick"; import { PerformerCard } from "./PerformerCard"; @@ -26,9 +27,9 @@ export const PerformerRecommendationRow: React.FC = (props) => { className="performer-recommendations" header={props.header} link={ - + - + } > = ( [props.scene] ); - // studio image is missing if it uses the default - const missingStudioImage = - props.scene.studio?.image_path?.endsWith("?default=true"); - const showStudioAsText = - missingStudioImage || (configuration?.interface.showStudioAsText ?? false); - function maybeRenderSceneSpecsOverlay() { let sizeObj = null; if (file?.size) { @@ -146,21 +140,31 @@ export const SceneCard: React.FC = ( ); } + function renderStudioThumbnail() { + const studioImage = props.scene.studio?.image_path; + const studioName = props.scene.studio?.name; + + if (configuration?.interface.showStudioAsText || !studioImage) { + return studioName; + } + + const studioImageURL = new URL(studioImage); + if (studioImageURL.searchParams.get("default") === "true") { + return studioName; + } + + return ( + {studioName} + ); + } + function maybeRenderSceneStudioOverlay() { if (!props.scene.studio) return; return (
- {showStudioAsText ? ( - props.scene.studio.name - ) : ( - {props.scene.studio.name} - )} + {renderStudioThumbnail()}
); diff --git a/ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx b/ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx index 86ae558f0..d33762761 100644 --- a/ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx +++ b/ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx @@ -1,4 +1,5 @@ import React, { useMemo } from "react"; +import { Link } from "react-router-dom"; import { useFindScenes } from "src/core/StashService"; import Slider from "@ant-design/react-slick"; import { SceneCard } from "./SceneCard"; @@ -31,9 +32,9 @@ export const SceneRecommendationRow: React.FC = (props) => { className="scene-recommendations" header={props.header} link={ - + - + } > = ({ studio }) => { function renderImage() { let studioImage = studio.image_path; if (isEditing) { - if (image === null) { - studioImage = `${studioImage}&default=true`; + if (image === null && studioImage) { + const studioImageURL = new URL(studioImage); + studioImageURL.searchParams.set("default", "true"); + studioImage = studioImageURL.toString(); } else if (image) { studioImage = image; } diff --git a/ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx b/ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx index ec639aeb6..3df4f65c6 100644 --- a/ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx +++ b/ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { Link } from "react-router-dom"; import { useFindStudios } from "src/core/StashService"; import Slider from "@ant-design/react-slick"; import { StudioCard } from "./StudioCard"; @@ -26,9 +27,9 @@ export const StudioRecommendationRow: React.FC = (props) => { className="studio-recommendations" header={props.header} link={ - + - + } > = ({ tag }) => { function renderImage() { let tagImage = tag.image_path; if (isEditing) { - if (image === null) { - tagImage = `${tagImage}&default=true`; + if (image === null && tagImage) { + const tagImageURL = new URL(tagImage); + tagImageURL.searchParams.set("default", "true"); + tagImage = tagImageURL.toString(); } else if (image) { tagImage = image; } diff --git a/ui/v2.5/src/components/Tags/TagRecommendationRow.tsx b/ui/v2.5/src/components/Tags/TagRecommendationRow.tsx index 4e50b4989..9d10d7333 100644 --- a/ui/v2.5/src/components/Tags/TagRecommendationRow.tsx +++ b/ui/v2.5/src/components/Tags/TagRecommendationRow.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { Link } from "react-router-dom"; import { useFindTags } from "src/core/StashService"; import Slider from "@ant-design/react-slick"; import { TagCard } from "./TagCard"; @@ -26,9 +27,9 @@ export const TagRecommendationRow: React.FC = (props) => { className="tag-recommendations" header={props.header} link={ - + - + } > { - const baseURL = window.STASH_BASE_URL; - if (baseURL === "/%BASE_URL%/") return "/"; - return baseURL; -}; +export const baseURL = + document.querySelector("base")?.getAttribute("href") ?? "/"; export const getPlatformURL = (ws?: boolean) => { - const platformUrl = new URL(window.location.origin + getBaseURL()); + const platformUrl = new URL(window.location.origin + baseURL); if (import.meta.env.DEV) { platformUrl.port = import.meta.env.VITE_APP_PLATFORM_PORT ?? "9999"; @@ -139,10 +136,7 @@ export const createClient = () => { // handle unauthorized error by redirecting to the login page if (networkError && (networkError as ServerError).statusCode === 401) { // redirect to login page - const newURL = new URL( - `${getBaseURL()}login`, - window.location.toString() - ); + const newURL = new URL(`${baseURL}login`, window.location.toString()); newURL.searchParams.append("returnURL", window.location.href); window.location.href = newURL.toString(); } diff --git a/ui/v2.5/src/globals.d.ts b/ui/v2.5/src/globals.d.ts index 3bd1b67fe..360388e5f 100644 --- a/ui/v2.5/src/globals.d.ts +++ b/ui/v2.5/src/globals.d.ts @@ -1,5 +1,3 @@ -// eslint-disable-next-line no-var -declare var STASH_BASE_URL: string; declare module "intersection-observer"; declare module "*.md" { diff --git a/ui/v2.5/src/index.tsx b/ui/v2.5/src/index.tsx index ba718f75e..2e66ca2c3 100755 --- a/ui/v2.5/src/index.tsx +++ b/ui/v2.5/src/index.tsx @@ -3,14 +3,14 @@ import ReactDOM from "react-dom"; import { BrowserRouter } from "react-router-dom"; import { App } from "./App"; import { getClient } from "./core/StashService"; -import { getPlatformURL, getBaseURL } from "./core/createClient"; +import { baseURL, getPlatformURL } from "./core/createClient"; import "./index.scss"; import * as serviceWorker from "./serviceWorker"; ReactDOM.render( <> - + diff --git a/vendor/github.com/go-chi/cors/LICENSE b/vendor/github.com/go-chi/cors/LICENSE new file mode 100644 index 000000000..aee6182f9 --- /dev/null +++ b/vendor/github.com/go-chi/cors/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) 2014 Olivier Poitrey +Copyright (c) 2016-Present https://github.com/go-chi authors + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/github.com/go-chi/cors/README.md b/vendor/github.com/go-chi/cors/README.md new file mode 100644 index 000000000..b41686b6a --- /dev/null +++ b/vendor/github.com/go-chi/cors/README.md @@ -0,0 +1,39 @@ +# CORS net/http middleware + +[go-chi/cors](https://github.com/go-chi/cors) is a fork of [github.com/rs/cors](https://github.com/rs/cors) that +provides a `net/http` compatible middleware for performing preflight CORS checks on the server side. These headers +are required for using the browser native [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). + +This middleware is designed to be used as a top-level middleware on the [chi](https://github.com/go-chi/chi) router. +Applying with within a `r.Group()` or using `With()` will not work without routes matching `OPTIONS` added. + +## Usage + +```go +func main() { + r := chi.NewRouter() + + // Basic CORS + // for more ideas, see: https://developer.github.com/v3/#cross-origin-resource-sharing + r.Use(cors.Handler(cors.Options{ + // AllowedOrigins: []string{"https://foo.com"}, // Use this to allow specific origin hosts + AllowedOrigins: []string{"https://*", "http://*"}, + // AllowOriginFunc: func(r *http.Request, origin string) bool { return true }, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, + ExposedHeaders: []string{"Link"}, + AllowCredentials: false, + MaxAge: 300, // Maximum value not ignored by any of major browsers + })) + + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("welcome")) + }) + + http.ListenAndServe(":3000", r) +} +``` + +## Credits + +All credit for the original work of this middleware goes out to [github.com/rs](github.com/rs). diff --git a/vendor/github.com/rs/cors/cors.go b/vendor/github.com/go-chi/cors/cors.go similarity index 71% rename from vendor/github.com/rs/cors/cors.go rename to vendor/github.com/go-chi/cors/cors.go index d301ca724..8df81636e 100644 --- a/vendor/github.com/rs/cors/cors.go +++ b/vendor/github.com/go-chi/cors/cors.go @@ -1,23 +1,21 @@ -/* -Package cors is net/http handler to handle CORS related requests -as defined by http://www.w3.org/TR/cors/ - -You can configure it by passing an option struct to cors.New: - - c := cors.New(cors.Options{ - AllowedOrigins: []string{"foo.com"}, - AllowedMethods: []string{"GET", "POST", "DELETE"}, - AllowCredentials: true, - }) - -Then insert the handler in the chain: - - handler = c.Handler(handler) - -See Options documentation for more options. - -The resulting handler is a standard net/http handler. -*/ +// cors package is net/http handler to handle CORS related requests +// as defined by http://www.w3.org/TR/cors/ +// +// You can configure it by passing an option struct to cors.New: +// +// c := cors.New(cors.Options{ +// AllowedOrigins: []string{"foo.com"}, +// AllowedMethods: []string{"GET", "POST", "DELETE"}, +// AllowCredentials: true, +// }) +// +// Then insert the handler in the chain: +// +// handler = c.Handler(handler) +// +// See Options documentation for more options. +// +// The resulting handler is a standard net/http handler. package cors import ( @@ -37,61 +35,77 @@ type Options struct { // Only one wildcard can be used per origin. // Default value is ["*"] AllowedOrigins []string - // AllowOriginFunc is a custom function to validate the origin. It take the origin + + // AllowOriginFunc is a custom function to validate the origin. It takes the origin // as argument and returns true if allowed or false otherwise. If this option is // set, the content of AllowedOrigins is ignored. - AllowOriginFunc func(origin string) bool - // AllowOriginFunc is a custom function to validate the origin. It takes the HTTP Request object and the origin as - // argument and returns true if allowed or false otherwise. If this option is set, the content of `AllowedOrigins` - // and `AllowOriginFunc` is ignored. - AllowOriginRequestFunc func(r *http.Request, origin string) bool + AllowOriginFunc func(r *http.Request, origin string) bool + // AllowedMethods is a list of methods the client is allowed to use with // cross-domain requests. Default value is simple methods (HEAD, GET and POST). AllowedMethods []string + // AllowedHeaders is list of non simple headers the client is allowed to use with // cross-domain requests. // If the special "*" value is present in the list, all headers will be allowed. // Default value is [] but "Origin" is always appended to the list. AllowedHeaders []string + // ExposedHeaders indicates which headers are safe to expose to the API of a CORS // API specification ExposedHeaders []string - // MaxAge indicates how long (in seconds) the results of a preflight request - // can be cached - MaxAge int + // AllowCredentials indicates whether the request can include user credentials like // cookies, HTTP authentication or client side SSL certificates. AllowCredentials bool + + // MaxAge indicates how long (in seconds) the results of a preflight request + // can be cached + MaxAge int + // OptionsPassthrough instructs preflight to let other potential next handlers to // process the OPTIONS method. Turn this on if your application handles OPTIONS. OptionsPassthrough bool + // Debugging flag adds additional output to debug server side CORS issues Debug bool } +// Logger generic interface for logger +type Logger interface { + Printf(string, ...interface{}) +} + // Cors http handler type Cors struct { // Debug logger - Log *log.Logger + Log Logger + // Normalized list of plain allowed origins allowedOrigins []string + // List of allowed origins containing wildcards allowedWOrigins []wildcard + // Optional origin validator function - allowOriginFunc func(origin string) bool - // Optional origin validator (with request) function - allowOriginRequestFunc func(r *http.Request, origin string) bool + allowOriginFunc func(r *http.Request, origin string) bool + // Normalized list of allowed headers allowedHeaders []string + // Normalized list of allowed methods allowedMethods []string + // Normalized list of exposed headers exposedHeaders []string maxAge int + // Set to true when allowed origins contains a "*" allowedOriginsAll bool + // Set to true when allowed headers contains a "*" allowedHeadersAll bool + allowCredentials bool optionPassthrough bool } @@ -99,14 +113,13 @@ type Cors struct { // New creates a new Cors handler with the provided options. func New(options Options) *Cors { c := &Cors{ - exposedHeaders: convert(options.ExposedHeaders, http.CanonicalHeaderKey), - allowOriginFunc: options.AllowOriginFunc, - allowOriginRequestFunc: options.AllowOriginRequestFunc, - allowCredentials: options.AllowCredentials, - maxAge: options.MaxAge, - optionPassthrough: options.OptionsPassthrough, + exposedHeaders: convert(options.ExposedHeaders, http.CanonicalHeaderKey), + allowOriginFunc: options.AllowOriginFunc, + allowCredentials: options.AllowCredentials, + maxAge: options.MaxAge, + optionPassthrough: options.OptionsPassthrough, } - if options.Debug { + if options.Debug && c.Log == nil { c.Log = log.New(os.Stdout, "[cors] ", log.LstdFlags) } @@ -116,7 +129,7 @@ func New(options Options) *Cors { // Allowed Origins if len(options.AllowedOrigins) == 0 { - if options.AllowOriginFunc == nil && options.AllowOriginRequestFunc == nil { + if options.AllowOriginFunc == nil { // Default is all origins c.allowedOriginsAll = true } @@ -145,7 +158,7 @@ func New(options Options) *Cors { // Allowed Headers if len(options.AllowedHeaders) == 0 { // Use sensible defaults - c.allowedHeaders = []string{"Origin", "Accept", "Content-Type", "X-Requested-With"} + c.allowedHeaders = []string{"Origin", "Accept", "Content-Type"} } else { // Origin is always appended as some browsers will always request for this header at preflight c.allowedHeaders = convert(append(options.AllowedHeaders, "Origin"), http.CanonicalHeaderKey) @@ -161,7 +174,7 @@ func New(options Options) *Cors { // Allowed Methods if len(options.AllowedMethods) == 0 { // Default is spec's "simple" methods - c.allowedMethods = []string{"GET", "POST", "HEAD"} + c.allowedMethods = []string{http.MethodGet, http.MethodPost, http.MethodHead} } else { c.allowedMethods = convert(options.AllowedMethods, strings.ToUpper) } @@ -169,17 +182,25 @@ func New(options Options) *Cors { return c } -// Default creates a new Cors handler with default options. -func Default() *Cors { - return New(Options{}) +// Handler creates a new Cors handler with passed options. +func Handler(options Options) func(next http.Handler) http.Handler { + c := New(options) + return c.Handler } // AllowAll create a new Cors handler with permissive configuration allowing all // origins with all standard methods with any header and credentials. func AllowAll() *Cors { return New(Options{ - AllowedOrigins: []string{"*"}, - AllowedMethods: []string{"HEAD", "GET", "POST", "PUT", "PATCH", "DELETE"}, + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{ + http.MethodHead, + http.MethodGet, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + }, AllowedHeaders: []string{"*"}, AllowCredentials: false, }) @@ -187,7 +208,7 @@ func AllowAll() *Cors { // Handler apply the CORS specification on the request, and add relevant CORS headers // as necessary. -func (c *Cors) Handler(h http.Handler) http.Handler { +func (c *Cors) Handler(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" { c.logf("Handler: Preflight request") @@ -197,57 +218,25 @@ func (c *Cors) Handler(h http.Handler) http.Handler { // is authentication middleware ; OPTIONS requests won't carry authentication // headers (see #1) if c.optionPassthrough { - h.ServeHTTP(w, r) + next.ServeHTTP(w, r) } else { w.WriteHeader(http.StatusOK) } } else { c.logf("Handler: Actual request") c.handleActualRequest(w, r) - h.ServeHTTP(w, r) + next.ServeHTTP(w, r) } }) } -// HandlerFunc provides Martini compatible handler -func (c *Cors) HandlerFunc(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" { - c.logf("HandlerFunc: Preflight request") - c.handlePreflight(w, r) - } else { - c.logf("HandlerFunc: Actual request") - c.handleActualRequest(w, r) - } -} - -// Negroni compatible interface -func (c *Cors) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { - if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" { - c.logf("ServeHTTP: Preflight request") - c.handlePreflight(w, r) - // Preflight requests are standalone and should stop the chain as some other - // middleware may not handle OPTIONS requests correctly. One typical example - // is authentication middleware ; OPTIONS requests won't carry authentication - // headers (see #1) - if c.optionPassthrough { - next(w, r) - } else { - w.WriteHeader(http.StatusOK) - } - } else { - c.logf("ServeHTTP: Actual request") - c.handleActualRequest(w, r) - next(w, r) - } -} - // handlePreflight handles pre-flight CORS requests func (c *Cors) handlePreflight(w http.ResponseWriter, r *http.Request) { headers := w.Header() origin := r.Header.Get("Origin") if r.Method != http.MethodOptions { - c.logf(" Preflight aborted: %s!=OPTIONS", r.Method) + c.logf("Preflight aborted: %s!=OPTIONS", r.Method) return } // Always set Vary headers @@ -258,22 +247,22 @@ func (c *Cors) handlePreflight(w http.ResponseWriter, r *http.Request) { headers.Add("Vary", "Access-Control-Request-Headers") if origin == "" { - c.logf(" Preflight aborted: empty origin") + c.logf("Preflight aborted: empty origin") return } if !c.isOriginAllowed(r, origin) { - c.logf(" Preflight aborted: origin '%s' not allowed", origin) + c.logf("Preflight aborted: origin '%s' not allowed", origin) return } reqMethod := r.Header.Get("Access-Control-Request-Method") if !c.isMethodAllowed(reqMethod) { - c.logf(" Preflight aborted: method '%s' not allowed", reqMethod) + c.logf("Preflight aborted: method '%s' not allowed", reqMethod) return } reqHeaders := parseHeaderList(r.Header.Get("Access-Control-Request-Headers")) if !c.areHeadersAllowed(reqHeaders) { - c.logf(" Preflight aborted: headers '%v' not allowed", reqHeaders) + c.logf("Preflight aborted: headers '%v' not allowed", reqHeaders) return } if c.allowedOriginsAll { @@ -296,7 +285,7 @@ func (c *Cors) handlePreflight(w http.ResponseWriter, r *http.Request) { if c.maxAge > 0 { headers.Set("Access-Control-Max-Age", strconv.Itoa(c.maxAge)) } - c.logf(" Preflight response headers: %v", headers) + c.logf("Preflight response headers: %v", headers) } // handleActualRequest handles simple cross-origin requests, actual request or redirects @@ -304,18 +293,14 @@ func (c *Cors) handleActualRequest(w http.ResponseWriter, r *http.Request) { headers := w.Header() origin := r.Header.Get("Origin") - if r.Method == http.MethodOptions { - c.logf(" Actual request no headers added: method == %s", r.Method) - return - } // Always set Vary, see https://github.com/rs/cors/issues/10 headers.Add("Vary", "Origin") if origin == "" { - c.logf(" Actual request no headers added: missing origin") + c.logf("Actual request no headers added: missing origin") return } if !c.isOriginAllowed(r, origin) { - c.logf(" Actual request no headers added: origin '%s' not allowed", origin) + c.logf("Actual request no headers added: origin '%s' not allowed", origin) return } @@ -324,7 +309,7 @@ func (c *Cors) handleActualRequest(w http.ResponseWriter, r *http.Request) { // spec doesn't instruct to check the allowed methods for simple cross-origin requests. // We think it's a nice feature to be able to have control on those methods though. if !c.isMethodAllowed(r.Method) { - c.logf(" Actual request no headers added: method '%s' not allowed", r.Method) + c.logf("Actual request no headers added: method '%s' not allowed", r.Method) return } @@ -339,10 +324,10 @@ func (c *Cors) handleActualRequest(w http.ResponseWriter, r *http.Request) { if c.allowCredentials { headers.Set("Access-Control-Allow-Credentials", "true") } - c.logf(" Actual response added headers: %v", headers) + c.logf("Actual response added headers: %v", headers) } -// convenience method. checks if debugging is turned on before printing +// convenience method. checks if a logger is set. func (c *Cors) logf(format string, a ...interface{}) { if c.Log != nil { c.Log.Printf(format, a...) @@ -352,11 +337,8 @@ func (c *Cors) logf(format string, a ...interface{}) { // isOriginAllowed checks if a given origin is allowed to perform cross-domain requests // on the endpoint func (c *Cors) isOriginAllowed(r *http.Request, origin string) bool { - if c.allowOriginRequestFunc != nil { - return c.allowOriginRequestFunc(r, origin) - } if c.allowOriginFunc != nil { - return c.allowOriginFunc(origin) + return c.allowOriginFunc(r, origin) } if c.allowedOriginsAll { return true @@ -376,7 +358,7 @@ func (c *Cors) isOriginAllowed(r *http.Request, origin string) bool { } // isMethodAllowed checks if a given method can be used as part of a cross-domain request -// on the endpoing +// on the endpoint func (c *Cors) isMethodAllowed(method string) bool { if len(c.allowedMethods) == 0 { // If no method allowed, always return false, even for preflight request @@ -407,6 +389,7 @@ func (c *Cors) areHeadersAllowed(requestedHeaders []string) bool { for _, h := range c.allowedHeaders { if h == header { found = true + break } } if !found { diff --git a/vendor/github.com/rs/cors/utils.go b/vendor/github.com/go-chi/cors/utils.go similarity index 89% rename from vendor/github.com/rs/cors/utils.go rename to vendor/github.com/go-chi/cors/utils.go index 53ad9e9db..3fe5a5aee 100644 --- a/vendor/github.com/rs/cors/utils.go +++ b/vendor/github.com/go-chi/cors/utils.go @@ -39,20 +39,19 @@ func parseHeaderList(headerList string) []string { headers := make([]string, 0, t) for i := 0; i < l; i++ { b := headerList[i] - switch { - case b >= 'a' && b <= 'z': + if b >= 'a' && b <= 'z' { if upper { h = append(h, b-toLower) } else { h = append(h, b) } - case b >= 'A' && b <= 'Z': + } else if b >= 'A' && b <= 'Z' { if !upper { h = append(h, b+toLower) } else { h = append(h, b) } - case b == '-' || b == '_' || (b >= '0' && b <= '9'): + } else if b == '-' || b == '_' || b == '.' || (b >= '0' && b <= '9') { h = append(h, b) } @@ -64,7 +63,7 @@ func parseHeaderList(headerList string) []string { upper = true } } else { - upper = b == '-' || b == '_' + upper = b == '-' } } return headers diff --git a/vendor/github.com/rs/cors/.travis.yml b/vendor/github.com/rs/cors/.travis.yml deleted file mode 100644 index 17e5e50d5..000000000 --- a/vendor/github.com/rs/cors/.travis.yml +++ /dev/null @@ -1,8 +0,0 @@ -language: go -go: -- 1.9 -- "1.10" -- tip -matrix: - allow_failures: - - go: tip diff --git a/vendor/github.com/rs/cors/LICENSE b/vendor/github.com/rs/cors/LICENSE deleted file mode 100644 index d8e2df5a4..000000000 --- a/vendor/github.com/rs/cors/LICENSE +++ /dev/null @@ -1,19 +0,0 @@ -Copyright (c) 2014 Olivier Poitrey - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished -to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/vendor/github.com/rs/cors/README.md b/vendor/github.com/rs/cors/README.md deleted file mode 100644 index ecc83b295..000000000 --- a/vendor/github.com/rs/cors/README.md +++ /dev/null @@ -1,115 +0,0 @@ -# Go CORS handler [![godoc](http://img.shields.io/badge/godoc-reference-blue.svg?style=flat)](https://godoc.org/github.com/rs/cors) [![license](http://img.shields.io/badge/license-MIT-red.svg?style=flat)](https://raw.githubusercontent.com/rs/cors/master/LICENSE) [![build](https://img.shields.io/travis/rs/cors.svg?style=flat)](https://travis-ci.org/rs/cors) [![Coverage](http://gocover.io/_badge/github.com/rs/cors)](http://gocover.io/github.com/rs/cors) - -CORS is a `net/http` handler implementing [Cross Origin Resource Sharing W3 specification](http://www.w3.org/TR/cors/) in Golang. - -## Getting Started - -After installing Go and setting up your [GOPATH](http://golang.org/doc/code.html#GOPATH), create your first `.go` file. We'll call it `server.go`. - -```go -package main - -import ( - "net/http" - - "github.com/rs/cors" -) - -func main() { - mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write([]byte("{\"hello\": \"world\"}")) - }) - - // cors.Default() setup the middleware with default options being - // all origins accepted with simple methods (GET, POST). See - // documentation below for more options. - handler := cors.Default().Handler(mux) - http.ListenAndServe(":8080", handler) -} -``` - -Install `cors`: - - go get github.com/rs/cors - -Then run your server: - - go run server.go - -The server now runs on `localhost:8080`: - - $ curl -D - -H 'Origin: http://foo.com' http://localhost:8080/ - HTTP/1.1 200 OK - Access-Control-Allow-Origin: foo.com - Content-Type: application/json - Date: Sat, 25 Oct 2014 03:43:57 GMT - Content-Length: 18 - - {"hello": "world"} - -### Allow * With Credentials Security Protection - -This library has been modified to avoid a well known security issue when configured with `AllowedOrigins` to `*` and `AllowCredentials` to `true`. Such setup used to make the library reflects the request `Origin` header value, working around a security protection embedded into the standard that makes clients to refuse such configuration. This behavior has been removed with [#55](https://github.com/rs/cors/issues/55) and [#57](https://github.com/rs/cors/issues/57). - -If you depend on this behavior and understand the implications, you can restore it using the `AllowOriginFunc` with `func(origin string) {return true}`. - -Please refer to [#55](https://github.com/rs/cors/issues/55) for more information about the security implications. - -### More Examples - -* `net/http`: [examples/nethttp/server.go](https://github.com/rs/cors/blob/master/examples/nethttp/server.go) -* [Goji](https://goji.io): [examples/goji/server.go](https://github.com/rs/cors/blob/master/examples/goji/server.go) -* [Martini](http://martini.codegangsta.io): [examples/martini/server.go](https://github.com/rs/cors/blob/master/examples/martini/server.go) -* [Negroni](https://github.com/codegangsta/negroni): [examples/negroni/server.go](https://github.com/rs/cors/blob/master/examples/negroni/server.go) -* [Alice](https://github.com/justinas/alice): [examples/alice/server.go](https://github.com/rs/cors/blob/master/examples/alice/server.go) -* [HttpRouter](https://github.com/julienschmidt/httprouter): [examples/httprouter/server.go](https://github.com/rs/cors/blob/master/examples/httprouter/server.go) -* [Gorilla](http://www.gorillatoolkit.org/pkg/mux): [examples/gorilla/server.go](https://github.com/rs/cors/blob/master/examples/gorilla/server.go) -* [Buffalo](https://gobuffalo.io): [examples/buffalo/server.go](https://github.com/rs/cors/blob/master/examples/buffalo/server.go) -* [Gin](https://gin-gonic.github.io/gin): [examples/gin/server.go](https://github.com/rs/cors/blob/master/examples/gin/server.go) -* [Chi](https://github.com/go-chi/chi): [examples/chi/server.go](https://github.com/rs/cors/blob/master/examples/chi/server.go) - -## Parameters - -Parameters are passed to the middleware thru the `cors.New` method as follow: - -```go -c := cors.New(cors.Options{ - AllowedOrigins: []string{"http://foo.com", "http://foo.com:8080"}, - AllowCredentials: true, - // Enable Debugging for testing, consider disabling in production - Debug: true, -}) - -// Insert the middleware -handler = c.Handler(handler) -``` - -* **AllowedOrigins** `[]string`: A list of origins a cross-domain request can be executed from. If the special `*` value is present in the list, all origins will be allowed. An origin may contain a wildcard (`*`) to replace 0 or more characters (i.e.: `http://*.domain.com`). Usage of wildcards implies a small performance penality. Only one wildcard can be used per origin. The default value is `*`. -* **AllowOriginFunc** `func (origin string) bool`: A custom function to validate the origin. It takes the origin as an argument and returns true if allowed, or false otherwise. If this option is set, the content of `AllowedOrigins` is ignored. -* **AllowOriginRequestFunc** `func (r *http.Request origin string) bool`: A custom function to validate the origin. It takes the HTTP Request object and the origin as argument and returns true if allowed or false otherwise. If this option is set, the content of `AllowedOrigins` and `AllowOriginFunc` is ignored -* **AllowedMethods** `[]string`: A list of methods the client is allowed to use with cross-domain requests. Default value is simple methods (`GET` and `POST`). -* **AllowedHeaders** `[]string`: A list of non simple headers the client is allowed to use with cross-domain requests. -* **ExposedHeaders** `[]string`: Indicates which headers are safe to expose to the API of a CORS API specification -* **AllowCredentials** `bool`: Indicates whether the request can include user credentials like cookies, HTTP authentication or client side SSL certificates. The default is `false`. -* **MaxAge** `int`: Indicates how long (in seconds) the results of a preflight request can be cached. The default is `0` which stands for no max age. -* **OptionsPassthrough** `bool`: Instructs preflight to let other potential next handlers to process the `OPTIONS` method. Turn this on if your application handles `OPTIONS`. -* **Debug** `bool`: Debugging flag adds additional output to debug server side CORS issues. - -See [API documentation](http://godoc.org/github.com/rs/cors) for more info. - -## Benchmarks - - BenchmarkWithout 20000000 64.6 ns/op 8 B/op 1 allocs/op - BenchmarkDefault 3000000 469 ns/op 114 B/op 2 allocs/op - BenchmarkAllowedOrigin 3000000 608 ns/op 114 B/op 2 allocs/op - BenchmarkPreflight 20000000 73.2 ns/op 0 B/op 0 allocs/op - BenchmarkPreflightHeader 20000000 73.6 ns/op 0 B/op 0 allocs/op - BenchmarkParseHeaderList 2000000 847 ns/op 184 B/op 6 allocs/op - BenchmarkParse…Single 5000000 290 ns/op 32 B/op 3 allocs/op - BenchmarkParse…Normalized 2000000 776 ns/op 160 B/op 6 allocs/op - -## Licenses - -All source code is licensed under the [MIT License](https://raw.github.com/rs/cors/master/LICENSE). diff --git a/vendor/modules.txt b/vendor/modules.txt index c7b53d4f0..b53f16a6b 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -154,6 +154,9 @@ github.com/go-chi/chi/middleware ## explicit; go 1.16 github.com/go-chi/chi/v5 github.com/go-chi/chi/v5/middleware +# github.com/go-chi/cors v1.2.1 +## explicit; go 1.14 +github.com/go-chi/cors # github.com/go-chi/httplog v0.2.1 ## explicit; go 1.14 github.com/go-chi/httplog @@ -308,9 +311,6 @@ github.com/robertkrimen/otto/file github.com/robertkrimen/otto/parser github.com/robertkrimen/otto/registry github.com/robertkrimen/otto/token -# github.com/rs/cors v1.6.0 -## explicit -github.com/rs/cors # github.com/rs/zerolog v1.26.1 ## explicit; go 1.15 github.com/rs/zerolog From 90683bd2632ffbf06a3bba39a2f1a2499c2304d7 Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Wed, 19 Apr 2023 05:06:53 +0200 Subject: [PATCH 14/81] Fix zip gallery moving (#3610) * Fix folder ZipFileID synchronization * Fix zip gallery moving --- pkg/file/folder.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ pkg/file/move.go | 45 +-------------------------------------------- pkg/file/scan.go | 35 ++++++++++++++++++++++++++++++++--- 3 files changed, 77 insertions(+), 47 deletions(-) diff --git a/pkg/file/folder.go b/pkg/file/folder.go index 719d1a1f9..5ffd7f2b5 100644 --- a/pkg/file/folder.go +++ b/pkg/file/folder.go @@ -6,6 +6,7 @@ import ( "io/fs" "path/filepath" "strconv" + "strings" "time" ) @@ -122,3 +123,46 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc FolderFinderCreator, pat return folder, nil } + +// TransferZipFolderHierarchy creates the folder hierarchy for zipFileID under newPath, and removes +// ZipFileID from folders under oldPath. +func TransferZipFolderHierarchy(ctx context.Context, folderStore FolderStore, zipFileID ID, oldPath string, newPath string) error { + zipFolders, err := folderStore.FindByZipFileID(ctx, zipFileID) + if err != nil { + return err + } + + for _, oldFolder := range zipFolders { + oldZfPath := oldFolder.Path + + // sanity check - ignore folders which aren't under oldPath + if !strings.HasPrefix(oldZfPath, oldPath) { + continue + } + + relZfPath, err := filepath.Rel(oldPath, oldZfPath) + if err != nil { + return err + } + newZfPath := filepath.Join(newPath, relZfPath) + + newFolder, err := GetOrCreateFolderHierarchy(ctx, folderStore, newZfPath) + if err != nil { + return err + } + + // add ZipFileID to new folder + newFolder.ZipFileID = &zipFileID + if err = folderStore.Update(ctx, newFolder); err != nil { + return err + } + + // remove ZipFileID from old folder + oldFolder.ZipFileID = nil + if err = folderStore.Update(ctx, oldFolder); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/file/move.go b/pkg/file/move.go index 3e29e328c..3b3c66ec5 100644 --- a/pkg/file/move.go +++ b/pkg/file/move.go @@ -87,7 +87,7 @@ func (m *Mover) Move(ctx context.Context, f File, folder *Folder, basename strin return fmt.Errorf("file %s already exists", newPath) } - if err := m.transferZipFolderHierarchy(ctx, fBase.ID, oldPath, newPath); err != nil { + if err := TransferZipFolderHierarchy(ctx, m.Folders, fBase.ID, oldPath, newPath); err != nil { return fmt.Errorf("moving folder hierarchy for file %s: %w", fBase.Path, err) } @@ -166,49 +166,6 @@ func (m *Mover) CreateFolderHierarchy(path string) error { return nil } -// transferZipFolderHierarchy creates the folder hierarchy for zipFileID under newPath, and removes -// ZipFileID from folders under oldPath. -func (m *Mover) transferZipFolderHierarchy(ctx context.Context, zipFileID ID, oldPath string, newPath string) error { - zipFolders, err := m.Folders.FindByZipFileID(ctx, zipFileID) - if err != nil { - return err - } - - for _, oldFolder := range zipFolders { - oldZfPath := oldFolder.Path - - // sanity check - ignore folders which aren't under oldPath - if !strings.HasPrefix(oldZfPath, oldPath) { - continue - } - - relZfPath, err := filepath.Rel(oldPath, oldZfPath) - if err != nil { - return err - } - newZfPath := filepath.Join(newPath, relZfPath) - - newFolder, err := GetOrCreateFolderHierarchy(ctx, m.Folders, newZfPath) - if err != nil { - return err - } - - // add ZipFileID to new folder - newFolder.ZipFileID = &zipFileID - if err = m.Folders.Update(ctx, newFolder); err != nil { - return err - } - - // remove ZipFileID from old folder - oldFolder.ZipFileID = nil - if err = m.Folders.Update(ctx, oldFolder); err != nil { - return err - } - } - - return nil -} - func (m *Mover) moveFile(oldPath, newPath string) error { if err := m.Renamer.Rename(oldPath, newPath); err != nil { return fmt.Errorf("renaming file %s to %s: %w", oldPath, newPath, err) diff --git a/pkg/file/scan.go b/pkg/file/scan.go index 148f18691..dcd625ff6 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -523,13 +523,29 @@ func (s *scanJob) onNewFolder(ctx context.Context, file scanFile) (*Folder, erro } func (s *scanJob) onExistingFolder(ctx context.Context, f scanFile, existing *Folder) (*Folder, error) { - // check if the mod time is changed + update := false + + // update if mod time is changed entryModTime := f.ModTime - if !entryModTime.Equal(existing.ModTime) { - // update entry in store existing.ModTime = entryModTime + update = true + } + // update if zip file ID has changed + fZfID := f.ZipFileID + existingZfID := existing.ZipFileID + if fZfID != existingZfID { + if fZfID == nil { + existing.ZipFileID = nil + update = true + } else if existingZfID == nil || *fZfID != *existingZfID { + existing.ZipFileID = fZfID + update = true + } + } + + if update { var err error if err = s.Repository.FolderStore.Update(ctx, existing); err != nil { return nil, fmt.Errorf("updating folder %q: %w", f.Path, err) @@ -753,7 +769,14 @@ func (s *scanJob) handleRename(ctx context.Context, f File, fp []Fingerprint) (F var missing []File + fZipID := f.Base().ZipFileID for _, other := range others { + // if file is from a zip file, then only rename if both files are from the same zip file + otherZipID := other.Base().ZipFileID + if otherZipID != nil && (fZipID == nil || *otherZipID != *fZipID) { + continue + } + // if file does not exist, then update it to the new path fs, err := s.getFileFS(other.Base()) if err != nil { @@ -798,6 +821,12 @@ func (s *scanJob) handleRename(ctx context.Context, f File, fp []Fingerprint) (F return fmt.Errorf("updating file for rename %q: %w", fBase.Path, err) } + if s.isZipFile(fBase.Basename) { + if err := TransferZipFolderHierarchy(ctx, s.Repository.FolderStore, fBase.ID, otherBase.Path, fBase.Path); err != nil { + return fmt.Errorf("moving folder hierarchy for renamed zip file %q: %w", fBase.Path, err) + } + } + if err := s.fireHandlers(ctx, f, other); err != nil { return err } From 203afb3d1ba56b8adf5ace133181612037aa4e87 Mon Sep 17 00:00:00 2001 From: trashcom <129524068+trashcom@users.noreply.github.com> Date: Mon, 24 Apr 2023 15:32:17 -0500 Subject: [PATCH 15/81] Use chevron icons instead of < and > (#3674) * Use chevron icons instead of < and > * Formatting with prettier * Update dividers for gallery, performer, studio and tag views to use chevrons --- .../components/Galleries/GalleryDetails/Gallery.tsx | 12 ++++++++---- .../Performers/PerformerDetails/Performer.tsx | 13 +++++++++---- .../components/ScenePlayer/ScenePlayerScrubber.tsx | 9 +++++++-- ui/v2.5/src/components/ScenePlayer/styles.scss | 4 ++-- .../src/components/Scenes/SceneDetails/Scene.tsx | 12 ++++++++---- .../src/components/Studios/StudioDetails/Studio.tsx | 13 +++++++++---- ui/v2.5/src/components/Tags/TagDetails/Tag.tsx | 8 +++++--- 7 files changed, 48 insertions(+), 23 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index 1adedc799..057642b1a 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -24,7 +24,11 @@ import { GalleryImagesPanel } from "./GalleryImagesPanel"; import { GalleryAddPanel } from "./GalleryAddPanel"; import { GalleryFileInfoPanel } from "./GalleryFileInfoPanel"; import { GalleryScenesPanel } from "./GalleryScenesPanel"; -import { faEllipsisV } from "@fortawesome/free-solid-svg-icons"; +import { + faEllipsisV, + faChevronRight, + faChevronLeft, +} from "@fortawesome/free-solid-svg-icons"; import { galleryPath, galleryTitle } from "src/core/galleries"; import { GalleryChapterPanel } from "./GalleryChaptersPanel"; @@ -78,8 +82,8 @@ export const GalleryPage: React.FC = ({ gallery }) => { } }; - function getCollapseButtonText() { - return collapsed ? ">" : "<"; + function getCollapseButtonIcon() { + return collapsed ? faChevronRight : faChevronLeft; } async function onRescan() { @@ -339,7 +343,7 @@ export const GalleryPage: React.FC = ({ gallery }) => {
diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index 15c63beff..fea046045 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -31,7 +31,12 @@ import { PerformerImagesPanel } from "./PerformerImagesPanel"; import { PerformerEditPanel } from "./PerformerEditPanel"; import { PerformerSubmitButton } from "./PerformerSubmitButton"; import GenderIcon from "../GenderIcon"; -import { faHeart, faLink } from "@fortawesome/free-solid-svg-icons"; +import { + faHeart, + faLink, + faChevronRight, + faChevronLeft, +} from "@fortawesome/free-solid-svg-icons"; import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons"; import { IUIConfig } from "src/core/config"; import { useRatingKeybinds } from "src/hooks/keybinds"; @@ -398,8 +403,8 @@ const PerformerPage: React.FC = ({ performer }) => { /> ); - function getCollapseButtonText() { - return collapsed ? ">" : "<"; + function getCollapseButtonIcon() { + return collapsed ? faChevronRight : faChevronLeft; } return ( @@ -421,7 +426,7 @@ const PerformerPage: React.FC = ({ performer }) => {
diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx index 565005cf8..68dfbb406 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx @@ -10,6 +10,11 @@ import axios from "axios"; import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; import { WebVTT } from "videojs-vtt.js"; +import { Icon } from "src/components/Shared/Icon"; +import { + faChevronRight, + faChevronLeft, +} from "@fortawesome/free-solid-svg-icons"; interface IScenePlayerScrubberProps { file: GQL.VideoFileDataFragment; @@ -346,7 +351,7 @@ export const ScenePlayerScrubber: React.FC = ({ id="scrubber-back" onClick={() => goBack()} > - < +
@@ -372,7 +377,7 @@ export const ScenePlayerScrubber: React.FC = ({ id="scrubber-forward" onClick={() => goForward()} > - > +
); diff --git a/ui/v2.5/src/components/ScenePlayer/styles.scss b/ui/v2.5/src/components/ScenePlayer/styles.scss index 7d324fc69..010ed1dcc 100644 --- a/ui/v2.5/src/components/ScenePlayer/styles.scss +++ b/ui/v2.5/src/components/ScenePlayer/styles.scss @@ -359,13 +359,13 @@ $sceneTabWidth: 450px; border: 1px solid #555; color: $link-color; cursor: pointer; - font-size: 1.3rem; + font-size: 1.1rem; font-weight: 800; height: 100%; line-height: $scrubberHeight; padding: 0; text-align: center; - width: 1.3rem; + width: 1.8rem; } .scrubber-content { diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 21ba9d5c4..847237cd6 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -28,7 +28,11 @@ import { OCounterButton } from "./OCounterButton"; import { OrganizedButton } from "./OrganizedButton"; import { ConfigurationContext } from "src/hooks/Config"; import { getPlayerPosition } from "src/components/ScenePlayer/util"; -import { faEllipsisV } from "@fortawesome/free-solid-svg-icons"; +import { + faEllipsisV, + faChevronRight, + faChevronLeft, +} from "@fortawesome/free-solid-svg-icons"; import { lazyComponent } from "src/utils/lazyComponent"; const SubmitStashBoxDraft = lazyComponent( @@ -464,8 +468,8 @@ const ScenePage: React.FC = ({ ); - function getCollapseButtonText() { - return collapsed ? ">" : "<"; + function getCollapseButtonIcon() { + return collapsed ? faChevronRight : faChevronLeft; } const title = objectTitle(scene); @@ -500,7 +504,7 @@ const ScenePage: React.FC = ({
= ({ studio }) => { } }; - function getCollapseButtonText() { - return collapsed ? ">" : "<"; + function getCollapseButtonIcon() { + return collapsed ? faChevronRight : faChevronLeft; } return ( @@ -229,7 +234,7 @@ const StudioPage: React.FC = ({ studio }) => {
diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index 2f34e4119..07f7db40b 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -33,6 +33,8 @@ import { faSignInAlt, faSignOutAlt, faTrashAlt, + faChevronRight, + faChevronLeft, } from "@fortawesome/free-solid-svg-icons"; import { IUIConfig } from "src/core/config"; @@ -251,8 +253,8 @@ const TagPage: React.FC = ({ tag }) => { ); } - function getCollapseButtonText() { - return collapsed ? ">" : "<"; + function getCollapseButtonIcon() { + return collapsed ? faChevronRight : faChevronLeft; } return ( @@ -304,7 +306,7 @@ const TagPage: React.FC = ({ tag }) => {
From 152f9114b22c653ec042fb0aad054a780e2f88de Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Mon, 24 Apr 2023 22:56:21 +0200 Subject: [PATCH 16/81] Add hint for alias duplicates (#3653) * Add hint for duplicate aliases * Fix spacing * Fix country select border * Improve date picker header alignment --- .../PerformerDetails/PerformerEditPanel.tsx | 34 ++++++++++++------- .../src/components/Shared/StringListInput.tsx | 8 +++-- ui/v2.5/src/components/Shared/styles.scss | 25 ++++++-------- .../Studios/StudioDetails/StudioEditPanel.tsx | 34 ++++++++++++------- .../Tags/TagDetails/TagEditPanel.tsx | 34 ++++++++++++------- 5 files changed, 82 insertions(+), 53 deletions(-) diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index b92330fad..e8c2ef028 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -104,14 +104,19 @@ export const PerformerEditPanel: React.FC = ({ .test({ name: "unique", test: (value, context) => { - if (!value) return true; - const aliases = new Set(value); - aliases.add(context.parent.name); - return value.length + 1 === aliases.size; + const aliases = [context.parent.name, ...value]; + const dupes = aliases + .map((e, i, a) => { + if (a.indexOf(e) !== i) { + return String(i - 1); + } else { + return null; + } + }) + .filter((e) => e !== null) as string[]; + if (dupes.length === 0) return true; + return new yup.ValidationError(dupes.join(" "), value, "alias_list"); }, - message: intl.formatMessage({ - id: "validation.aliases_must_be_unique", - }), }), gender: yup.string().ensure(), birthdate: yup @@ -845,6 +850,14 @@ export const PerformerEditPanel: React.FC = ({ ); } + const aliasErrors = Array.isArray(formik.errors.alias_list) + ? formik.errors.alias_list[0] + : formik.errors.alias_list; + const aliasErrorMsg = aliasErrors + ? intl.formatMessage({ id: "validation.aliases_must_be_unique" }) + : undefined; + const aliasErrorIdx = aliasErrors?.split(" ").map((e) => parseInt(e)); + return ( <> {renderScrapeModal()} @@ -899,11 +912,8 @@ export const PerformerEditPanel: React.FC = ({ formik.setFieldValue("alias_list", value)} - errors={ - Array.isArray(formik.errors.alias_list) - ? formik.errors.alias_list[0] - : formik.errors.alias_list - } + errors={aliasErrorMsg} + errorIdx={aliasErrorIdx} /> diff --git a/ui/v2.5/src/components/Shared/StringListInput.tsx b/ui/v2.5/src/components/Shared/StringListInput.tsx index ad89b6f63..2c8f16ee8 100644 --- a/ui/v2.5/src/components/Shared/StringListInput.tsx +++ b/ui/v2.5/src/components/Shared/StringListInput.tsx @@ -9,6 +9,7 @@ interface IStringListInputProps { placeholder?: string; className?: string; errors?: string; + errorIdx?: number[]; } export const StringListInput: React.FC = (props) => { @@ -35,10 +36,11 @@ export const StringListInput: React.FC = (props) => {
{values.map((v, i) => ( - // eslint-disable-next-line react/no-array-index-key ) => valueChanged(i, e.currentTarget.value) @@ -58,7 +60,7 @@ export const StringListInput: React.FC = (props) => { ))}
-
{props.errors}
+
{props.errors}
); }; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 2e63b4c75..067f8cf4b 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -31,8 +31,8 @@ } .details-edit { - /* - The penultimate button should be wrapped in an unstyled div. + /* + The penultimate button should be wrapped in an unstyled div. This allows the div to expand, to right-justify the last (save / delete) button. */ @@ -285,8 +285,8 @@ button.collapse-button.btn-primary:not(:disabled):not(.disabled):active { opacity: 0.5; } -.string-list-input .text-input { - margin-bottom: 0.25rem; +.string-list-input .input-group { + margin-bottom: 0.35rem; } .bulk-update-text-input { @@ -316,14 +316,6 @@ button.collapse-button.btn-primary:not(:disabled):not(.disabled):active { } } -.CountrySelect { - /* stylelint-disable */ - .react-select__control:hover { - border: none; - } - /* stylelint-enable */ -} - .date-input.form-control:focus { // z-index gets set to 3 in input groups z-index: inherit; @@ -339,6 +331,11 @@ div.react-datepicker { .react-datepicker-time__header { background-color: $secondary; color: $text-color; + padding-top: 0.4rem; + } + + .react-datepicker__navigation { + top: 0.4rem; } .react-datepicker__day { @@ -376,11 +373,11 @@ div.react-datepicker { .react-datepicker__month-dropdown-container { margin-left: 0; - margin-right: 0.25rem; + margin-right: 0.1rem; } .react-datepicker__year-dropdown-container { - margin-left: 0.25rem; + margin-left: 0.1rem; margin-right: 0; } diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index 1955bfc54..c2a469a70 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -53,14 +53,19 @@ export const StudioEditPanel: React.FC = ({ .test({ name: "unique", test: (value, context) => { - if (!value) return true; - const aliases = new Set(value); - aliases.add(context.parent.name); - return value.length + 1 === aliases.size; + const aliases = [context.parent.name, ...value]; + const dupes = aliases + .map((e, i, a) => { + if (a.indexOf(e) !== i) { + return String(i - 1); + } else { + return null; + } + }) + .filter((e) => e !== null) as string[]; + if (dupes.length === 0) return true; + return new yup.ValidationError(dupes.join(" "), value, "aliases"); }, - message: intl.formatMessage({ - id: "validation.aliases_must_be_unique", - }), }), ignore_auto_tag: yup.boolean().defined(), stash_ids: yup.mixed().defined(), @@ -187,6 +192,14 @@ export const StudioEditPanel: React.FC = ({ ); } + const aliasErrors = Array.isArray(formik.errors.aliases) + ? formik.errors.aliases[0] + : formik.errors.aliases; + const aliasErrorMsg = aliasErrors + ? intl.formatMessage({ id: "validation.aliases_must_be_unique" }) + : undefined; + const aliasErrorIdx = aliasErrors?.split(" ").map((e) => parseInt(e)); + return ( <> = ({ formik.setFieldValue("aliases", value)} - errors={ - Array.isArray(formik.errors.aliases) - ? formik.errors.aliases[0] - : formik.errors.aliases - } + errors={aliasErrorMsg} + errorIdx={aliasErrorIdx} /> diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx index 625da6106..2c2eca35d 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx @@ -48,14 +48,19 @@ export const TagEditPanel: React.FC = ({ .test({ name: "unique", test: (value, context) => { - if (!value) return true; - const aliases = new Set(value); - aliases.add(context.parent.name); - return value.length + 1 === aliases.size; + const aliases = [context.parent.name, ...value]; + const dupes = aliases + .map((e, i, a) => { + if (a.indexOf(e) !== i) { + return String(i - 1); + } else { + return null; + } + }) + .filter((e) => e !== null) as string[]; + if (dupes.length === 0) return true; + return new yup.ValidationError(dupes.join(" "), value, "aliases"); }, - message: intl.formatMessage({ - id: "validation.aliases_must_be_unique", - }), }), description: yup.string().ensure(), parent_ids: yup.array(yup.string().required()).defined(), @@ -114,6 +119,14 @@ export const TagEditPanel: React.FC = ({ ImageUtils.onImageChange(event, onImageLoad); } + const aliasErrors = Array.isArray(formik.errors.aliases) + ? formik.errors.aliases[0] + : formik.errors.aliases; + const aliasErrorMsg = aliasErrors + ? intl.formatMessage({ id: "validation.aliases_must_be_unique" }) + : undefined; + const aliasErrorIdx = aliasErrors?.split(" ").map((e) => parseInt(e)); + const isEditing = true; // TODO: CSS class @@ -165,11 +178,8 @@ export const TagEditPanel: React.FC = ({ formik.setFieldValue("aliases", value)} - errors={ - Array.isArray(formik.errors.aliases) - ? formik.errors.aliases[0] - : formik.errors.aliases - } + errors={aliasErrorMsg} + errorIdx={aliasErrorIdx} /> From 64b7934af26f430bad5dd8250e534ef4cfa9bbfd Mon Sep 17 00:00:00 2001 From: jpnsfw <128326333+jpnsfw@users.noreply.github.com> Date: Mon, 24 Apr 2023 17:01:41 -0400 Subject: [PATCH 17/81] Add O-Counter for Performers and Sort/Filter Performers by O-Counter (#3588) * initial commit of sort performer by o-count * work on o_counter filter * filter working * sorting, filtering using combined scene+image count * linting * fix performer list view --------- Co-authored-by: jpnsfw --- graphql/documents/data/performer.graphql | 1 + graphql/schema/types/filters.graphql | 2 + graphql/schema/types/performer.graphql | 1 + internal/api/resolver_model_performer.go | 18 ++++++++ pkg/models/image.go | 1 + pkg/models/mocks/ImageReaderWriter.go | 21 +++++++++ pkg/models/mocks/SceneReaderWriter.go | 21 +++++++++ pkg/models/performer.go | 2 + pkg/models/scene.go | 1 + .../stashbox/graphql/generated_models.go | 5 ++- pkg/sqlite/filter.go | 22 ++++++++++ pkg/sqlite/image.go | 13 ++++++ pkg/sqlite/performer.go | 20 +++++++++ pkg/sqlite/scene.go | 13 ++++++ pkg/sqlite/sql.go | 43 +++++++++++++++++++ .../components/Performers/PerformerCard.tsx | 18 ++++++++ .../Performers/PerformerListTable.tsx | 4 ++ ui/v2.5/src/models/list-filter/performers.ts | 5 +++ 18 files changed, 210 insertions(+), 1 deletion(-) diff --git a/graphql/documents/data/performer.graphql b/graphql/documents/data/performer.graphql index 338ae0e10..84dbbea2c 100644 --- a/graphql/documents/data/performer.graphql +++ b/graphql/documents/data/performer.graphql @@ -25,6 +25,7 @@ fragment PerformerData on Performer { image_count gallery_count movie_count + o_counter tags { ...SlimTagData diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 75d1e6f2d..b1b0e503b 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -98,6 +98,8 @@ input PerformerFilterType { image_count: IntCriterionInput """Filter by gallery count""" gallery_count: IntCriterionInput + """Filter by o count""" + o_counter: IntCriterionInput """Filter by StashID""" stash_id: StringCriterionInput @deprecated(reason: "Use stash_id_endpoint instead") """Filter by StashID""" diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index 235960bfc..168ff9e8c 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -37,6 +37,7 @@ type Performer { scene_count: Int # Resolver image_count: Int # Resolver gallery_count: Int # Resolver + o_counter: Int # Resolver scenes: [Scene!]! stash_ids: [StashID!]! # rating expressed as 1-5 diff --git a/internal/api/resolver_model_performer.go b/internal/api/resolver_model_performer.go index 0fb8f6518..8abf28297 100644 --- a/internal/api/resolver_model_performer.go +++ b/internal/api/resolver_model_performer.go @@ -127,6 +127,24 @@ func (r *performerResolver) GalleryCount(ctx context.Context, obj *models.Perfor return &res, nil } +func (r *performerResolver) OCounter(ctx context.Context, obj *models.Performer) (ret *int, err error) { + var res_scene int + var res_image int + var res int + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + res_scene, err = r.repository.Scene.OCountByPerformerID(ctx, obj.ID) + if err != nil { + return err + } + res_image, err = r.repository.Image.OCountByPerformerID(ctx, obj.ID) + return err + }); err != nil { + return nil, err + } + res = res_scene + res_image + return &res, nil +} + func (r *performerResolver) Scenes(ctx context.Context, obj *models.Performer) (ret []*models.Scene, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Scene.FindByPerformerID(ctx, obj.ID) diff --git a/pkg/models/image.go b/pkg/models/image.go index 774e0536a..288f69976 100644 --- a/pkg/models/image.go +++ b/pkg/models/image.go @@ -108,6 +108,7 @@ type ImageReader interface { FindByChecksum(ctx context.Context, checksum string) ([]*Image, error) FindByGalleryID(ctx context.Context, galleryID int) ([]*Image, error) CountByGalleryID(ctx context.Context, galleryID int) (int, error) + OCountByPerformerID(ctx context.Context, performerID int) (int, error) Count(ctx context.Context) (int, error) Size(ctx context.Context) (float64, error) All(ctx context.Context) ([]*Image, error) diff --git a/pkg/models/mocks/ImageReaderWriter.go b/pkg/models/mocks/ImageReaderWriter.go index 41468ceb2..67a9d318e 100644 --- a/pkg/models/mocks/ImageReaderWriter.go +++ b/pkg/models/mocks/ImageReaderWriter.go @@ -79,6 +79,27 @@ func (_m *ImageReaderWriter) CountByGalleryID(ctx context.Context, galleryID int return r0, r1 } +// OCountByPerformerID provides a mock function with given fields: ctx, performerID +func (_m *ImageReaderWriter) OCountByPerformerID(ctx context.Context, performerID int) (int, error) { + ret := _m.Called(ctx, performerID) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, performerID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, performerID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Create provides a mock function with given fields: ctx, newImage func (_m *ImageReaderWriter) Create(ctx context.Context, newImage *models.ImageCreateInput) error { ret := _m.Called(ctx, newImage) diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index 5f7191827..f67a909b4 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -102,6 +102,27 @@ func (_m *SceneReaderWriter) CountByPerformerID(ctx context.Context, performerID return r0, r1 } +// OCountByPerformerID provides a mock function with given fields: ctx, performerID +func (_m *SceneReaderWriter) OCountByPerformerID(ctx context.Context, performerID int) (int, error) { + ret := _m.Called(ctx, performerID) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, performerID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, performerID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // CountByStudioID provides a mock function with given fields: ctx, studioID func (_m *SceneReaderWriter) CountByStudioID(ctx context.Context, studioID int) (int, error) { ret := _m.Called(ctx, studioID) diff --git a/pkg/models/performer.go b/pkg/models/performer.go index 30ace6da8..e56f20ce0 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -110,6 +110,8 @@ type PerformerFilterType struct { ImageCount *IntCriterionInput `json:"image_count"` // Filter by gallery count GalleryCount *IntCriterionInput `json:"gallery_count"` + // Filter by O count + OCounter *IntCriterionInput `json:"o_counter"` // Filter by StashID StashID *StringCriterionInput `json:"stash_id"` // Filter by StashID Endpoint diff --git a/pkg/models/scene.go b/pkg/models/scene.go index cc503fa92..ac9cd93c8 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -163,6 +163,7 @@ type SceneReader interface { VideoFileLoader CountByPerformerID(ctx context.Context, performerID int) (int, error) + OCountByPerformerID(ctx context.Context, performerID int) (int, error) // FindByStudioID(studioID int) ([]*Scene, error) FindByMovieID(ctx context.Context, movieID int) ([]*Scene, error) CountByMovieID(ctx context.Context, movieID int) (int, error) diff --git a/pkg/scraper/stashbox/graphql/generated_models.go b/pkg/scraper/stashbox/graphql/generated_models.go index 6b3e09565..0dfb4bf57 100644 --- a/pkg/scraper/stashbox/graphql/generated_models.go +++ b/pkg/scraper/stashbox/graphql/generated_models.go @@ -332,6 +332,7 @@ type Performer struct { Deleted bool `json:"deleted"` Edits []*Edit `json:"edits,omitempty"` SceneCount int `json:"scene_count"` + OCounter int `json:"o_counter"` MergedIds []string `json:"merged_ids,omitempty"` Studios []*PerformerStudio `json:"studios,omitempty"` IsFavorite bool `json:"is_favorite"` @@ -1771,6 +1772,7 @@ const ( PerformerSortEnumName PerformerSortEnum = "NAME" PerformerSortEnumBirthdate PerformerSortEnum = "BIRTHDATE" PerformerSortEnumSceneCount PerformerSortEnum = "SCENE_COUNT" + PerformerSortEnumOCounter PerformerSortEnum = "O_COUNTER" PerformerSortEnumCareerStartYear PerformerSortEnum = "CAREER_START_YEAR" PerformerSortEnumDebut PerformerSortEnum = "DEBUT" PerformerSortEnumCreatedAt PerformerSortEnum = "CREATED_AT" @@ -1781,6 +1783,7 @@ var AllPerformerSortEnum = []PerformerSortEnum{ PerformerSortEnumName, PerformerSortEnumBirthdate, PerformerSortEnumSceneCount, + PerformerSortEnumOCounter, PerformerSortEnumCareerStartYear, PerformerSortEnumDebut, PerformerSortEnumCreatedAt, @@ -1789,7 +1792,7 @@ var AllPerformerSortEnum = []PerformerSortEnum{ func (e PerformerSortEnum) IsValid() bool { switch e { - case PerformerSortEnumName, PerformerSortEnumBirthdate, PerformerSortEnumSceneCount, PerformerSortEnumCareerStartYear, PerformerSortEnumDebut, PerformerSortEnumCreatedAt, PerformerSortEnumUpdatedAt: + case PerformerSortEnumName, PerformerSortEnumBirthdate, PerformerSortEnumSceneCount, PerformerSortEnumOCounter, PerformerSortEnumCareerStartYear, PerformerSortEnumDebut, PerformerSortEnumCreatedAt, PerformerSortEnumUpdatedAt: return true } return false diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index d75012b4e..057fec179 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -722,6 +722,28 @@ func (m *countCriterionHandlerBuilder) handler(criterion *models.IntCriterionInp } } +type joinedMultiSumCriterionHandlerBuilder struct { + primaryTable string + foreignTable1 string + joinTable1 string + foreignTable2 string + joinTable2 string + primaryFK string + foreignFK1 string + foreignFK2 string + sum string +} + +func (m *joinedMultiSumCriterionHandlerBuilder) handler(criterion *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if criterion != nil { + clause, args := getJoinedMultiSumCriterionClause(m.primaryTable, m.foreignTable1, m.joinTable1, m.foreignTable2, m.joinTable2, m.primaryFK, m.foreignFK1, m.foreignFK2, m.sum, *criterion) + + f.addWhere(clause, args...) + } + } +} + // handler for StringCriterion for string list fields type stringListCriterionHandlerBuilder struct { // table joining primary and foreign objects diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 2648c523d..58ec592a9 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -513,6 +513,19 @@ func (qb *ImageStore) CountByGalleryID(ctx context.Context, galleryID int) (int, return count(ctx, q) } +func (qb *ImageStore) OCountByPerformerID(ctx context.Context, performerID int) (int, error) { + table := qb.table() + joinTable := performersImagesJoinTable + q := dialect.Select(goqu.COALESCE(goqu.SUM("o_counter"), 0)).From(table).InnerJoin(joinTable, goqu.On(table.Col(idColumn).Eq(joinTable.Col(imageIDColumn)))).Where(joinTable.Col(performerIDColumn).Eq(performerID)) + + var ret int + if err := querySimple(ctx, q, &ret); err != nil { + return 0, err + } + + return ret, nil +} + func (qb *ImageStore) FindByFolderID(ctx context.Context, folderID file.FolderID) ([]*models.Image, error) { table := qb.table() fileTable := goqu.T(fileTable) diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 60b936716..27eae9cdd 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -629,6 +629,7 @@ func (qb *PerformerStore) makeFilter(ctx context.Context, filter *models.Perform query.handleCriterion(ctx, performerSceneCountCriterionHandler(qb, filter.SceneCount)) query.handleCriterion(ctx, performerImageCountCriterionHandler(qb, filter.ImageCount)) query.handleCriterion(ctx, performerGalleryCountCriterionHandler(qb, filter.GalleryCount)) + query.handleCriterion(ctx, performerOCounterCriterionHandler(qb, filter.OCounter)) query.handleCriterion(ctx, dateCriterionHandler(filter.Birthdate, tableName+".birthdate")) query.handleCriterion(ctx, dateCriterionHandler(filter.DeathDate, tableName+".death_date")) query.handleCriterion(ctx, timestampCriterionHandler(filter.CreatedAt, tableName+".created_at")) @@ -805,6 +806,22 @@ func performerGalleryCountCriterionHandler(qb *PerformerStore, count *models.Int return h.handler(count) } +func performerOCounterCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { + h := joinedMultiSumCriterionHandlerBuilder{ + primaryTable: performerTable, + foreignTable1: sceneTable, + joinTable1: performersScenesTable, + foreignTable2: imageTable, + joinTable2: performersImagesTable, + primaryFK: performerIDColumn, + foreignFK1: sceneIDColumn, + foreignFK2: imageIDColumn, + sum: "o_counter", + } + + return h.handler(count) +} + func performerStudiosCriterionHandler(qb *PerformerStore, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if studios != nil { @@ -906,6 +923,9 @@ func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) st default: sortQuery += getSort(sort, direction, "performers") } + if sort == "o_counter" { + return getMultiSumSort("o_counter", performerTable, sceneTable, performersScenesTable, imageTable, performersImagesTable, performerIDColumn, sceneIDColumn, imageIDColumn, direction) + } // Whatever the sorting, always use name/id as a final sort sortQuery += ", COALESCE(performers.name, performers.id) COLLATE NATURAL_CI ASC" diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index e478e4477..a049557da 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -680,6 +680,19 @@ func (qb *SceneStore) CountByPerformerID(ctx context.Context, performerID int) ( return count(ctx, q) } +func (qb *SceneStore) OCountByPerformerID(ctx context.Context, performerID int) (int, error) { + table := qb.table() + joinTable := scenesPerformersJoinTable + + q := dialect.Select(goqu.COALESCE(goqu.SUM("o_counter"), 0)).From(table).InnerJoin(joinTable, goqu.On(table.Col(idColumn).Eq(joinTable.Col(sceneIDColumn)))).Where(joinTable.Col(performerIDColumn).Eq(performerID)) + var ret int + if err := querySimple(ctx, q, &ret); err != nil { + return 0, err + } + + return ret, nil +} + func (qb *SceneStore) FindByMovieID(ctx context.Context, movieID int) ([]*models.Scene, error) { sq := dialect.From(scenesMoviesJoinTable).Select(scenesMoviesJoinTable.Col(sceneIDColumn)).Where( scenesMoviesJoinTable.Col(movieIDColumn).Eq(movieID), diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index 334c3eca1..a410bac28 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -103,6 +103,27 @@ func getCountSort(primaryTable, joinTable, primaryFK, direction string) string { return fmt.Sprintf(" ORDER BY (SELECT COUNT(*) FROM %s WHERE %s = %s.id) %s", joinTable, primaryFK, primaryTable, getSortDirection(direction)) } +func getMultiSumSort(sum string, primaryTable, foreignTable1, joinTable1, foreignTable2, joinTable2, primaryFK, foreignFK1, foreignFK2, direction string) string { + return fmt.Sprintf(" ORDER BY (SELECT SUM(%s) "+ + "FROM ("+ + "SELECT SUM(%s) as %s from %s s "+ + "LEFT JOIN %s ON %s.id = s.%s "+ + "WHERE s.%s = %s.id "+ + "UNION ALL "+ + "SELECT SUM(%s) as %s from %s s "+ + "LEFT JOIN %s ON %s.id = s.%s "+ + "WHERE s.%s = %s.id "+ + ")) %s", + sum, + sum, sum, joinTable1, + foreignTable1, foreignTable1, foreignFK1, + primaryFK, primaryTable, + sum, sum, joinTable2, + foreignTable2, foreignTable2, foreignFK2, + primaryFK, primaryTable, + getSortDirection(direction)) +} + func getStringSearchClause(columns []string, q string, not bool) sqlClause { var likeClauses []string var args []interface{} @@ -287,6 +308,28 @@ func getCountCriterionClause(primaryTable, joinTable, primaryFK string, criterio return getIntCriterionWhereClause(lhs, criterion) } +func getJoinedMultiSumCriterionClause(primaryTable, foreignTable1, joinTable1, foreignTable2, joinTable2, primaryFK string, foreignFK1 string, foreignFK2 string, sum string, criterion models.IntCriterionInput) (string, []interface{}) { + lhs := fmt.Sprintf("(SELECT SUM(%s) "+ + "FROM ("+ + "SELECT SUM(%s) as %s from %s s "+ + "LEFT JOIN %s ON %s.id = s.%s "+ + "WHERE s.%s = %s.id "+ + "UNION ALL "+ + "SELECT SUM(%s) as %s from %s s "+ + "LEFT JOIN %s ON %s.id = s.%s "+ + "WHERE s.%s = %s.id "+ + "))", + sum, + sum, sum, joinTable1, + foreignTable1, foreignTable1, foreignFK1, + primaryFK, primaryTable, + sum, sum, joinTable2, + foreignTable2, foreignTable2, foreignFK2, + primaryFK, primaryTable, + ) + return getIntCriterionWhereClause(lhs, criterion) +} + func coalesce(column string) string { return fmt.Sprintf("COALESCE(%s, '')", column) } diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index f11f96dbe..0b2cbd61a 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -6,6 +6,7 @@ import NavUtils from "src/utils/navigation"; import TextUtils from "src/utils/text"; import { GridCard } from "../Shared/GridCard"; import { CountryFlag } from "../Shared/CountryFlag"; +import { SweatDrops } from "../Shared/SweatDrops"; import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; import { TagLink } from "../Shared/TagLink"; @@ -137,6 +138,21 @@ export const PerformerCard: React.FC = ({ ); } + function maybeRenderOCounter() { + if (!performer.o_counter) return; + + return ( +
+ +
+ ); + } + function maybeRenderTagPopoverButton() { if (performer.tags.length <= 0) return; @@ -173,6 +189,7 @@ export const PerformerCard: React.FC = ({ performer.image_count || performer.gallery_count || performer.tags.length > 0 || + performer.o_counter || performer.movie_count ) { return ( @@ -184,6 +201,7 @@ export const PerformerCard: React.FC = ({ {maybeRenderImagesPopoverButton()} {maybeRenderGalleriesPopoverButton()} {maybeRenderTagPopoverButton()} + {maybeRenderOCounter()} ); diff --git a/ui/v2.5/src/components/Performers/PerformerListTable.tsx b/ui/v2.5/src/components/Performers/PerformerListTable.tsx index 1b2f858fd..cc0bdc2a5 100644 --- a/ui/v2.5/src/components/Performers/PerformerListTable.tsx +++ b/ui/v2.5/src/components/Performers/PerformerListTable.tsx @@ -97,6 +97,9 @@ export const PerformerListTable: React.FC = (
{performer.gallery_count}
+ +
{performer.o_counter}
+ {performer.birthdate} {!!performer.height_cm && formatHeight(performer.height_cm)} @@ -114,6 +117,7 @@ export const PerformerListTable: React.FC = ( {intl.formatMessage({ id: "scene_count" })} {intl.formatMessage({ id: "image_count" })} {intl.formatMessage({ id: "gallery_count" })} + {intl.formatMessage({ id: "o_counter" })} {intl.formatMessage({ id: "birthdate" })} {intl.formatMessage({ id: "height" })} diff --git a/ui/v2.5/src/models/list-filter/performers.ts b/ui/v2.5/src/models/list-filter/performers.ts index 4028209f9..5a628ca2a 100644 --- a/ui/v2.5/src/models/list-filter/performers.ts +++ b/ui/v2.5/src/models/list-filter/performers.ts @@ -40,6 +40,10 @@ const sortByOptions = [ messageID: "gallery_count", value: "galleries_count", }, + { + messageID: "o_counter", + value: "o_counter", + }, ]); const displayModeOptions = [ @@ -84,6 +88,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("scene_count"), createMandatoryNumberCriterionOption("image_count"), createMandatoryNumberCriterionOption("gallery_count"), + createMandatoryNumberCriterionOption("o_counter"), createBooleanCriterionOption("ignore_auto_tag"), new NumberCriterionOption("height", "height_cm", "height_cm"), ...numberCriteria.map((c) => createNumberCriterionOption(c)), From 3bc5caa6dedcc72c37cb8cefbbc249e9b48d2911 Mon Sep 17 00:00:00 2001 From: dogwithakeyboard <128322708+dogwithakeyboard@users.noreply.github.com> Date: Mon, 24 Apr 2023 22:38:49 +0100 Subject: [PATCH 18/81] Add performer pairings/appears with tab to performers (#3563) * database query * Appears With panel * Typos * Validation fix * naming consistency, remove extraneous component. --------- Co-authored-by: kermieisinthehouse --- graphql/documents/data/performer.graphql | 1 + graphql/schema/types/filters.graphql | 2 + graphql/schema/types/performer.graphql | 1 + internal/api/resolver_model_performer.go | 13 +++++ pkg/models/performer.go | 2 + pkg/performer/query.go | 11 ++++ pkg/sqlite/performer.go | 56 +++++++++++++++++++ .../components/Performers/PerformerCard.tsx | 29 +++++++--- .../Performers/PerformerDetails/Performer.tsx | 21 ++++++- .../performerAppearsWithPanel.tsx | 33 +++++++++++ ui/v2.5/src/locales/en-GB.json | 1 + ui/v2.5/src/utils/navigation.ts | 25 +++++++++ 12 files changed, 187 insertions(+), 8 deletions(-) create mode 100644 ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx diff --git a/graphql/documents/data/performer.graphql b/graphql/documents/data/performer.graphql index 84dbbea2c..ed469f01e 100644 --- a/graphql/documents/data/performer.graphql +++ b/graphql/documents/data/performer.graphql @@ -25,6 +25,7 @@ fragment PerformerData on Performer { image_count gallery_count movie_count + performer_count o_counter tags { diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index b1b0e503b..a635eaf51 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -118,6 +118,8 @@ input PerformerFilterType { death_year: IntCriterionInput """Filter by studios where performer appears in scene/image/gallery""" studios: HierarchicalMultiCriterionInput + """Filter by performers where performer appears with another performer in scene/image/gallery""" + performers: MultiCriterionInput """Filter by autotag ignore value""" ignore_auto_tag: Boolean """Filter by birthdate""" diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index 168ff9e8c..401f3b7c6 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -37,6 +37,7 @@ type Performer { scene_count: Int # Resolver image_count: Int # Resolver gallery_count: Int # Resolver + performer_count: Int # Resolver o_counter: Int # Resolver scenes: [Scene!]! stash_ids: [StashID!]! diff --git a/internal/api/resolver_model_performer.go b/internal/api/resolver_model_performer.go index 8abf28297..afdfa6f14 100644 --- a/internal/api/resolver_model_performer.go +++ b/internal/api/resolver_model_performer.go @@ -10,6 +10,7 @@ import ( "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/performer" ) // Checksum is deprecated @@ -208,3 +209,15 @@ func (r *performerResolver) MovieCount(ctx context.Context, obj *models.Performe return &res, nil } + +func (r *performerResolver) PerformerCount(ctx context.Context, obj *models.Performer) (ret *int, err error) { + var res int + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + res, err = performer.CountByAppearsWith(ctx, r.repository.Performer, obj.ID) + return err + }); err != nil { + return nil, err + } + + return &res, nil +} diff --git a/pkg/models/performer.go b/pkg/models/performer.go index e56f20ce0..aa6ea3af6 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -130,6 +130,8 @@ type PerformerFilterType struct { DeathYear *IntCriterionInput `json:"death_year"` // Filter by studios where performer appears in scene/image/gallery Studios *HierarchicalMultiCriterionInput `json:"studios"` + // Filter by performers where performer appears with another performer in scene/image/gallery + Performers *MultiCriterionInput `json:"performers"` // Filter by autotag ignore value IgnoreAutoTag *bool `json:"ignore_auto_tag"` // Filter by birthdate diff --git a/pkg/performer/query.go b/pkg/performer/query.go index d790c6d52..a3045ef67 100644 --- a/pkg/performer/query.go +++ b/pkg/performer/query.go @@ -25,3 +25,14 @@ func CountByStudioID(ctx context.Context, r CountQueryer, id int) (int, error) { return r.QueryCount(ctx, filter, nil) } + +func CountByAppearsWith(ctx context.Context, r CountQueryer, id int) (int, error) { + filter := &models.PerformerFilterType{ + Performers: &models.MultiCriterionInput{ + Value: []string{strconv.Itoa(id)}, + Modifier: models.CriterionModifierIncludes, + }, + } + + return r.QueryCount(ctx, filter, nil) +} diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 27eae9cdd..a197b2ce5 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -625,6 +625,8 @@ func (qb *PerformerStore) makeFilter(ctx context.Context, filter *models.Perform query.handleCriterion(ctx, performerStudiosCriterionHandler(qb, filter.Studios)) + query.handleCriterion(ctx, performerAppearsWithCriterionHandler(qb, filter.Performers)) + query.handleCriterion(ctx, performerTagCountCriterionHandler(qb, filter.TagCount)) query.handleCriterion(ctx, performerSceneCountCriterionHandler(qb, filter.SceneCount)) query.handleCriterion(ctx, performerImageCountCriterionHandler(qb, filter.ImageCount)) @@ -899,6 +901,60 @@ func performerStudiosCriterionHandler(qb *PerformerStore, studios *models.Hierar } } +func performerAppearsWithCriterionHandler(qb *PerformerStore, performers *models.MultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performers != nil { + formatMaps := []utils.StrFormatMap{ + { + "primaryTable": performersScenesTable, + "joinTable": performersScenesTable, + "primaryFK": sceneIDColumn, + }, + { + "primaryTable": performersImagesTable, + "joinTable": performersImagesTable, + "primaryFK": imageIDColumn, + }, + { + "primaryTable": performersGalleriesTable, + "joinTable": performersGalleriesTable, + "primaryFK": galleryIDColumn, + }, + } + + if len(performers.Value) == '0' { + return + } + + const derivedPerformerPerformersTable = "performer_performers" + + valuesClause := strings.Join(performers.Value, "),(") + + f.addWith("performer(id) AS (VALUES(" + valuesClause + "))") + + templStr := `SELECT {primaryTable}2.performer_id FROM {primaryTable} + INNER JOIN {primaryTable} AS {primaryTable}2 ON {primaryTable}.{primaryFK} = {primaryTable}2.{primaryFK} + INNER JOIN performer ON {primaryTable}.performer_id = performer.id + WHERE {primaryTable}2.performer_id != performer.id` + + if performers.Modifier == models.CriterionModifierIncludesAll && len(performers.Value) > 1 { + templStr += ` + GROUP BY {primaryTable}2.performer_id + HAVING(count(distinct {primaryTable}.performer_id) IS ` + strconv.Itoa(len(performers.Value)) + `)` + } + + var unions []string + for _, c := range formatMaps { + unions = append(unions, utils.StrFormat(templStr, c)) + } + + f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerPerformersTable, strings.Join(unions, " UNION "))) + + f.addInnerJoin(derivedPerformerPerformersTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerPerformersTable)) + } + } +} + func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) string { var sort string var direction string diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index 0b2cbd61a..c34b184a5 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -21,12 +21,14 @@ import { faHeart, faTag } from "@fortawesome/free-solid-svg-icons"; import { RatingBanner } from "../Shared/RatingBanner"; import cx from "classnames"; import { usePerformerUpdate } from "src/core/StashService"; +import { ILabeledId } from "src/models/list-filter/types"; export interface IPerformerCardExtraCriteria { - scenes: Criterion[]; - images: Criterion[]; - galleries: Criterion[]; - movies: Criterion[]; + scenes?: Criterion[]; + images?: Criterion[]; + galleries?: Criterion[]; + movies?: Criterion[]; + performer?: ILabeledId; } interface IPerformerCardProps { @@ -104,7 +106,11 @@ export const PerformerCard: React.FC = ({ className="scene-count" type="scene" count={performer.scene_count} - url={NavUtils.makePerformerScenesUrl(performer, extraCriteria?.scenes)} + url={NavUtils.makePerformerScenesUrl( + performer, + extraCriteria?.performer, + extraCriteria?.scenes + )} /> ); } @@ -117,7 +123,11 @@ export const PerformerCard: React.FC = ({ className="image-count" type="image" count={performer.image_count} - url={NavUtils.makePerformerImagesUrl(performer, extraCriteria?.images)} + url={NavUtils.makePerformerImagesUrl( + performer, + extraCriteria?.performer, + extraCriteria?.images + )} /> ); } @@ -132,6 +142,7 @@ export const PerformerCard: React.FC = ({ count={performer.gallery_count} url={NavUtils.makePerformerGalleriesUrl( performer, + extraCriteria?.performer, extraCriteria?.galleries )} /> @@ -178,7 +189,11 @@ export const PerformerCard: React.FC = ({ className="movie-count" type="movie" count={performer.movie_count} - url={NavUtils.makePerformerMoviesUrl(performer, extraCriteria?.movies)} + url={NavUtils.makePerformerMoviesUrl( + performer, + extraCriteria?.performer, + extraCriteria?.movies + )} /> ); } diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index fea046045..ddd74cff4 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -28,6 +28,7 @@ import { PerformerScenesPanel } from "./PerformerScenesPanel"; import { PerformerGalleriesPanel } from "./PerformerGalleriesPanel"; import { PerformerMoviesPanel } from "./PerformerMoviesPanel"; import { PerformerImagesPanel } from "./PerformerImagesPanel"; +import { PerformerAppearsWithPanel } from "./performerAppearsWithPanel"; import { PerformerEditPanel } from "./PerformerEditPanel"; import { PerformerSubmitButton } from "./PerformerSubmitButton"; import GenderIcon from "../GenderIcon"; @@ -93,7 +94,8 @@ const PerformerPage: React.FC = ({ performer }) => { tab === "scenes" || tab === "galleries" || tab === "images" || - tab === "movies" + tab === "movies" || + tab == "appearswith" ? tab : "details"; const setActiveTabKey = (newTab: string | null) => { @@ -263,6 +265,23 @@ const PerformerPage: React.FC = ({ performer }) => { performer={performer} /> + + {intl.formatMessage({ id: "appears_with" })} + + + } + > + + ); diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx new file mode 100644 index 000000000..a05ec5e9f --- /dev/null +++ b/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import * as GQL from "src/core/generated-graphql"; +import { PerformerList } from "src/components/Performers/PerformerList"; +import { usePerformerFilterHook } from "src/core/performers"; + +interface IPerformerDetailsProps { + active: boolean; + performer: GQL.PerformerDataFragment; +} + +export const PerformerAppearsWithPanel: React.FC = ({ + active, + performer, +}) => { + const performerValue = { + id: performer.id, + label: performer.name ?? `Performer ${performer.id}`, + }; + + const extraCriteria = { + performer: performerValue, + }; + + const filterHook = usePerformerFilterHook(performer); + + return ( + + ); +}; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index cfb528d86..c232e0964 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -127,6 +127,7 @@ "aliases": "Aliases", "all": "all", "also_known_as": "Also known as", + "appears_with": "Appears With", "ascending": "Ascending", "average_resolution": "Average Resolution", "between_and": "and", diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index d33a00a74..a1ba4cf33 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -19,6 +19,7 @@ import { } from "src/models/list-filter/criteria/criterion"; import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries"; import { PhashCriterion } from "src/models/list-filter/criteria/phash"; +import { ILabeledId } from "src/models/list-filter/types"; function addExtraCriteria( dest: Criterion[], @@ -31,6 +32,7 @@ function addExtraCriteria( const makePerformerScenesUrl = ( performer: Partial, + extraPerformer?: ILabeledId, extraCriteria?: Criterion[] ) => { if (!performer.id) return "#"; @@ -39,6 +41,11 @@ const makePerformerScenesUrl = ( criterion.value = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; + + if (extraPerformer) { + criterion.value.push(extraPerformer); + } + filter.criteria.push(criterion); addExtraCriteria(filter.criteria, extraCriteria); return `/scenes?${filter.makeQueryParameters()}`; @@ -46,6 +53,7 @@ const makePerformerScenesUrl = ( const makePerformerImagesUrl = ( performer: Partial, + extraPerformer?: ILabeledId, extraCriteria?: Criterion[] ) => { if (!performer.id) return "#"; @@ -54,6 +62,11 @@ const makePerformerImagesUrl = ( criterion.value = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; + + if (extraPerformer) { + criterion.value.push(extraPerformer); + } + filter.criteria.push(criterion); addExtraCriteria(filter.criteria, extraCriteria); return `/images?${filter.makeQueryParameters()}`; @@ -61,6 +74,7 @@ const makePerformerImagesUrl = ( const makePerformerGalleriesUrl = ( performer: Partial, + extraPerformer?: ILabeledId, extraCriteria?: Criterion[] ) => { if (!performer.id) return "#"; @@ -69,6 +83,11 @@ const makePerformerGalleriesUrl = ( criterion.value = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; + + if (extraPerformer) { + criterion.value.push(extraPerformer); + } + filter.criteria.push(criterion); addExtraCriteria(filter.criteria, extraCriteria); return `/galleries?${filter.makeQueryParameters()}`; @@ -76,6 +95,7 @@ const makePerformerGalleriesUrl = ( const makePerformerMoviesUrl = ( performer: Partial, + extraPerformer?: ILabeledId, extraCriteria?: Criterion[] ) => { if (!performer.id) return "#"; @@ -84,6 +104,11 @@ const makePerformerMoviesUrl = ( criterion.value = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; + + if (extraPerformer) { + criterion.value.push(extraPerformer); + } + filter.criteria.push(criterion); addExtraCriteria(filter.criteria, extraCriteria); return `/movies?${filter.makeQueryParameters()}`; From 8d3f632d4c0266fa9d887ffc41177aa26745da4f Mon Sep 17 00:00:00 2001 From: yoshnopa <40072150+yoshnopa@users.noreply.github.com> Date: Tue, 25 Apr 2023 21:40:28 +0200 Subject: [PATCH 19/81] Pinned Filters (#3675) * Pinned Filters // Add the ability to pin filters in the new filter dialog * Pinned Filters // Prevent overlap with x * Pinned Filters // Pills in the button show up correctly now... * Pinned Filters // Maximum height for mobile view * Pinned Filters // Save in config.yml * Style changes and minor fixes * Pinned Filters // Increase divider space --------- Co-authored-by: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> --- .../src/components/List/EditFilterDialog.tsx | 174 +++++++++++++----- ui/v2.5/src/components/List/styles.scss | 26 ++- ui/v2.5/src/core/config.ts | 5 + 3 files changed, 160 insertions(+), 45 deletions(-) diff --git a/ui/v2.5/src/components/List/EditFilterDialog.tsx b/ui/v2.5/src/components/List/EditFilterDialog.tsx index 7097bc1d8..4e52f1259 100644 --- a/ui/v2.5/src/components/List/EditFilterDialog.tsx +++ b/ui/v2.5/src/components/List/EditFilterDialog.tsx @@ -26,18 +26,24 @@ import { faChevronDown, faChevronRight, faTimes, + faThumbtack, } from "@fortawesome/free-solid-svg-icons"; import { useCompare, usePrevious } from "src/hooks/state"; import { CriterionType } from "src/models/list-filter/types"; +import { useToast } from "src/hooks/Toast"; +import { useConfigureUI } from "src/core/StashService"; +import { IUIConfig } from "src/core/config"; interface ICriterionList { criteria: string[]; currentCriterion?: Criterion; setCriterion: (c: Criterion) => void; criterionOptions: CriterionOption[]; + pinnedCriterionOptions: CriterionOption[]; selected?: CriterionOption; optionSelected: (o?: CriterionOption) => void; onRemoveCriterion: (c: string) => void; + onTogglePin: (c: CriterionOption) => void; } const CriterionOptionList: React.FC = ({ @@ -45,9 +51,11 @@ const CriterionOptionList: React.FC = ({ currentCriterion, setCriterion, criterionOptions, + pinnedCriterionOptions, selected, optionSelected, onRemoveCriterion, + onTogglePin, }) => { const prevCriterion = usePrevious(currentCriterion); @@ -61,15 +69,22 @@ const CriterionOptionList: React.FC = ({ criterionOptions.forEach((c) => { refs[c.type] = React.createRef(); }); + pinnedCriterionOptions.forEach((c) => { + refs[c.type] = React.createRef(); + }); return refs; - }, [criterionOptions]); + }, [criterionOptions, pinnedCriterionOptions]); function onSelect(k: string | null) { if (!k) { optionSelected(undefined); return; } - const option = criterionOptions.find((c) => c.type === k); + + let option = criterionOptions.find((c) => c.type === k); + if (!option) { + option = pinnedCriterionOptions.find((c) => c.type === k); + } if (option) { optionSelected(option); @@ -104,47 +119,71 @@ const CriterionOptionList: React.FC = ({ onRemoveCriterion(t); } + function togglePin(ev: React.MouseEvent, c: CriterionOption) { + // needed to prevent the nav item from being selected + ev.stopPropagation(); + ev.preventDefault(); + onTogglePin(c); + } + + function renderCard(c: CriterionOption, isPin: boolean) { + return ( + + + + + + + {criteria.some((cc) => c.type === cc) && ( + + )} + + + + {(type === c.type && currentCriterion) || + (prevType === c.type && prevCriterion) ? ( + + + + ) : ( + + )} + + + ); + } + return ( - {criterionOptions.map((c) => ( - - - - - - - {criteria.some((cc) => c.type === cc) && ( - - )} - - - {(type === c.type && currentCriterion) || - (prevType === c.type && prevCriterion) ? ( - - - - ) : ( - - )} - - - ))} + {pinnedCriterionOptions.length !== 0 && ( + <> + {pinnedCriterionOptions.map((c) => renderCard(c, true))} +
+ + )} + {criterionOptions.map((c) => renderCard(c, false))} ); }; @@ -162,9 +201,10 @@ export const EditFilterDialog: React.FC = ({ onApply, onCancel, }) => { + const Toast = useToast(); const intl = useIntl(); - const { configuration: config } = useContext(ConfigurationContext); + const { configuration } = useContext(ConfigurationContext); const [currentFilter, setCurrentFilter] = useState( cloneDeep(filter) @@ -209,11 +249,27 @@ export const EditFilterDialog: React.FC = ({ if (existing) { setCriterion(existing); } else { - const newCriterion = makeCriteria(config, option.type); + const newCriterion = makeCriteria(configuration, option.type); setCriterion(newCriterion); } }, - [criteria, config] + [criteria, configuration] + ); + + const ui = (configuration?.ui ?? {}) as IUIConfig; + const [saveUI] = useConfigureUI(); + + const pinnedFilters = useMemo( + () => ui.pinnedFilters?.[currentFilter.mode.toLowerCase()] ?? [], + [currentFilter.mode, ui.pinnedFilters] + ); + const pinnedElements = useMemo( + () => criterionOptions.filter((c) => pinnedFilters.includes(c.messageID)), + [pinnedFilters, criterionOptions] + ); + const unpinnedElements = useMemo( + () => criterionOptions.filter((c) => !pinnedFilters.includes(c.messageID)), + [pinnedFilters, criterionOptions] ); const editingCriterionChanged = useCompare(editingCriterion); @@ -232,6 +288,40 @@ export const EditFilterDialog: React.FC = ({ editingCriterionChanged, ]); + async function updatePinnedFilters(filters: string[]) { + const currentMode = currentFilter.mode.toLowerCase(); + try { + await saveUI({ + variables: { + input: { + ...configuration?.ui, + pinnedFilters: { + ...ui.pinnedFilters, + [currentMode]: filters, + }, + }, + }, + }); + } catch (e) { + Toast.error(e); + } + } + + async function onTogglePinFilter(f: CriterionOption) { + try { + const existing = pinnedFilters.find((name) => name === f.messageID); + if (existing) { + await updatePinnedFilters( + pinnedFilters.filter((name) => name !== f.messageID) + ); + } else { + await updatePinnedFilters([...pinnedFilters, f.messageID]); + } + } catch (err) { + Toast.error(err); + } + } + function replaceCriterion(c: Criterion) { const newFilter = cloneDeep(currentFilter); @@ -309,10 +399,12 @@ export const EditFilterDialog: React.FC = ({ criteria={criteriaList} currentCriterion={criterion} setCriterion={replaceCriterion} - criterionOptions={criterionOptions} + criterionOptions={unpinnedElements} + pinnedCriterionOptions={pinnedElements} optionSelected={optionSelected} selected={criterion?.criterionOption} onRemoveCriterion={(c) => removeCriterionString(c)} + onTogglePin={(c) => onTogglePinFilter(c)} /> {criteria.length > 0 && (
diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index c8fcb4bc4..e86199e6b 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -134,6 +134,10 @@ input[type="range"].zoom-slider { max-height: 550px; overflow-y: auto; + .pinned-criterion-divider { + padding-bottom: 2.5rem; + } + .card { border: 1px solid rgb(16 22 26 / 40%); box-shadow: none; @@ -147,15 +151,25 @@ input[type="range"].zoom-slider { .card-header { cursor: pointer; display: flex; - justify-content: space-between; + } + } + + .btn { + border: 0; + padding-bottom: 0; + padding-top: 0; + } + + .pin-criterion-button { + color: $text_color; + + &:hover svg { + transform: rotate(0); } } .remove-criterion-button { - border: 0; color: $danger; - padding-bottom: 0; - padding-top: 0; } } @@ -196,3 +210,7 @@ input[type="range"].zoom-slider { z-index: 2; } } + +.tilted { + transform: rotate(45deg); +} diff --git a/ui/v2.5/src/core/config.ts b/ui/v2.5/src/core/config.ts index 024914c94..8ca489bf3 100644 --- a/ui/v2.5/src/core/config.ts +++ b/ui/v2.5/src/core/config.ts @@ -25,6 +25,9 @@ export interface ICustomFilter extends ITypename { direction: SortDirectionEnum; } +// NOTE: This value cannot be more defined, because the generated enum it depends upon is UpperCase, which leads to errors on saving +export type PinnedFilters = Record>; + export type FrontPageContent = ISavedFilterRow | ICustomFilter; export const defaultMaxOptionsShown = 200; @@ -55,6 +58,8 @@ export interface IUIConfig { imageWallOptions?: ImageWallOptions; lastNoteSeen?: number; + + pinnedFilters?: PinnedFilters; } function recentlyReleased( From 85c893fd816394c96e22cb265b6a06c83e416fe7 Mon Sep 17 00:00:00 2001 From: charitybell <128868596+charitybell@users.noreply.github.com> Date: Tue, 25 Apr 2023 14:48:43 -0700 Subject: [PATCH 20/81] Add an explicit help flag that exits with 0 (#3654) `stash --help` exits with a non-zero exit code. Because `stash --help` is a legitimate invocation, it should return an exit code of zero. Adding an explicit help flag allows for exiting with a successful exit code. --- internal/manager/config/init.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/manager/config/init.go b/internal/manager/config/init.go index f512999a6..37a191436 100644 --- a/internal/manager/config/init.go +++ b/internal/manager/config/init.go @@ -24,6 +24,7 @@ type flagStruct struct { configFilePath string cpuProfilePath string nobrowser bool + helpFlag bool } func GetInstance() *Instance { @@ -40,6 +41,12 @@ func Initialize() (*Instance, error) { var err error initOnce.Do(func() { flags := initFlags() + + if flags.helpFlag { + pflag.Usage() + os.Exit(0) + } + overrides := makeOverrideConfig() _ = GetInstance() @@ -126,6 +133,7 @@ func initFlags() flagStruct { pflag.StringVarP(&flags.configFilePath, "config", "c", "", "config file to use") pflag.StringVar(&flags.cpuProfilePath, "cpuprofile", "", "write cpu profile to file") pflag.BoolVar(&flags.nobrowser, "nobrowser", false, "Don't open a browser window after launch") + pflag.BoolVarP(&flags.helpFlag, "help", "h", false, "show this help text and exit") pflag.Parse() From 23e52738c60154b51f619d54191a0001e2298c8a Mon Sep 17 00:00:00 2001 From: dogwithakeyboard <128322708+dogwithakeyboard@users.noreply.github.com> Date: Thu, 27 Apr 2023 00:33:44 +0100 Subject: [PATCH 21/81] remove styling from performer page (#3632) --- ui/v2.5/src/components/Performers/styles.scss | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index ecf2ffbe1..7bc932363 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -1,8 +1,4 @@ #performer-page { - flex-direction: row; - margin: 0 auto; - overflow: hidden; - .performer-image-container .performer { max-height: calc(100vh - 6rem); max-width: 100%; From 7939e7595bb18047bf109c6aed6cf6a3d063e958 Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Thu, 27 Apr 2023 01:34:45 +0200 Subject: [PATCH 22/81] Fix latest version error (#3648) --- internal/api/check_version.go | 42 ++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/internal/api/check_version.go b/internal/api/check_version.go index bb621cb9d..a2da99c9a 100644 --- a/internal/api/check_version.go +++ b/internal/api/check_version.go @@ -113,7 +113,6 @@ type LatestRelease struct { } func makeGithubRequest(ctx context.Context, url string, output interface{}) error { - transport := &http.Transport{Proxy: http.ProxyFromEnvironment} client := &http.Client{ @@ -124,6 +123,7 @@ func makeGithubRequest(ctx context.Context, url string, output interface{}) erro req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req.Header.Add("Accept", apiAcceptHeader) // gh api recommendation , send header with api version + logger.Debugf("Github API request: %s", url) response, err := client.Do(req) if err != nil { @@ -229,19 +229,39 @@ func GetLatestRelease(ctx context.Context) (*LatestRelease, error) { } func getReleaseHash(ctx context.Context, tagName string) (string, error) { - url := apiTags - tags := []githubTagResponse{} - err := makeGithubRequest(ctx, url, &tags) - if err != nil { - return "", err + // Start with a small page size if not searching for latest_develop + perPage := 10 + if tagName == developmentTag { + perPage = 100 } - for _, tag := range tags { - if tag.Name == tagName { - if len(tag.Commit.Sha) != 40 { - return "", errors.New("invalid Github API response") + // Limit to 5 pages, ie 500 tags - should be plenty + for page := 1; page <= 5; { + url := fmt.Sprintf("%s?per_page=%d&page=%d", apiTags, perPage, page) + tags := []githubTagResponse{} + err := makeGithubRequest(ctx, url, &tags) + if err != nil { + return "", err + } + + for _, tag := range tags { + if tag.Name == tagName { + if len(tag.Commit.Sha) != 40 { + return "", errors.New("invalid Github API response") + } + return tag.Commit.Sha, nil } - return tag.Commit.Sha, nil + } + + if len(tags) == 0 { + break + } + + // if not found in the first 10, search again on page 1 with the first 100 + if perPage == 10 { + perPage = 100 + } else { + page++ } } From 55d3deee49efa3dd212ade197e28d41e008eeafb Mon Sep 17 00:00:00 2001 From: dumdum7 <95527094+dumdum7@users.noreply.github.com> Date: Thu, 27 Apr 2023 04:24:33 +0200 Subject: [PATCH 23/81] Use big-buttons instead of videojs-mobile-ui touch controls (#3650) * Use big-buttons instead of videojs-mobile-ui touch controls * Update @types/videojs-mobile-ui to 0.8.0 --- ui/v2.5/package.json | 2 +- .../components/ScenePlayer/ScenePlayer.tsx | 6 +- .../src/components/ScenePlayer/big-buttons.ts | 69 +++++++++++++++++++ ui/v2.5/yarn.lock | 8 +-- 4 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 ui/v2.5/src/components/ScenePlayer/big-buttons.ts diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index 7236dda18..d287b8437 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -90,7 +90,7 @@ "@types/react-router-bootstrap": "^0.24.5", "@types/react-router-hash-link": "^2.4.5", "@types/video.js": "^7.3.51", - "@types/videojs-mobile-ui": "^0.5.0", + "@types/videojs-mobile-ui": "^0.8.0", "@types/videojs-seek-buttons": "^2.1.0", "@typescript-eslint/eslint-plugin": "^5.52.0", "@typescript-eslint/parser": "^5.52.0", diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 5ecc43a90..1a4c5e87d 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -16,6 +16,7 @@ import "./source-selector"; import "./persist-volume"; import "./markers"; import "./vtt-thumbnails"; +import "./big-buttons"; import "./track-activity"; import cx from "classnames"; import { @@ -303,6 +304,7 @@ export const ScenePlayer: React.FC = ({ markers: {}, sourceSelector: {}, persistVolume: {}, + bigButtons: {}, seekButtons: { forward: 10, back: 10, @@ -469,9 +471,7 @@ export const ScenePlayer: React.FC = ({ lockToLandscapeOnEnter: isLandscape, }, touchControls: { - seekSeconds: 10, - tapTimeout: 500, - disableOnEnd: false, + disabled: true, }, }; player.mobileUi(mobileUiOptions); diff --git a/ui/v2.5/src/components/ScenePlayer/big-buttons.ts b/ui/v2.5/src/components/ScenePlayer/big-buttons.ts new file mode 100644 index 000000000..619349aea --- /dev/null +++ b/ui/v2.5/src/components/ScenePlayer/big-buttons.ts @@ -0,0 +1,69 @@ +import videojs, { VideoJsPlayer } from "video.js"; + +// prettier-ignore +const BigPlayButton = videojs.getComponent("BigPlayButton") as unknown as typeof videojs.BigPlayButton; + +class BigPlayPauseButton extends BigPlayButton { + handleClick(event: videojs.EventTarget.Event) { + if (this.player().paused()) { + super.handleClick(event); + } else { + this.player().pause(); + } + } + + buildCSSClass() { + return "vjs-control vjs-button vjs-big-play-pause-button"; + } +} + +class BigButtonGroup extends videojs.getComponent("Component") { + constructor(player: VideoJsPlayer) { + super(player); + + this.addChild("seekButton", { + direction: "back", + seconds: 10, + }); + + this.addChild("BigPlayPauseButton"); + + this.addChild("seekButton", { + direction: "forward", + seconds: 10, + }); + } + + createEl() { + return super.createEl("div", { + className: "vjs-big-button-group", + }); + } +} + +class BigButtonsPlugin extends videojs.getPlugin("plugin") { + constructor(player: VideoJsPlayer) { + super(player); + + player.ready(() => { + player.addChild("BigButtonGroup"); + }); + } +} + +// Register the plugin with video.js. +videojs.registerComponent("BigButtonGroup", BigButtonGroup); +videojs.registerComponent("BigPlayPauseButton", BigPlayPauseButton); +videojs.registerPlugin("bigButtons", BigButtonsPlugin); + +/* eslint-disable @typescript-eslint/naming-convention */ +declare module "video.js" { + interface VideoJsPlayer { + bigButtons: () => BigButtonsPlugin; + } + interface VideoJsPlayerPluginOptions { + bigButtons?: {}; + } +} + +export default BigButtonsPlugin; diff --git a/ui/v2.5/yarn.lock b/ui/v2.5/yarn.lock index 32d6db07d..9c82c29b8 100644 --- a/ui/v2.5/yarn.lock +++ b/ui/v2.5/yarn.lock @@ -2440,10 +2440,10 @@ resolved "https://registry.yarnpkg.com/@types/video.js/-/video.js-7.3.51.tgz#ce69e02681ed6ed8abe61bb3802dd032a74d63e8" integrity sha512-xLlt/ZfCuWYBvG2MRn018RvaEplcK6dI63aOiVUeeAWFyjx3Br1hL749ndFgbrvNdY4m9FoHG1FQ/PB6IpfSAQ== -"@types/videojs-mobile-ui@^0.5.0": - version "0.5.0" - resolved "https://registry.yarnpkg.com/@types/videojs-mobile-ui/-/videojs-mobile-ui-0.5.0.tgz#66934b140fd437fda361986f8e7e87b01dc39138" - integrity sha512-wqeapTB35qpLfERxvL5mZGoexf5bA2TreDpFgc3zyCdr7Acf86VItvo9oTclFeUc11wOo7W7/4ueZZAEYmlTaA== +"@types/videojs-mobile-ui@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@types/videojs-mobile-ui/-/videojs-mobile-ui-0.8.0.tgz#0fb82810155f3dee9620ea411c8b9bd17e1ac645" + integrity sha512-Q8p7ezQLZzf8pnvYd8GZ/6tcg2oX0269Q94dDoqNnq2QMmqWp1sj8npU3gGnTaLkYvvdrO8UjBOIzX68RkQLew== dependencies: "@types/video.js" "*" From da1ef146c68f5a4fe4528bdf9a29a89fee738dca Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Wed, 3 May 2023 06:13:51 +0300 Subject: [PATCH 24/81] Add Matrix badge and link in support section (#3710) --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 5f2c0fdcd..3840a654e 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ https://stashapp.cc [![Docker pulls](https://img.shields.io/docker/pulls/stashapp/stash.svg)](https://hub.docker.com/r/stashapp/stash 'DockerHub') [![Open Collective backers](https://img.shields.io/opencollective/backers/stashapp?logo=opencollective)](https://opencollective.com/stashapp) [![Go Report Card](https://goreportcard.com/badge/github.com/stashapp/stash)](https://goreportcard.com/report/github.com/stashapp/stash) +[![Matrix](https://img.shields.io/matrix/stashapp:unredacted.org?logo=matrix&server_fqdn=matrix.org)](https://matrix.to/#/#stashapp:unredacted.org) [![Discord](https://img.shields.io/discord/559159668438728723.svg?logo=discord)](https://discord.gg/2TsNFKt) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/stashapp/stash?logo=github)](https://github.com/stashapp/stash/releases/latest) [![GitHub issues by-label](https://img.shields.io/github/issues-raw/stashapp/stash/bounty)](https://github.com/stashapp/stash/labels/bounty) @@ -58,6 +59,7 @@ Check out our documentation on [Stash-Docs](https://docs.stashapp.cc) for inform For more help you can: * Check the in-app documentation, in the top right corner of the app (it's also mirrored on [Stash-Docs](https://docs.stashapp.cc/in-app-manual)) +* Join the [Matrix space](https://matrix.to/#/#stashapp:unredacted.org) * Join the [Discord server](https://discord.gg/2TsNFKt), where the community can offer support. * Start a [discussion on GitHub](https://github.com/stashapp/stash/discussions) From 89ed6e9a67788d3d172c0fe652bee0dd69f30ffc Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Wed, 3 May 2023 05:24:29 +0200 Subject: [PATCH 25/81] Fix scene marker pinned filters (#3687) --- .../src/components/List/EditFilterDialog.tsx | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/List/EditFilterDialog.tsx b/ui/v2.5/src/components/List/EditFilterDialog.tsx index 4e52f1259..8a41b4a2d 100644 --- a/ui/v2.5/src/components/List/EditFilterDialog.tsx +++ b/ui/v2.5/src/components/List/EditFilterDialog.tsx @@ -33,6 +33,7 @@ import { CriterionType } from "src/models/list-filter/types"; import { useToast } from "src/hooks/Toast"; import { useConfigureUI } from "src/core/StashService"; import { IUIConfig } from "src/core/config"; +import { FilterMode } from "src/core/generated-graphql"; interface ICriterionList { criteria: string[]; @@ -188,6 +189,21 @@ const CriterionOptionList: React.FC = ({ ); }; +const FilterModeToConfigKey = { + [FilterMode.Galleries]: "galleries", + [FilterMode.Images]: "images", + [FilterMode.Movies]: "movies", + [FilterMode.Performers]: "performers", + [FilterMode.SceneMarkers]: "sceneMarkers", + [FilterMode.Scenes]: "scenes", + [FilterMode.Studios]: "studios", + [FilterMode.Tags]: "tags", +}; + +function filterModeToConfigKey(filterMode: FilterMode) { + return FilterModeToConfigKey[filterMode]; +} + interface IEditFilterProps { filter: ListFilterModel; editingCriterion?: string; @@ -260,7 +276,7 @@ export const EditFilterDialog: React.FC = ({ const [saveUI] = useConfigureUI(); const pinnedFilters = useMemo( - () => ui.pinnedFilters?.[currentFilter.mode.toLowerCase()] ?? [], + () => ui.pinnedFilters?.[filterModeToConfigKey(currentFilter.mode)] ?? [], [currentFilter.mode, ui.pinnedFilters] ); const pinnedElements = useMemo( @@ -289,7 +305,7 @@ export const EditFilterDialog: React.FC = ({ ]); async function updatePinnedFilters(filters: string[]) { - const currentMode = currentFilter.mode.toLowerCase(); + const configKey = filterModeToConfigKey(currentFilter.mode); try { await saveUI({ variables: { @@ -297,7 +313,7 @@ export const EditFilterDialog: React.FC = ({ ...configuration?.ui, pinnedFilters: { ...ui.pinnedFilters, - [currentMode]: filters, + [configKey]: filters, }, }, }, From c9c5b557212a7ffa6bef0e475bc6a951465e7ade Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Wed, 3 May 2023 05:28:23 +0200 Subject: [PATCH 26/81] Ignore graphql context canceled errors (#3689) --- internal/api/error.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/api/error.go b/internal/api/error.go index 85d9cde28..208b2521c 100644 --- a/internal/api/error.go +++ b/internal/api/error.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "errors" "github.com/99designs/gqlgen/graphql" "github.com/stashapp/stash/pkg/logger" @@ -13,7 +14,7 @@ func gqlErrorHandler(ctx context.Context, e error) *gqlerror.Error { // log all errors - for now just log the error message // we can potentially add more context later fc := graphql.GetFieldContext(ctx) - if fc != nil { + if fc != nil && !errors.Is(e, context.Canceled) { logger.Errorf("%s: %v", fc.Path(), e) // log the args in debug level From 55e0d5c82fbe63baa2ff7ebdd4de6732cc6100a1 Mon Sep 17 00:00:00 2001 From: Bawdy Ink Slinger <51732963+BawdyInkSlinger@users.noreply.github.com> Date: Tue, 2 May 2023 20:29:38 -0700 Subject: [PATCH 27/81] Removed a sentence that is technically irrelevant to auto tagging (#3683) - (As far as I know,) scraping is irrelevant to auto tagging so I removed it from the Auto Tagging documentation. Alternatively, it could be moved to the bottom. Co-authored-by: Bawdy Ink Slinger --- ui/v2.5/src/docs/en/Manual/AutoTagging.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/docs/en/Manual/AutoTagging.md b/ui/v2.5/src/docs/en/Manual/AutoTagging.md index 4fba88ad0..ef9035e6c 100644 --- a/ui/v2.5/src/docs/en/Manual/AutoTagging.md +++ b/ui/v2.5/src/docs/en/Manual/AutoTagging.md @@ -2,7 +2,7 @@ This task matches your Performers, Studios, and Tags against your media, based on names only. It finds Scenes, Images, and Galleries where the path or filename contains the Performer/Studio/Tag. -For each scene it finds that matches, it sets the applicable field. It will **only** tag based on performers, studios, and tags that already exist in your database. In order to completely identify and gather information about the scenes in your collection, you will need to use the Tagger view and/or Scraping tools. +For each scene it finds that matches, it sets the applicable field. It will **only** tag based on performers, studios, and tags that already exist in your database. When the Performer/Studio/Tag name has multiple words, the search will include paths/filenames where the Performer/Studio/Tag name is separated with `.`, `-` or `_` characters, as well as whitespace. From d6b4d16ff415979ff40051e985ee2bf993fe40a6 Mon Sep 17 00:00:00 2001 From: CJ <72030708+Teda1@users.noreply.github.com> Date: Tue, 2 May 2023 20:33:32 -0700 Subject: [PATCH 28/81] Adds ability to configure sort order for DLNA videos (#3645) --- graphql/documents/data/config.graphql | 1 + graphql/schema/types/config.graphql | 4 +++ internal/api/resolver_mutation_configure.go | 4 +++ internal/api/resolver_query_configuration.go | 1 + internal/dlna/cds.go | 11 +++++--- internal/dlna/dms.go | 1 + internal/dlna/service.go | 4 +++ internal/manager/config/config.go | 14 ++++++++++ .../Settings/SettingsServicesPanel.tsx | 27 ++++++++++++++++++- ui/v2.5/src/locales/en-GB.json | 4 ++- ui/v2.5/src/utils/dlnaVideoSort.ts | 17 ++++++++++++ 11 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 ui/v2.5/src/utils/dlnaVideoSort.ts diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index 173a7948e..a96341653 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -99,6 +99,7 @@ fragment ConfigDLNAData on ConfigDLNAResult { enabled whitelistedIPs interfaces + videoSortOrder } fragment ConfigScrapingData on ConfigScrapingResult { diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index df0aba092..904d235dd 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -431,6 +431,8 @@ input ConfigDLNAInput { whitelistedIPs: [String!] """List of interfaces to run DLNA on. Empty for all""" interfaces: [String!] + """Order to sort videos""" + videoSortOrder: String } type ConfigDLNAResult { @@ -441,6 +443,8 @@ type ConfigDLNAResult { whitelistedIPs: [String!]! """List of interfaces to run DLNA on. Empty for all""" interfaces: [String!]! + """Order to sort videos""" + videoSortOrder: String! } input ConfigScrapingInput { diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 824f9e6d7..2a102af6e 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -493,6 +493,10 @@ func (r *mutationResolver) ConfigureDlna(ctx context.Context, input ConfigDLNAIn c.Set(config.DLNADefaultIPWhitelist, input.WhitelistedIPs) } + if input.VideoSortOrder != nil { + c.Set(config.DLNAVideoSortOrder, input.VideoSortOrder) + } + currentDLNAEnabled := c.GetDLNADefaultEnabled() if input.Enabled != nil && *input.Enabled != currentDLNAEnabled { c.Set(config.DLNADefaultEnabled, *input.Enabled) diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index fd598ce92..643aa263b 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -202,6 +202,7 @@ func makeConfigDLNAResult() *ConfigDLNAResult { Enabled: config.GetDLNADefaultEnabled(), WhitelistedIPs: config.GetDLNADefaultIPWhitelist(), Interfaces: config.GetDLNAInterfaces(), + VideoSortOrder: config.GetVideoSortOrder(), } } diff --git a/internal/dlna/cds.go b/internal/dlna/cds.go index 4deb017f2..cf5deaa7c 100644 --- a/internal/dlna/cds.go +++ b/internal/dlna/cds.go @@ -444,10 +444,15 @@ func (me *contentDirectoryService) getVideos(sceneFilter *models.SceneFilterType var objs []interface{} if err := txn.WithReadTxn(context.TODO(), me.txnManager, func(ctx context.Context) error { - sort := "title" + sort := me.VideoSortOrder + direction := models.SortDirectionEnumDesc + if sort == "title" { + direction = models.SortDirectionEnumAsc + } findFilter := &models.FindFilterType{ - PerPage: &pageSize, - Sort: &sort, + PerPage: &pageSize, + Sort: &sort, + Direction: &direction, } scenes, total, err := scene.QueryWithCount(ctx, me.repository.SceneFinder, sceneFilter, findFilter) diff --git a/internal/dlna/dms.go b/internal/dlna/dms.go index fdef80db1..502dbe0e4 100644 --- a/internal/dlna/dms.go +++ b/internal/dlna/dms.go @@ -276,6 +276,7 @@ type Server struct { repository Repository sceneServer sceneServer ipWhitelistManager *ipWhitelistManager + VideoSortOrder string } // UPnP SOAP service. diff --git a/internal/dlna/service.go b/internal/dlna/service.go index a257b7f94..0d8932e08 100644 --- a/internal/dlna/service.go +++ b/internal/dlna/service.go @@ -45,6 +45,7 @@ type dmsConfig struct { LogHeaders bool StallEventSubscribe bool NotifyInterval time.Duration + VideoSortOrder string } type sceneServer interface { @@ -56,6 +57,7 @@ type Config interface { GetDLNAInterfaces() []string GetDLNAServerName() string GetDLNADefaultIPWhitelist() []string + GetVideoSortOrder() string } type Service struct { @@ -123,6 +125,7 @@ func (s *Service) init() error { FriendlyName: friendlyName, LogHeaders: false, NotifyInterval: 30 * time.Second, + VideoSortOrder: s.config.GetVideoSortOrder(), } interfaces, err := s.getInterfaces() @@ -164,6 +167,7 @@ func (s *Service) init() error { // }, StallEventSubscribe: dmsConfig.StallEventSubscribe, NotifyInterval: dmsConfig.NotifyInterval, + VideoSortOrder: dmsConfig.VideoSortOrder, } return nil diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 4b2ba7921..fe9730219 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -210,6 +210,9 @@ const ( DLNADefaultIPWhitelist = "dlna.default_whitelist" DLNAInterfaces = "dlna.interfaces" + DLNAVideoSortOrder = "dlna.video_sort_order" + dlnaVideoSortOrderDefault = "title" + // Logging options LogFile = "logFile" LogOut = "logOut" @@ -1370,6 +1373,17 @@ func (i *Instance) GetDLNAInterfaces() []string { return i.getStringSlice(DLNAInterfaces) } +// GetVideoSortOrder returns the sort order to display videos. If +// empty, videos will be sorted by titles. +func (i *Instance) GetVideoSortOrder() string { + ret := i.getString(DLNAVideoSortOrder) + if ret == "" { + ret = dlnaVideoSortOrderDefault + } + + return ret +} + // GetLogFile returns the filename of the file to output logs to. // An empty string means that file logging will be disabled. func (i *Instance) GetLogFile() string { diff --git a/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx b/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx index 2db88f926..38a1ccb79 100644 --- a/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx @@ -14,8 +14,17 @@ import { Icon } from "../Shared/Icon"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { ModalComponent } from "../Shared/Modal"; import { SettingSection } from "./SettingSection"; -import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs"; +import { + BooleanSetting, + StringListSetting, + StringSetting, + SelectSetting, +} from "./Inputs"; import { SettingStateContext } from "./context"; +import { + videoSortOrderIntlMap, + defaultVideoSort, +} from "src/utils/dlnaVideoSort"; import { faClock, faTimes, @@ -445,6 +454,22 @@ export const SettingsServicesPanel: React.FC = () => { value={dlna.whitelistedIPs ?? undefined} onChange={(v) => saveDLNA({ whitelistedIPs: v })} /> + + saveDLNA({ videoSortOrder: v })} + > + {Array.from(videoSortOrderIntlMap.entries()).map((v) => ( + + ))} + ); diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index c232e0964..2272cd1c0 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -234,7 +234,9 @@ "server_display_name": "Server Display Name", "server_display_name_desc": "Display name for the DLNA server. Defaults to {server_name} if empty.", "successfully_cancelled_temporary_behaviour": "Successfully cancelled temporary behaviour", - "until_restart": "until restart" + "until_restart": "until restart", + "video_sort_order": "Default Video Sort Order", + "video_sort_order_desc": "Order to sort videos by default." }, "general": { "auth": { diff --git a/ui/v2.5/src/utils/dlnaVideoSort.ts b/ui/v2.5/src/utils/dlnaVideoSort.ts new file mode 100644 index 000000000..8cd26e6f9 --- /dev/null +++ b/ui/v2.5/src/utils/dlnaVideoSort.ts @@ -0,0 +1,17 @@ +export enum VideoSortOrder { + Created_At = "created_at", + Date = "date", + Random = "random", + Title = "title", + Updated_At = "updated_at", +} + +export const defaultVideoSort = VideoSortOrder.Title; + +export const videoSortOrderIntlMap = new Map([ + [VideoSortOrder.Created_At, "created_at"], + [VideoSortOrder.Date, "date"], + [VideoSortOrder.Random, "random"], + [VideoSortOrder.Title, "title"], + [VideoSortOrder.Updated_At, "updated_at"], +]); From 1606f1b17e58ea33c9eb65a0572e8fb467ccc3f2 Mon Sep 17 00:00:00 2001 From: Flashy78 <90150289+Flashy78@users.noreply.github.com> Date: Tue, 2 May 2023 20:34:57 -0700 Subject: [PATCH 29/81] Sort scrapers by name (#3691) --- pkg/scraper/cache.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pkg/scraper/cache.go b/pkg/scraper/cache.go index 3b5391994..5a15239db 100644 --- a/pkg/scraper/cache.go +++ b/pkg/scraper/cache.go @@ -150,7 +150,6 @@ func (c *Cache) loadScrapers() (map[string]scraper, error) { logger.Debugf("Reading scraper configs from %s", path) - scraperFiles := []string{} err := fsutil.SymWalk(path, func(fp string, f os.FileInfo, err error) error { if filepath.Ext(fp) == ".yml" { conf, err := loadConfigFromYAMLFile(fp) @@ -160,7 +159,6 @@ func (c *Cache) loadScrapers() (map[string]scraper, error) { scraper := newGroupScraper(*conf, c.globalConfig) scrapers[scraper.spec().ID] = scraper } - scraperFiles = append(scraperFiles, fp) } return nil }) @@ -187,7 +185,7 @@ func (c *Cache) ReloadScrapers() error { } // ListScrapers lists scrapers matching one of the given types. -// Returns a list of scrapers, sorted by their ID. +// Returns a list of scrapers, sorted by their name. func (c Cache) ListScrapers(tys []ScrapeContentType) []*Scraper { var ret []*Scraper for _, s := range c.scrapers { @@ -201,7 +199,7 @@ func (c Cache) ListScrapers(tys []ScrapeContentType) []*Scraper { } sort.Slice(ret, func(i, j int) bool { - return ret[i].ID < ret[j].ID + return strings.ToLower(ret[i].Name) < strings.ToLower(ret[j].Name) }) return ret From 1717474a8161f15b7c5dbbe497800df146b572af Mon Sep 17 00:00:00 2001 From: dogwithakeyboard <128322708+dogwithakeyboard@users.noreply.github.com> Date: Wed, 3 May 2023 04:37:31 +0100 Subject: [PATCH 30/81] fix scene scraper movie error (#3633) --- .../src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx index 953b24b64..cf658200a 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx @@ -521,7 +521,7 @@ export const SceneScrapeDialog: React.FC = ({ // remove the movie from the list const newMoviesClone = newMovies.concat(); - const pIndex = newMoviesClone.indexOf(toCreate); + const pIndex = newMoviesClone.findIndex((p) => p.name === toCreate.name); if (pIndex === -1) throw new Error("Could not find movie to remove"); newMoviesClone.splice(pIndex, 1); @@ -558,7 +558,7 @@ export const SceneScrapeDialog: React.FC = ({ // remove the tag from the list const newTagsClone = newTags.concat(); const pIndex = newTagsClone.indexOf(toCreate); - if (pIndex === -1) throw new Error("Could not find performer to remove"); + if (pIndex === -1) throw new Error("Could not find tag to remove"); newTagsClone.splice(pIndex, 1); setNewTags(newTagsClone); From 67a2161c626441afae50b56d582e81dcce96c0e8 Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Wed, 3 May 2023 05:42:25 +0200 Subject: [PATCH 31/81] Fix generate task override behaviour (#3661) --- internal/manager/task_generate.go | 55 +++++++++++++------ ...task_generate_interactive_heatmap_speed.go | 11 +++- internal/manager/task_generate_phash.go | 10 +++- internal/manager/task_generate_preview.go | 42 ++++++++------ internal/manager/task_generate_screenshot.go | 17 +++--- internal/manager/task_generate_sprite.go | 7 ++- internal/manager/task_scan.go | 1 + internal/manager/task_transcode.go | 2 +- ui/v2.5/src/locales/en-GB.json | 2 +- 9 files changed, 96 insertions(+), 51 deletions(-) diff --git a/internal/manager/task_generate.go b/internal/manager/task_generate.go index c3b4f16f7..c457ddedf 100644 --- a/internal/manager/task_generate.go +++ b/internal/manager/task_generate.go @@ -142,7 +142,35 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) { return } - logger.Infof("Generating %d covers %d sprites %d previews %d image previews %d markers %d transcodes %d phashes %d heatmaps & speeds", totals.covers, totals.sprites, totals.previews, totals.imagePreviews, totals.markers, totals.transcodes, totals.phashes, totals.interactiveHeatmapSpeeds) + logMsg := "Generating" + if j.input.Covers { + logMsg += fmt.Sprintf(" %d covers", totals.covers) + } + if j.input.Sprites { + logMsg += fmt.Sprintf(" %d sprites", totals.sprites) + } + if j.input.Previews { + logMsg += fmt.Sprintf(" %d previews", totals.previews) + } + if j.input.ImagePreviews { + logMsg += fmt.Sprintf(" %d image previews", totals.imagePreviews) + } + if j.input.Markers { + logMsg += fmt.Sprintf(" %d markers", totals.markers) + } + if j.input.Transcodes { + logMsg += fmt.Sprintf(" %d transcodes", totals.transcodes) + } + if j.input.Phashes { + logMsg += fmt.Sprintf(" %d phashes", totals.phashes) + } + if j.input.InteractiveHeatmapsSpeeds { + logMsg += fmt.Sprintf(" %d heatmaps & speeds", totals.interactiveHeatmapSpeeds) + } + if logMsg == "Generating" { + logMsg = "Nothing selected to generate" + } + logger.Infof(logMsg) progress.SetTotal(int(totals.tasks)) }() @@ -269,9 +297,10 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, task := &GenerateCoverTask{ txnManager: j.txnManager, Scene: *scene, + Overwrite: j.overwrite, } - if j.overwrite || task.required(ctx) { + if task.required(ctx) { totals.covers++ totals.tasks++ queue <- task @@ -285,7 +314,7 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, fileNamingAlgorithm: j.fileNamingAlgo, } - if j.overwrite || task.required() { + if task.required() { totals.sprites++ totals.tasks++ queue <- task @@ -309,21 +338,15 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, } if task.required() { - addTask := false - if j.overwrite || !task.doesVideoPreviewExist() { + if task.videoPreviewRequired() { totals.previews++ - addTask = true } - - if j.input.ImagePreviews && (j.overwrite || !task.doesImagePreviewExist()) { + if task.imagePreviewRequired() { totals.imagePreviews++ - addTask = true } - if addTask { - totals.tasks++ - queue <- task - } + totals.tasks++ + queue <- task } } @@ -357,7 +380,7 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, fileNamingAlgorithm: j.fileNamingAlgo, g: g, } - if task.isTranscodeNeeded() { + if task.required() { totals.transcodes++ totals.tasks++ queue <- task @@ -375,7 +398,7 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, Overwrite: j.overwrite, } - if task.shouldGenerate() { + if task.required() { totals.phashes++ totals.tasks++ queue <- task @@ -391,7 +414,7 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, TxnManager: j.txnManager, } - if task.shouldGenerate() { + if task.required() { totals.interactiveHeatmapSpeeds++ totals.tasks++ queue <- task diff --git a/internal/manager/task_generate_interactive_heatmap_speed.go b/internal/manager/task_generate_interactive_heatmap_speed.go index 564004b8e..4f91bd023 100644 --- a/internal/manager/task_generate_interactive_heatmap_speed.go +++ b/internal/manager/task_generate_interactive_heatmap_speed.go @@ -22,7 +22,7 @@ func (t *GenerateInteractiveHeatmapSpeedTask) GetDescription() string { } func (t *GenerateInteractiveHeatmapSpeedTask) Start(ctx context.Context) { - if !t.shouldGenerate() { + if !t.required() { return } @@ -52,13 +52,18 @@ func (t *GenerateInteractiveHeatmapSpeedTask) Start(ctx context.Context) { } } -func (t *GenerateInteractiveHeatmapSpeedTask) shouldGenerate() bool { +func (t *GenerateInteractiveHeatmapSpeedTask) required() bool { primaryFile := t.Scene.Files.Primary() if primaryFile == nil || !primaryFile.Interactive { return false } + + if t.Overwrite { + return true + } + sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) - return !t.doesHeatmapExist(sceneHash) || primaryFile.InteractiveSpeed == nil || t.Overwrite + return !t.doesHeatmapExist(sceneHash) || primaryFile.InteractiveSpeed == nil } func (t *GenerateInteractiveHeatmapSpeedTask) doesHeatmapExist(sceneChecksum string) bool { diff --git a/internal/manager/task_generate_phash.go b/internal/manager/task_generate_phash.go index 6ba840694..8ae84b02e 100644 --- a/internal/manager/task_generate_phash.go +++ b/internal/manager/task_generate_phash.go @@ -24,7 +24,7 @@ func (t *GeneratePhashTask) GetDescription() string { } func (t *GeneratePhashTask) Start(ctx context.Context) { - if !t.shouldGenerate() { + if !t.required() { return } @@ -49,6 +49,10 @@ func (t *GeneratePhashTask) Start(ctx context.Context) { } } -func (t *GeneratePhashTask) shouldGenerate() bool { - return t.Overwrite || t.File.Fingerprints.Get(file.FingerprintTypePhash) == nil +func (t *GeneratePhashTask) required() bool { + if t.Overwrite { + return true + } + + return t.File.Fingerprints.Get(file.FingerprintTypePhash) == nil } diff --git a/internal/manager/task_generate_preview.go b/internal/manager/task_generate_preview.go index c81909417..df2a69ee5 100644 --- a/internal/manager/task_generate_preview.go +++ b/internal/manager/task_generate_preview.go @@ -30,13 +30,9 @@ func (t *GeneratePreviewTask) GetDescription() string { } func (t *GeneratePreviewTask) Start(ctx context.Context) { - if !t.Overwrite && !t.required() { - return - } - videoChecksum := t.Scene.GetHash(t.fileNamingAlgorithm) - if t.Overwrite || !t.doesVideoPreviewExist() { + if t.videoPreviewRequired() { ffprobe := instance.FFProbe videoFile, err := ffprobe.NewVideoFile(t.Scene.Path) if err != nil { @@ -51,7 +47,7 @@ func (t *GeneratePreviewTask) Start(ctx context.Context) { } } - if t.ImagePreview && (t.Overwrite || !t.doesImagePreviewExist()) { + if t.imagePreviewRequired() { if err := t.generateWebp(videoChecksum); err != nil { logger.Errorf("error generating preview webp: %v", err) logErrorOutput(err) @@ -59,7 +55,7 @@ func (t *GeneratePreviewTask) Start(ctx context.Context) { } } -func (t GeneratePreviewTask) generateVideo(videoChecksum string, videoDuration float64, videoFrameRate float64) error { +func (t *GeneratePreviewTask) generateVideo(videoChecksum string, videoDuration float64, videoFrameRate float64) error { videoFilename := t.Scene.Path useVsync2 := false @@ -78,12 +74,16 @@ func (t GeneratePreviewTask) generateVideo(videoChecksum string, videoDuration f return nil } -func (t GeneratePreviewTask) generateWebp(videoChecksum string) error { +func (t *GeneratePreviewTask) generateWebp(videoChecksum string) error { videoFilename := t.Scene.Path return t.generator.PreviewWebp(context.TODO(), videoFilename, videoChecksum) } -func (t GeneratePreviewTask) required() bool { +func (t *GeneratePreviewTask) required() bool { + return t.videoPreviewRequired() || t.imagePreviewRequired() +} + +func (t *GeneratePreviewTask) videoPreviewRequired() bool { if t.Scene.Path == "" { return false } @@ -92,12 +92,6 @@ func (t GeneratePreviewTask) required() bool { return true } - videoExists := t.doesVideoPreviewExist() - imageExists := !t.ImagePreview || t.doesImagePreviewExist() - return !imageExists || !videoExists -} - -func (t *GeneratePreviewTask) doesVideoPreviewExist() bool { sceneChecksum := t.Scene.GetHash(t.fileNamingAlgorithm) if sceneChecksum == "" { return false @@ -108,10 +102,22 @@ func (t *GeneratePreviewTask) doesVideoPreviewExist() bool { t.videoPreviewExists = &videoExists } - return *t.videoPreviewExists + return !*t.videoPreviewExists } -func (t *GeneratePreviewTask) doesImagePreviewExist() bool { +func (t *GeneratePreviewTask) imagePreviewRequired() bool { + if !t.ImagePreview { + return false + } + + if t.Scene.Path == "" { + return false + } + + if t.Overwrite { + return true + } + sceneChecksum := t.Scene.GetHash(t.fileNamingAlgorithm) if sceneChecksum == "" { return false @@ -122,5 +128,5 @@ func (t *GeneratePreviewTask) doesImagePreviewExist() bool { t.imagePreviewExists = &imageExists } - return *t.imagePreviewExists + return !*t.imagePreviewExists } diff --git a/internal/manager/task_generate_screenshot.go b/internal/manager/task_generate_screenshot.go index 5d32f2762..b3cd93e38 100644 --- a/internal/manager/task_generate_screenshot.go +++ b/internal/manager/task_generate_screenshot.go @@ -21,21 +21,18 @@ func (t *GenerateCoverTask) GetDescription() string { } func (t *GenerateCoverTask) Start(ctx context.Context) { + if !t.required(ctx) { + return + } + scenePath := t.Scene.Path - var required bool if err := t.txnManager.WithReadTxn(ctx, func(ctx context.Context) error { - // don't generate the screenshot if it already exists - required = t.required(ctx) return t.Scene.LoadPrimaryFile(ctx, t.txnManager.File) }); err != nil { logger.Error(err) } - if !required { - return - } - videoFile := t.Scene.Files.Primary() if videoFile == nil { return @@ -92,7 +89,11 @@ func (t *GenerateCoverTask) Start(ctx context.Context) { } // required returns true if the sprite needs to be generated -func (t GenerateCoverTask) required(ctx context.Context) bool { +func (t *GenerateCoverTask) required(ctx context.Context) bool { + if t.Scene.Path == "" { + return false + } + if t.Overwrite { return true } diff --git a/internal/manager/task_generate_sprite.go b/internal/manager/task_generate_sprite.go index eb96d8f4c..0275830ab 100644 --- a/internal/manager/task_generate_sprite.go +++ b/internal/manager/task_generate_sprite.go @@ -20,7 +20,7 @@ func (t *GenerateSpriteTask) GetDescription() string { } func (t *GenerateSpriteTask) Start(ctx context.Context) { - if !t.Overwrite && !t.required() { + if !t.required() { return } @@ -54,6 +54,11 @@ func (t GenerateSpriteTask) required() bool { if t.Scene.Path == "" { return false } + + if t.Overwrite { + return true + } + sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) return !t.doesSpriteExist(sceneHash) } diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index fa31af610..43d264c22 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -490,6 +490,7 @@ func (g *sceneGenerators) Generate(ctx context.Context, s *models.Scene, f *file taskCover := GenerateCoverTask{ Scene: *s, txnManager: instance.Repository, + Overwrite: overwrite, } taskCover.Start(ctx) progress.Increment() diff --git a/internal/manager/task_transcode.go b/internal/manager/task_transcode.go index 296042bdd..edda08fbb 100644 --- a/internal/manager/task_transcode.go +++ b/internal/manager/task_transcode.go @@ -101,7 +101,7 @@ func (t *GenerateTranscodeTask) Start(ctc context.Context) { // return true if transcode is needed // used only when counting files to generate, doesn't affect the actual transcode generation // if container is missing from DB it is treated as non supported in order not to delay the user -func (t *GenerateTranscodeTask) isTranscodeNeeded() bool { +func (t *GenerateTranscodeTask) required() bool { f := t.Scene.Files.Primary() if f == nil { return false diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 2272cd1c0..73f9a73e7 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -813,7 +813,7 @@ "markers_tooltip": "20 second videos which begin at the given timecode.", "override_preview_generation_options": "Override Preview Generation Options", "override_preview_generation_options_desc": "Override Preview Generation Options for this operation. Defaults are set in System -> Preview Generation.", - "overwrite": "Overwrite existing generated files", + "overwrite": "Overwrite existing files", "phash": "Perceptual hashes (for deduplication)", "preview_exclude_end_time_desc": "Exclude the last x seconds from scene previews. This can be a value in seconds, or a percentage (eg 2%) of the total scene duration.", "preview_exclude_end_time_head": "Exclude end time", From 002b71bd6763021e79c316bf5388f2813c1f1445 Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Wed, 3 May 2023 05:43:52 +0200 Subject: [PATCH 32/81] Fix filter dialog datepicker button padding (#3690) --- ui/v2.5/src/components/List/styles.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index e86199e6b..40a9ead91 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -154,7 +154,7 @@ input[type="range"].zoom-slider { } } - .btn { + .card-header .btn { border: 0; padding-bottom: 0; padding-top: 0; From 899d1b9395ab8a5bbd75ab34bdd22a5a3aae20a2 Mon Sep 17 00:00:00 2001 From: puc9 <51006296+puc9@users.noreply.github.com> Date: Tue, 2 May 2023 22:01:59 -0700 Subject: [PATCH 33/81] Limit duplicate matching to files that have ~ same duration (#3663) * Limit duplicate matching to files that have ~ same duration * Add UI for duration diff --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/documents/queries/scene.graphql | 4 +- graphql/schema/schema.graphql | 16 ++- internal/api/resolver_query_find_scene.go | 8 +- pkg/models/mocks/SceneReaderWriter.go | 2 +- pkg/models/scene.go | 2 +- pkg/sqlite/scene.go | 48 ++++--- pkg/sqlite/scene_test.go | 6 +- pkg/utils/phash.go | 22 ++- .../SceneDuplicateChecker.tsx | 134 ++++++++++++------ .../SceneDuplicateChecker/styles.scss | 4 + ui/v2.5/src/locales/en-GB.json | 6 + 11 files changed, 177 insertions(+), 75 deletions(-) diff --git a/graphql/documents/queries/scene.graphql b/graphql/documents/queries/scene.graphql index 1f762855a..e62303dc7 100644 --- a/graphql/documents/queries/scene.graphql +++ b/graphql/documents/queries/scene.graphql @@ -20,8 +20,8 @@ query FindScenesByPathRegex($filter: FindFilterType) { } } -query FindDuplicateScenes($distance: Int) { - findDuplicateScenes(distance: $distance) { +query FindDuplicateScenes($distance: Int, $duration_diff: Float) { + findDuplicateScenes(distance: $distance, duration_diff: $duration_diff) { ...SlimSceneData } } diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 112f8aba9..3a4f6e738 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -14,8 +14,16 @@ type Query { findScenesByPathRegex(filter: FindFilterType): FindScenesResultType! - """ Returns any groups of scenes that are perceptual duplicates within the queried distance """ - findDuplicateScenes(distance: Int): [[Scene!]!]! + """ + Returns any groups of scenes that are perceptual duplicates within the queried distance + and the difference between their duration is smaller than durationDiff + """ + findDuplicateScenes( + distance: Int, + """Max difference in seconds between files in order to be considered for similarity matching. + Fractional seconds are ok: 0.5 will mean only files that have durations within 0.5 seconds between them will be matched based on PHash distance.""" + duration_diff: Float + ): [[Scene!]!]! """Return valid stream paths""" sceneStreams(id: ID): [SceneStreamEndpoint!]! @@ -295,14 +303,14 @@ type Mutation { metadataClean(input: CleanMetadataInput!): ID! """Identifies scenes using scrapers. Returns the job ID""" metadataIdentify(input: IdentifyMetadataInput!): ID! - + """Migrate generated files for the current hash naming""" migrateHashNaming: ID! """Migrates legacy scene screenshot files into the blob storage""" migrateSceneScreenshots(input: MigrateSceneScreenshotsInput!): ID! """Migrates blobs from the old storage system to the current one""" migrateBlobs(input: MigrateBlobsInput!): ID! - + """Anonymise the database in a separate file. Optionally returns a link to download the database file""" anonymiseDatabase(input: AnonymiseDatabaseInput!): String diff --git a/internal/api/resolver_query_find_scene.go b/internal/api/resolver_query_find_scene.go index 1eaa2dc03..c60cf88c2 100644 --- a/internal/api/resolver_query_find_scene.go +++ b/internal/api/resolver_query_find_scene.go @@ -220,13 +220,17 @@ func (r *queryResolver) ParseSceneFilenames(ctx context.Context, filter *models. return ret, nil } -func (r *queryResolver) FindDuplicateScenes(ctx context.Context, distance *int) (ret [][]*models.Scene, err error) { +func (r *queryResolver) FindDuplicateScenes(ctx context.Context, distance *int, durationDiff *float64) (ret [][]*models.Scene, err error) { dist := 0 + durDiff := -1. if distance != nil { dist = *distance } + if durationDiff != nil { + durDiff = *durationDiff + } if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = r.repository.Scene.FindDuplicates(ctx, dist) + ret, err = r.repository.Scene.FindDuplicates(ctx, dist, durDiff) return err }); err != nil { return nil, err diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index f67a909b4..7ee47e906 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -439,7 +439,7 @@ func (_m *SceneReaderWriter) FindByPerformerID(ctx context.Context, performerID } // FindDuplicates provides a mock function with given fields: ctx, distance -func (_m *SceneReaderWriter) FindDuplicates(ctx context.Context, distance int) ([][]*models.Scene, error) { +func (_m *SceneReaderWriter) FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*models.Scene, error) { ret := _m.Called(ctx, distance) var r0 [][]*models.Scene diff --git a/pkg/models/scene.go b/pkg/models/scene.go index ac9cd93c8..90655ff5e 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -153,7 +153,7 @@ type SceneReader interface { FindByPath(ctx context.Context, path string) ([]*Scene, error) FindByPerformerID(ctx context.Context, performerID int) ([]*Scene, error) FindByGalleryID(ctx context.Context, performerID int) ([]*Scene, error) - FindDuplicates(ctx context.Context, distance int) ([][]*Scene, error) + FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*Scene, error) GalleryIDLoader PerformerIDLoader diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index a049557da..721a4d456 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -36,23 +36,38 @@ const ( ) var findExactDuplicateQuery = ` -SELECT GROUP_CONCAT(scenes.id) as ids -FROM scenes -INNER JOIN scenes_files ON (scenes.id = scenes_files.scene_id) -INNER JOIN files ON (scenes_files.file_id = files.id) -INNER JOIN files_fingerprints ON (scenes_files.file_id = files_fingerprints.file_id AND files_fingerprints.type = 'phash') -GROUP BY files_fingerprints.fingerprint -HAVING COUNT(files_fingerprints.fingerprint) > 1 AND COUNT(DISTINCT scenes.id) > 1 -ORDER BY SUM(files.size) DESC; +SELECT GROUP_CONCAT(DISTINCT scene_id) as ids +FROM ( + SELECT scenes.id as scene_id + , video_files.duration as file_duration + , files.size as file_size + , files_fingerprints.fingerprint as phash + , abs(max(video_files.duration) OVER (PARTITION by files_fingerprints.fingerprint) - video_files.duration) as durationDiff + FROM scenes + INNER JOIN scenes_files ON (scenes.id = scenes_files.scene_id) + INNER JOIN files ON (scenes_files.file_id = files.id) + INNER JOIN files_fingerprints ON (scenes_files.file_id = files_fingerprints.file_id AND files_fingerprints.type = 'phash') + INNER JOIN video_files ON (files.id == video_files.file_id) +) +WHERE durationDiff <= ?1 + OR ?1 < 0 -- Always TRUE if the parameter is negative. + -- That will disable the durationDiff checking. +GROUP BY phash +HAVING COUNT(phash) > 1 + AND COUNT(DISTINCT scene_id) > 1 +ORDER BY SUM(file_size) DESC; ` var findAllPhashesQuery = ` -SELECT scenes.id as id, files_fingerprints.fingerprint as phash +SELECT scenes.id as id + , files_fingerprints.fingerprint as phash + , video_files.duration as duration FROM scenes -INNER JOIN scenes_files ON (scenes.id = scenes_files.scene_id) -INNER JOIN files ON (scenes_files.file_id = files.id) +INNER JOIN scenes_files ON (scenes.id = scenes_files.scene_id) +INNER JOIN files ON (scenes_files.file_id = files.id) INNER JOIN files_fingerprints ON (scenes_files.file_id = files_fingerprints.file_id AND files_fingerprints.type = 'phash') -ORDER BY files.size DESC +INNER JOIN video_files ON (files.id == video_files.file_id) +ORDER BY files.size DESC; ` type sceneRow struct { @@ -1729,11 +1744,11 @@ func (qb *SceneStore) GetStashIDs(ctx context.Context, sceneID int) ([]models.St return qb.stashIDRepository().get(ctx, sceneID) } -func (qb *SceneStore) FindDuplicates(ctx context.Context, distance int) ([][]*models.Scene, error) { +func (qb *SceneStore) FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*models.Scene, error) { var dupeIds [][]int if distance == 0 { var ids []string - if err := qb.tx.Select(ctx, &ids, findExactDuplicateQuery); err != nil { + if err := qb.tx.Select(ctx, &ids, findExactDuplicateQuery, durationDiff); err != nil { return nil, err } @@ -1755,7 +1770,8 @@ func (qb *SceneStore) FindDuplicates(ctx context.Context, distance int) ([][]*mo if err := qb.queryFunc(ctx, findAllPhashesQuery, nil, false, func(rows *sqlx.Rows) error { phash := utils.Phash{ - Bucket: -1, + Bucket: -1, + Duration: -1, } if err := rows.StructScan(&phash); err != nil { return err @@ -1767,7 +1783,7 @@ func (qb *SceneStore) FindDuplicates(ctx context.Context, distance int) ([][]*mo return nil, err } - dupeIds = utils.FindDuplicates(hashes, distance) + dupeIds = utils.FindDuplicates(hashes, distance, durationDiff) } var duplicates [][]*models.Scene diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index 560d3fcfc..137319c31 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -4237,7 +4237,8 @@ func TestSceneStore_FindDuplicates(t *testing.T) { withRollbackTxn(func(ctx context.Context) error { distance := 0 - got, err := qb.FindDuplicates(ctx, distance) + durationDiff := -1. + got, err := qb.FindDuplicates(ctx, distance, durationDiff) if err != nil { t.Errorf("SceneStore.FindDuplicates() error = %v", err) return nil @@ -4246,7 +4247,8 @@ func TestSceneStore_FindDuplicates(t *testing.T) { assert.Len(t, got, dupeScenePhashes) distance = 1 - got, err = qb.FindDuplicates(ctx, distance) + durationDiff = -1. + got, err = qb.FindDuplicates(ctx, distance, durationDiff) if err != nil { t.Errorf("SceneStore.FindDuplicates() error = %v", err) return nil diff --git a/pkg/utils/phash.go b/pkg/utils/phash.go index 7b15ec5e0..395d86f93 100644 --- a/pkg/utils/phash.go +++ b/pkg/utils/phash.go @@ -1,6 +1,7 @@ package utils import ( + "math" "strconv" "github.com/corona10/goimagehash" @@ -8,21 +9,28 @@ import ( ) type Phash struct { - SceneID int `db:"id"` - Hash int64 `db:"phash"` + SceneID int `db:"id"` + Hash int64 `db:"phash"` + Duration float64 `db:"duration"` Neighbors []int Bucket int } -func FindDuplicates(hashes []*Phash, distance int) [][]int { +func FindDuplicates(hashes []*Phash, distance int, durationDiff float64) [][]int { for i, scene := range hashes { sceneHash := goimagehash.NewImageHash(uint64(scene.Hash), goimagehash.PHash) for j, neighbor := range hashes { if i != j && scene.SceneID != neighbor.SceneID { - neighborHash := goimagehash.NewImageHash(uint64(neighbor.Hash), goimagehash.PHash) - neighborDistance, _ := sceneHash.Distance(neighborHash) - if neighborDistance <= distance { - scene.Neighbors = append(scene.Neighbors, j) + neighbourDurationDistance := 0. + if scene.Duration > 0 && neighbor.Duration > 0 { + neighbourDurationDistance = math.Abs(scene.Duration - neighbor.Duration) + } + if (neighbourDurationDistance <= durationDiff) || (durationDiff < 0) { + neighborHash := goimagehash.NewImageHash(uint64(neighbor.Hash), goimagehash.PHash) + neighborDistance, _ := sceneHash.Distance(neighborHash) + if neighborDistance <= distance { + scene.Neighbors = append(scene.Neighbors, j) + } } } } diff --git a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx index 882664d26..c45d1b293 100644 --- a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx +++ b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx @@ -41,6 +41,8 @@ import { objectTitle } from "src/core/files"; const CLASSNAME = "duplicate-checker"; +const defaultDurationDiff = "1"; + export const SceneDuplicateChecker: React.FC = () => { const intl = useIntl(); const history = useHistory(); @@ -49,6 +51,9 @@ export const SceneDuplicateChecker: React.FC = () => { const currentPage = Number.parseInt(query.get("page") ?? "1", 10); const pageSize = Number.parseInt(query.get("size") ?? "20", 10); const hashDistance = Number.parseInt(query.get("distance") ?? "0", 10); + const durationDiff = Number.parseFloat( + query.get("durationDiff") ?? defaultDurationDiff + ); const [currentPageSize, setCurrentPageSize] = useState(pageSize); const [isMultiDelete, setIsMultiDelete] = useState(false); @@ -59,7 +64,10 @@ export const SceneDuplicateChecker: React.FC = () => { ); const { data, loading, refetch } = GQL.useFindDuplicateScenesQuery({ fetchPolicy: "no-cache", - variables: { distance: hashDistance }, + variables: { + distance: hashDistance, + duration_diff: durationDiff, + }, }); const { data: missingPhash } = GQL.useFindScenesQuery({ variables: { @@ -480,45 +488,91 @@ export const SceneDuplicateChecker: React.FC = () => {

- - - - - - - - setQuery({ - distance: - e.currentTarget.value === "0" - ? undefined - : e.currentTarget.value, - page: undefined, - }) - } - defaultValue={hashDistance} - className="input-control ml-4" - > - - - - - - - - - - - +
+ + + + + + + + setQuery({ + distance: + e.currentTarget.value === "0" + ? undefined + : e.currentTarget.value, + page: undefined, + }) + } + defaultValue={hashDistance} + className="input-control ml-4" + > + + + + + + + + + + + + + + + + + + + + setQuery({ + durationDiff: + e.currentTarget.value === defaultDurationDiff + ? undefined + : e.currentTarget.value, + page: undefined, + }) + } + defaultValue={durationDiff} + className="input-control ml-4" + > + + + + + + + + + +
{maybeRenderMissingPhashWarning()} {renderPagination()} diff --git a/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss b/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss index 24084527a..9177a9367 100644 --- a/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss +++ b/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss @@ -10,4 +10,8 @@ .separator { height: 50px; } + + .form-group .row { + align-items: center; + } } diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 73f9a73e7..e049b1792 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -855,6 +855,11 @@ "donate": "Donate", "dupe_check": { "description": "Levels below 'Exact' can take longer to calculate. False positives might also be returned on lower accuracy levels.", + "duration_diff": "Maximum Duration Difference", + "duration_options": { + "any": "Any", + "equal": "Equal" + }, "found_sets": "{setCount, plural, one{# set of duplicates found.} other {# sets of duplicates found.}}", "options": { "exact": "Exact", @@ -1077,6 +1082,7 @@ "saved_filters": "Saved filters", "update_filter": "Update Filter" }, + "second": "Second", "seconds": "Seconds", "settings": "Settings", "setup": { From 79bc5c914fe24d36e057799679065977632b9e73 Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Wed, 3 May 2023 09:05:30 +0200 Subject: [PATCH 34/81] WallPanel refactor (#3686) --- .../Scenes/SceneDetails/SceneMarkersPanel.tsx | 11 +- ui/v2.5/src/components/Scenes/SceneList.tsx | 4 +- .../src/components/Scenes/SceneMarkerList.tsx | 4 +- ui/v2.5/src/components/Wall/WallItem.tsx | 227 ++++++++++-------- ui/v2.5/src/components/Wall/WallPanel.tsx | 119 +++++---- 5 files changed, 209 insertions(+), 156 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx index ed1f4d7c0..21e23af23 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx @@ -3,7 +3,7 @@ import { Button } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; -import { WallPanel } from "src/components/Wall/WallPanel"; +import { MarkerWallPanel } from "src/components/Wall/WallPanel"; import { PrimaryTags } from "./PrimaryTags"; import { SceneMarkerForm } from "./SceneMarkerForm"; @@ -77,11 +77,12 @@ export const SceneMarkersPanel: React.FC = ( onEdit={onOpenEditor} />
- { + { + e.preventDefault(); window.scrollTo(0, 0); - onClickMarker(marker as GQL.SceneMarkerDataFragment); + onClickMarker(marker); }} />
diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index ada2d69db..3156a8a25 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -14,7 +14,7 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { Tagger } from "../Tagger/scenes/SceneTagger"; import { IPlaySceneOptions, SceneQueue } from "src/models/sceneQueue"; -import { WallPanel } from "../Wall/WallPanel"; +import { SceneWallPanel } from "../Wall/WallPanel"; import { SceneListTable } from "./SceneListTable"; import { EditScenesDialog } from "./EditScenesDialog"; import { DeleteScenesDialog } from "./DeleteScenesDialog"; @@ -314,7 +314,7 @@ export const SceneList: React.FC = ({ } if (filter.displayMode === DisplayMode.Wall) { return ( - diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index af6635940..6de661671 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -12,7 +12,7 @@ import NavUtils from "src/utils/navigation"; import { makeItemList, PersistanceLevel } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; -import { WallPanel } from "../Wall/WallPanel"; +import { MarkerWallPanel } from "../Wall/WallPanel"; const SceneMarkerItemList = makeItemList({ filterMode: GQL.FilterMode.SceneMarkers, @@ -88,7 +88,7 @@ export const SceneMarkerList: React.FC = ({ if (filter.displayMode === DisplayMode.Wall) { return ( - + ); } } diff --git a/ui/v2.5/src/components/Wall/WallItem.tsx b/ui/v2.5/src/components/Wall/WallItem.tsx index 8f3555944..686a355cc 100644 --- a/ui/v2.5/src/components/Wall/WallItem.tsx +++ b/ui/v2.5/src/components/Wall/WallItem.tsx @@ -1,4 +1,11 @@ -import React, { useRef, useState, useEffect, useMemo } from "react"; +import React, { + useRef, + useState, + useEffect, + useCallback, + MouseEvent, + useMemo, +} from "react"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; @@ -9,18 +16,20 @@ import { ConfigurationContext } from "src/hooks/Config"; import { markerTitle } from "src/core/markers"; import { objectTitle } from "src/core/files"; -interface IWallItemProps { +export type WallItemType = keyof WallItemData; + +export type WallItemData = { + scene: GQL.SlimSceneDataFragment; + sceneMarker: GQL.SceneMarkerDataFragment; + image: GQL.SlimImageDataFragment; +}; + +interface IWallItemProps { + type: T; index?: number; - scene?: GQL.SlimSceneDataFragment; + data: WallItemData[T]; sceneQueue?: SceneQueue; - sceneMarker?: GQL.SceneMarkerDataFragment; - image?: GQL.SlimImageDataFragment; - clickHandler?: ( - item: - | GQL.SlimSceneDataFragment - | GQL.SceneMarkerDataFragment - | GQL.SlimImageDataFragment - ) => void; + clickHandler?: (e: MouseEvent, item: WallItemData[T]) => void; className: string; } @@ -31,26 +40,29 @@ interface IPreviews { } const Preview: React.FC<{ - previews?: IPreviews; + previews: IPreviews; config?: GQL.ConfigDataFragment; active: boolean; }> = ({ previews, config, active }) => { - const videoElement = useRef() as React.MutableRefObject; + const videoEl = useRef(null); const [isMissing, setIsMissing] = useState(false); const previewType = config?.interface?.wallPlayback; const soundOnPreview = config?.interface?.soundOnPreview ?? false; useEffect(() => { - if (!videoElement.current) return; - videoElement.current.muted = !(soundOnPreview && active); - if (previewType !== "video") { - if (active) videoElement.current.play(); - else videoElement.current.pause(); - } - }, [videoElement, previewType, soundOnPreview, active]); + const video = videoEl.current; + if (!video) return; - if (!previews) return
; + video.muted = !(soundOnPreview && active); + if (previewType !== "video") { + if (active) { + video.play(); + } else { + video.pause(); + } + } + }, [previewType, soundOnPreview, active]); const image = ( ); @@ -105,108 +117,123 @@ const Preview: React.FC<{ ); }; -export const WallItem: React.FC = (props: IWallItemProps) => { +export const WallItem = ({ + type, + index, + data, + sceneQueue, + clickHandler, + className, +}: IWallItemProps) => { const [active, setActive] = useState(false); - const wallItem = useRef() as React.MutableRefObject; + const itemEl = useRef(null); const { configuration: config } = React.useContext(ConfigurationContext); const showTextContainer = config?.interface.wallShowTitle ?? true; - const previews = props.sceneMarker - ? { - video: props.sceneMarker.stream, - animation: props.sceneMarker.preview, - image: props.sceneMarker.screenshot, - } - : props.scene - ? { - video: props.scene?.paths.preview ?? undefined, - animation: props.scene?.paths.webp ?? undefined, - image: props.scene?.paths.screenshot ?? undefined, - } - : props.image - ? { - image: props.image?.paths.thumbnail ?? undefined, - } - : undefined; + const previews = useMemo(() => { + switch (type) { + case "scene": + const scene = data as GQL.SlimSceneDataFragment; + return { + video: scene.paths.preview ?? undefined, + animation: scene.paths.webp ?? undefined, + image: scene.paths.screenshot ?? undefined, + }; + case "sceneMarker": + const sceneMarker = data as GQL.SceneMarkerDataFragment; + return { + video: sceneMarker.stream, + animation: sceneMarker.preview, + image: sceneMarker.screenshot, + }; + case "image": + const image = data as GQL.SlimImageDataFragment; + return { + image: image.paths.thumbnail ?? undefined, + }; + default: + // this is unreachable, inference fails for some reason + return type as never; + } + }, [type, data]); + const linkSrc = useMemo(() => { + switch (type) { + case "scene": + const scene = data as GQL.SlimSceneDataFragment; + return sceneQueue + ? sceneQueue.makeLink(scene.id, { sceneIndex: index }) + : `/scenes/${scene.id}`; + case "sceneMarker": + const sceneMarker = data as GQL.SceneMarkerDataFragment; + return NavUtils.makeSceneMarkerUrl(sceneMarker); + case "image": + const image = data as GQL.SlimImageDataFragment; + return `/images/${image.id}`; + default: + return type; + } + }, [type, data, sceneQueue, index]); + const title = useMemo(() => { + switch (type) { + case "scene": + const scene = data as GQL.SlimSceneDataFragment; + return objectTitle(scene); + case "sceneMarker": + const sceneMarker = data as GQL.SceneMarkerDataFragment; + const newTitle = markerTitle(sceneMarker); + const seconds = TextUtils.secondsToTimestamp(sceneMarker.seconds); + if (newTitle) { + return `${newTitle} - ${seconds}`; + } else { + return seconds; + } + case "image": + return ""; + default: + return type; + } + }, [type, data]); + const tags = useMemo(() => { + if (type === "sceneMarker") { + const sceneMarker = data as GQL.SceneMarkerDataFragment; + return [sceneMarker.primary_tag, ...sceneMarker.tags]; + } + }, [type, data]); const setInactive = () => setActive(false); - const toggleActive = (e: TransitionEvent) => { + const toggleActive = useCallback((e: TransitionEvent) => { if (e.propertyName === "transform" && e.elapsedTime === 0) { // Get the current scale of the wall-item. If it's smaller than 1.1 the item is being scaled up, otherwise down. - const matrixScale = getComputedStyle(wallItem.current).transform.match( + const matrixScale = getComputedStyle(itemEl.current!).transform.match( /-?\d+\.?\d+|\d+/g )?.[0]; const scale = Number.parseFloat(matrixScale ?? "2") || 2; - setActive(scale <= 1.1 && !active); + setActive((value) => scale <= 1.1 && !value); } - }; + }, []); useEffect(() => { - const { current } = wallItem; - current?.addEventListener("transitioncancel", setInactive); - current?.addEventListener("transitionstart", toggleActive); + const item = itemEl.current!; + item.addEventListener("transitioncancel", setInactive); + item.addEventListener("transitionstart", toggleActive); return () => { - current?.removeEventListener("transitioncancel", setInactive); - current?.removeEventListener("transitionstart", toggleActive); + item.removeEventListener("transitioncancel", setInactive); + item.removeEventListener("transitionstart", toggleActive); }; - }); + }, [toggleActive]); - const clickHandler = () => { - if (props.scene) { - props?.clickHandler?.(props.scene); - } - if (props.sceneMarker) { - props?.clickHandler?.(props.sceneMarker); - } - if (props.image) { - props?.clickHandler?.(props.image); - } + const onClick = (e: MouseEvent) => { + clickHandler?.(e, data); }; - const cont = config?.interface.continuePlaylistDefault ?? false; - - let linkSrc: string = "#"; - if (!props.clickHandler) { - if (props.scene) { - linkSrc = props.sceneQueue - ? props.sceneQueue.makeLink(props.scene.id, { - sceneIndex: props.index, - continue: cont, - }) - : `/scenes/${props.scene.id}`; - } else if (props.sceneMarker) { - linkSrc = NavUtils.makeSceneMarkerUrl(props.sceneMarker); - } else if (props.image) { - linkSrc = `/images/${props.image.id}`; - } - } - - const title = useMemo(() => { - if (props.sceneMarker) { - return `${markerTitle( - props.sceneMarker - )} - ${TextUtils.secondsToTimestamp(props.sceneMarker.seconds)}`; - } - - if (props.scene) { - return objectTitle(props.scene); - } - - return ""; - }, [props.sceneMarker, props.scene]); - const renderText = () => { if (!showTextContainer) return; - const tags = props.sceneMarker - ? [props.sceneMarker.primary_tag, ...props.sceneMarker.tags] - : []; - return (
{title}
- {tags.map((tag) => ( + {tags?.map((tag) => ( {tag.name} @@ -217,8 +244,8 @@ export const WallItem: React.FC = (props: IWallItemProps) => { return (
-
- +
+ {renderText()} diff --git a/ui/v2.5/src/components/Wall/WallPanel.tsx b/ui/v2.5/src/components/Wall/WallPanel.tsx index 2d7d5c932..91ee7607f 100644 --- a/ui/v2.5/src/components/Wall/WallPanel.tsx +++ b/ui/v2.5/src/components/Wall/WallPanel.tsx @@ -1,19 +1,13 @@ -import React from "react"; +import React, { MouseEvent } from "react"; import * as GQL from "src/core/generated-graphql"; import { SceneQueue } from "src/models/sceneQueue"; -import { WallItem } from "./WallItem"; +import { WallItem, WallItemData, WallItemType } from "./WallItem"; -interface IWallPanelProps { - scenes?: GQL.SlimSceneDataFragment[]; +interface IWallPanelProps { + type: T; + data: WallItemData[T][]; sceneQueue?: SceneQueue; - sceneMarkers?: GQL.SceneMarkerDataFragment[]; - images?: GQL.SlimImageDataFragment[]; - clickHandler?: ( - item: - | GQL.SlimSceneDataFragment - | GQL.SceneMarkerDataFragment - | GQL.SlimImageDataFragment - ) => void; + clickHandler?: (e: MouseEvent, item: WallItemData[T]) => void; } const calculateClass = (index: number, count: number) => { @@ -33,53 +27,84 @@ const calculateClass = (index: number, count: number) => { if (index % 5 === 4) return "transform-origin-right"; // Multiple of five if (index % 5 === 0) return "transform-origin-left"; - // Position is equal or larger than first postion in last row + // Position is equal or larger than first position in last row if (count - (count % 5 || 5) <= index + 1) return "transform-origin-bottom"; // Default return "transform-origin-center"; }; -export const WallPanel: React.FC = ( - props: IWallPanelProps -) => { - const scenes = (props.scenes ?? []).map((scene, index, sceneArray) => ( - - )); - - const sceneMarkers = (props.sceneMarkers ?? []).map( - (marker, index, markerArray) => ( +const WallPanel = ({ + type, + data, + sceneQueue, + clickHandler, +}: IWallPanelProps) => { + function renderItems() { + return data.map((item, index, arr) => ( - ) - ); - - const images = (props.images ?? []).map((image, index, imageArray) => ( - - )); + )); + } return (
- {scenes} - {sceneMarkers} - {images} + {renderItems()}
); }; + +interface IImageWallPanelProps { + images: GQL.SlimImageDataFragment[]; + clickHandler?: (e: MouseEvent, item: GQL.SlimImageDataFragment) => void; +} + +export const ImageWallPanel: React.FC = ({ + images, + clickHandler, +}) => { + return ; +}; + +interface IMarkerWallPanelProps { + markers: GQL.SceneMarkerDataFragment[]; + clickHandler?: (e: MouseEvent, item: GQL.SceneMarkerDataFragment) => void; +} + +export const MarkerWallPanel: React.FC = ({ + markers, + clickHandler, +}) => { + return ( + + ); +}; + +interface ISceneWallPanelProps { + scenes: GQL.SlimSceneDataFragment[]; + sceneQueue?: SceneQueue; + clickHandler?: (e: MouseEvent, item: GQL.SlimSceneDataFragment) => void; +} + +export const SceneWallPanel: React.FC = ({ + scenes, + sceneQueue, + clickHandler, +}) => { + return ( + + ); +}; From f3f7ee7fd28feafd3f3f0ccb345ad9a798fc47e7 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 4 May 2023 08:24:58 +1000 Subject: [PATCH 35/81] Fix cover generation error --- internal/manager/task_generate_screenshot.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/manager/task_generate_screenshot.go b/internal/manager/task_generate_screenshot.go index b3cd93e38..a245dcdca 100644 --- a/internal/manager/task_generate_screenshot.go +++ b/internal/manager/task_generate_screenshot.go @@ -21,17 +21,20 @@ func (t *GenerateCoverTask) GetDescription() string { } func (t *GenerateCoverTask) Start(ctx context.Context) { - if !t.required(ctx) { - return - } - scenePath := t.Scene.Path + var required bool if err := t.txnManager.WithReadTxn(ctx, func(ctx context.Context) error { + required = t.required(ctx) + return t.Scene.LoadPrimaryFile(ctx, t.txnManager.File) }); err != nil { logger.Error(err) } + + if !required { + return + } videoFile := t.Scene.Files.Primary() if videoFile == nil { @@ -89,6 +92,7 @@ func (t *GenerateCoverTask) Start(ctx context.Context) { } // required returns true if the sprite needs to be generated +// assumes in a transaction func (t *GenerateCoverTask) required(ctx context.Context) bool { if t.Scene.Path == "" { return false From b7d179e448b5f63326aa6a08bc840a5223bca756 Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Thu, 4 May 2023 05:33:39 +0200 Subject: [PATCH 36/81] Fix deceptive WEBM playback in Safari (#3676) * Fix babel deoptimization warning in vite dev server * Fix videojs HMR * Fix fake WEBM support in Safari --- .../components/ScenePlayer/ScenePlayer.tsx | 135 +++++++++--------- .../components/ScenePlayer/source-selector.ts | 24 ++-- .../src/components/ScenePlayer/styles.scss | 11 +- .../components/Scenes/SceneDetails/Scene.tsx | 1 - ui/v2.5/vite.config.js | 6 +- 5 files changed, 95 insertions(+), 82 deletions(-) diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 1a4c5e87d..bb04eec3f 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -1,5 +1,6 @@ import React, { KeyboardEvent, + useCallback, useContext, useEffect, useMemo, @@ -159,7 +160,6 @@ function getMarkerTitle(marker: MarkerFragment) { } interface IScenePlayerProps { - className?: string; scene: GQL.SceneDataFragment | undefined | null; hideScrubberOverride: boolean; autoplay?: boolean; @@ -172,7 +172,6 @@ interface IScenePlayerProps { } export const ScenePlayer: React.FC = ({ - className, scene, hideScrubberOverride, autoplay, @@ -186,15 +185,14 @@ export const ScenePlayer: React.FC = ({ const { configuration } = useContext(ConfigurationContext); const interfaceConfig = configuration?.interface; const uiConfig = configuration?.ui as IUIConfig | undefined; - const videoRef = useRef(null); - const playerRef = useRef(); + const videoRef = useRef(null); + const [_player, setPlayer] = useState(); const sceneId = useRef(); const [sceneSaveActivity] = useSceneSaveActivity(); const [sceneIncrementPlayCount] = useSceneIncrementPlayCount(); const [time, setTime] = useState(0); const [ready, setReady] = useState(false); - const [sessionInitialised, setSessionInitialised] = useState(false); // tracks play session. This is reset whenever ScenePlayer page is exited const { interactive: interactiveClient, @@ -230,6 +228,12 @@ export const ScenePlayer: React.FC = ({ [file, permitLoop, maxLoopDuration] ); + const getPlayer = useCallback(() => { + if (!_player) return null; + if (_player.isDisposed()) return null; + return _player; + }, [_player]); + useEffect(() => { if (hideScrubberOverride || fullscreen) { setShowScrubber(false); @@ -249,18 +253,19 @@ export const ScenePlayer: React.FC = ({ useEffect(() => { sendSetTimestamp((value: number) => { - const player = playerRef.current; + const player = getPlayer(); if (player && value >= 0) { player.play()?.then(() => { player.currentTime(value); }); } }); - }, [sendSetTimestamp]); + }, [sendSetTimestamp, getPlayer]); // Initialize VideoJS player useEffect(() => { const options: VideoJsPlayerOptions = { + id: VIDEO_PLAYER_ID, controls: true, controlBar: { pictureInPictureToggle: false, @@ -292,6 +297,7 @@ export const ScenePlayer: React.FC = ({ playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], inactivityTimeout: 2000, preload: "none", + playsinline: true, userActions: { hotkeys: function (this: VideoJsPlayer, event) { handleHotkeys(this, event); @@ -314,33 +320,42 @@ export const ScenePlayer: React.FC = ({ }, }; - const player = videojs(videoRef.current!, options); + const videoEl = document.createElement("video-js"); + videoEl.setAttribute("data-vjs-player", "true"); + videoEl.classList.add("vjs-big-play-centered"); + videoRef.current!.appendChild(videoEl); + + const vjs = videojs(videoEl, options); /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - const settings = (player as any).textTrackSettings; + const settings = (vjs as any).textTrackSettings; settings.setValues({ backgroundColor: "#000", backgroundOpacity: "0.5", }); settings.updateDisplay(); - player.focus(); - playerRef.current = player; + vjs.focus(); + setPlayer(vjs); // Video player destructor return () => { - playerRef.current = undefined; - player.dispose(); + vjs.dispose(); + videoEl.remove(); + setPlayer(undefined); + + // reset sceneId to force reload sources + sceneId.current = undefined; }; }, []); useEffect(() => { - const player = playerRef.current; + const player = getPlayer(); if (!player) return; const skipButtons = player.skipButtons(); skipButtons.setForwardHandler(onNext); skipButtons.setBackwardHandler(onPrevious); - }, [onNext, onPrevious]); + }, [getPlayer, onNext, onPrevious]); useEffect(() => { if (scene?.interactive && interactiveInitialised) { @@ -358,6 +373,9 @@ export const ScenePlayer: React.FC = ({ // Player event handlers useEffect(() => { + const player = getPlayer(); + if (!player) return; + function canplay(this: VideoJsPlayer) { if (initialTimestamp.current !== -1) { this.currentTime(initialTimestamp.current); @@ -381,9 +399,6 @@ export const ScenePlayer: React.FC = ({ setFullscreen(this.isFullscreen()); } - const player = playerRef.current; - if (!player) return; - player.on("canplay", canplay); player.on("playing", playing); player.on("loadstart", loadstart); @@ -395,9 +410,12 @@ export const ScenePlayer: React.FC = ({ player.off("loadstart", loadstart); player.off("fullscreenchange", fullscreenchange); }; - }, []); + }, [getPlayer]); useEffect(() => { + const player = getPlayer(); + if (!player) return; + function onplay(this: VideoJsPlayer) { this.persistVolume().enabled = true; if (scene?.interactive && interactiveReady.current) { @@ -424,9 +442,6 @@ export const ScenePlayer: React.FC = ({ setTime(this.currentTime()); } - const player = playerRef.current; - if (!player) return; - player.on("play", onplay); player.on("pause", pause); player.on("seeking", seeking); @@ -438,26 +453,22 @@ export const ScenePlayer: React.FC = ({ player.off("seeking", seeking); player.off("timeupdate", timeupdate); }; - }, [interactiveClient, scene]); + }, [getPlayer, interactiveClient, scene]); useEffect(() => { - const player = playerRef.current; + const player = getPlayer(); if (!player) return; // don't re-initialise the player unless the scene has changed if (!scene || !file || scene.id === sceneId.current) return; - // if new scene was picked from playlist - if (playerRef.current && sceneId.current) { - if (trackActivity) { - playerRef.current.trackActivity().reset(); - } - } - sceneId.current = scene.id; setReady(false); + // reset on new scene + player.trackActivity().reset(); + // always stop the interactive client on initialisation interactiveClient.pause(); interactiveReady.current = false; @@ -546,19 +557,19 @@ export const ScenePlayer: React.FC = ({ const alwaysStartFromBeginning = uiConfig?.alwaysStartFromBeginning ?? false; + const resumeTime = scene.resume_time ?? 0; let startPosition = _initialTimestamp; if ( !startPosition && - !(alwaysStartFromBeginning || sessionInitialised) && - file.duration > scene.resume_time! + !alwaysStartFromBeginning && + file.duration > resumeTime ) { - startPosition = scene.resume_time!; + startPosition = resumeTime; } initialTimestamp.current = startPosition; setTime(startPosition); - setSessionInitialised(true); player.load(); player.focus(); @@ -574,11 +585,10 @@ export const ScenePlayer: React.FC = ({ interactiveClient.pause(); }; }, [ + getPlayer, file, scene, - trackActivity, interactiveClient, - sessionInitialised, autoplay, interfaceConfig?.autostartVideo, uiConfig?.alwaysStartFromBeginning, @@ -586,7 +596,7 @@ export const ScenePlayer: React.FC = ({ ]); useEffect(() => { - const player = playerRef.current; + const player = getPlayer(); if (!player || !scene) return; const markers = player.markers(); @@ -603,10 +613,10 @@ export const ScenePlayer: React.FC = ({ } else { player.poster(""); } - }, [scene]); + }, [getPlayer, scene]); useEffect(() => { - const player = playerRef.current; + const player = getPlayer(); if (!player) return; async function saveActivity(resumeTime: number, playDuration: number) { @@ -637,6 +647,7 @@ export const ScenePlayer: React.FC = ({ activity.minimumPlayPercent = minimumPlayPercent; activity.setEnabled(trackActivity); }, [ + getPlayer, scene, trackActivity, minimumPlayPercent, @@ -645,15 +656,16 @@ export const ScenePlayer: React.FC = ({ ]); useEffect(() => { - const player = playerRef.current; + const player = getPlayer(); if (!player) return; player.loop(looping); interactiveClient.setLooping(looping); - }, [interactiveClient, looping]); + }, [getPlayer, interactiveClient, looping]); useEffect(() => { - if (!scene || !ready || !auto.current) { + const player = getPlayer(); + if (!player || !scene || !ready || !auto.current) { return; } @@ -666,9 +678,6 @@ export const ScenePlayer: React.FC = ({ return; } - const player = playerRef.current; - if (!player) return; - player.play()?.catch(() => { // Browser probably blocking non-muted autoplay, so mute and try again player.persistVolume().enabled = false; @@ -677,35 +686,36 @@ export const ScenePlayer: React.FC = ({ player.play(); }); auto.current = false; - }, [scene, ready, interactiveClient, currentScript]); + }, [getPlayer, scene, ready, interactiveClient, currentScript]); + // Attach handler for onComplete event useEffect(() => { - // Attach handler for onComplete event - const player = playerRef.current; + const player = getPlayer(); if (!player) return; player.on("ended", onComplete); return () => player.off("ended"); - }, [onComplete]); + }, [getPlayer, onComplete]); - const onScrubberScroll = () => { + function onScrubberScroll() { if (started.current) { - playerRef.current?.pause(); + getPlayer()?.pause(); } - }; - const onScrubberSeek = (seconds: number) => { + } + + function onScrubberSeek(seconds: number) { if (started.current) { - playerRef.current?.currentTime(seconds); + getPlayer()?.currentTime(seconds); } else { initialTimestamp.current = seconds; setTime(seconds); } - }; + } // Override spacebar to always pause/play function onKeyDown(this: HTMLDivElement, event: KeyboardEvent) { - const player = playerRef.current; + const player = getPlayer(); if (!player) return; if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { @@ -730,17 +740,10 @@ export const ScenePlayer: React.FC = ({ className={cx("VideoPlayer", { portrait: isPortrait })} onKeyDownCapture={onKeyDown} > -
-
+
{scene?.interactive && (interactiveState !== ConnectionState.Ready || - playerRef.current?.paused()) && } + getPlayer()?.paused()) && } {scene && file && showScrubber && ( src === source); if (this.selectedIndex === -1) return; + const loadSrc = this.sources[this.selectedIndex]; + const currentTime = player.currentTime(); - - // put the selected source at the top of the list - const loadSources = [...this.sources]; - const selectedSrc = loadSources.splice(this.selectedIndex, 1)[0]; - loadSources.unshift(selectedSrc); - const paused = player.paused(); - player.src(loadSources); + + player.src(loadSrc); player.one("canplay", () => { if (paused) { player.pause(); @@ -128,10 +125,15 @@ class SourceSelectorPlugin extends videojs.getPlugin("plugin") { if (!player.videoWidth() && !player.videoHeight()) { // Occurs during preload when videos with supported audio/unsupported video are preloaded. // Treat this as a decoding error and try the next source without playing. - // However on Safari we get an media event when m3u8 is loaded which needs to be ignored. + // However on Safari we get an media event when m3u8 or mpd is loaded which needs to be ignored. if (player.error() !== null) return; + const currentSrc = player.currentSrc(); - if (currentSrc !== null && !currentSrc.includes(".m3u8")) { + if (currentSrc === null) return; + + if (currentSrc.includes(".m3u8") || currentSrc.includes(".mpd")) { + player.play(); + } else { player.error(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED); return; } @@ -156,7 +158,7 @@ class SourceSelectorPlugin extends videojs.getPlugin("plugin") { console.log(`Trying next source in playlist: '${newSource.label}'`); this.menu.setSources(this.sources); this.selectedIndex = 0; - player.src(this.sources); + player.src(newSource); player.load(); player.play(); } else { @@ -179,7 +181,7 @@ class SourceSelectorPlugin extends videojs.getPlugin("plugin") { } this.sources = sources; - this.player.src(this.sources); + this.player.src(sources[0]); } get textTracks(): HTMLTrackElement[] { diff --git a/ui/v2.5/src/components/ScenePlayer/styles.scss b/ui/v2.5/src/components/ScenePlayer/styles.scss index 010ed1dcc..c8bee39ea 100644 --- a/ui/v2.5/src/components/ScenePlayer/styles.scss +++ b/ui/v2.5/src/components/ScenePlayer/styles.scss @@ -16,12 +16,12 @@ $sceneTabWidth: 450px; height: 100vh; } - &.portrait .video-js { + &.portrait .video-wrapper { height: 177.78vw; } } -.video-js { +.video-wrapper { height: 56.25vw; overflow: hidden; width: 100%; @@ -29,6 +29,11 @@ $sceneTabWidth: 450px; @media (min-width: 1200px) { height: 100%; } +} + +.video-js { + height: 100%; + width: 100%; .vjs-button { outline: none; @@ -109,7 +114,7 @@ $sceneTabWidth: 450px; width: 100%; .vjs-progress-holder { - margin: 0 1rem; + margin: 0 15px; } } diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 847237cd6..5d6bb3690 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -765,7 +765,6 @@ const SceneLoader: React.FC = () => {
{ let plugins = [ - react(), + react({ + babel: { + compact: true, + }, + }), tsconfigPaths(), viteCompression({ algorithm: "gzip", From b1c61d2846d13ae5ccdcde4baefeab1f411bc255 Mon Sep 17 00:00:00 2001 From: Flashy78 <90150289+Flashy78@users.noreply.github.com> Date: Wed, 3 May 2023 21:13:35 -0700 Subject: [PATCH 37/81] Identify: Select existing value on edit (#3696) * Select field option on edit * Fix create missing display --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- .../Dialogs/IdentifyDialog/FieldOptions.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/ui/v2.5/src/components/Dialogs/IdentifyDialog/FieldOptions.tsx b/ui/v2.5/src/components/Dialogs/IdentifyDialog/FieldOptions.tsx index ba027cd5c..68a31fe6b 100644 --- a/ui/v2.5/src/components/Dialogs/IdentifyDialog/FieldOptions.tsx +++ b/ui/v2.5/src/components/Dialogs/IdentifyDialog/FieldOptions.tsx @@ -96,17 +96,13 @@ const FieldOptionsEditor: React.FC = ({ }); } - if (!localOptions) { - return <>; - } - return ( {allowSetDefault ? ( setLocalOptions({ ...localOptions, @@ -122,7 +118,7 @@ const FieldOptionsEditor: React.FC = ({ type="radio" key={f[0]} id={`${field}-strategy-${f[0]}`} - checked={localOptions.strategy === f[1]} + checked={strategy === f[1]} onChange={() => setLocalOptions({ ...localOptions, @@ -168,7 +164,9 @@ const FieldOptionsEditor: React.FC = ({ (f) => f.field === localOptions.field )?.createMissing; - if (localOptions.strategy === undefined) { + // if allowSetDefault is false, then strategy is considered merge + // if its true, then its using the default value and should not be shown here + if (localOptions.strategy === undefined && allowSetDefault) { return; } From 39ebd92e60c467e70c6746d0fb60787ec679a502 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 4 May 2023 14:23:23 +1000 Subject: [PATCH 38/81] Format --- internal/manager/task_generate_screenshot.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/manager/task_generate_screenshot.go b/internal/manager/task_generate_screenshot.go index a245dcdca..384d8740c 100644 --- a/internal/manager/task_generate_screenshot.go +++ b/internal/manager/task_generate_screenshot.go @@ -26,12 +26,12 @@ func (t *GenerateCoverTask) Start(ctx context.Context) { var required bool if err := t.txnManager.WithReadTxn(ctx, func(ctx context.Context) error { required = t.required(ctx) - + return t.Scene.LoadPrimaryFile(ctx, t.txnManager.File) }); err != nil { logger.Error(err) } - + if !required { return } From 242f61b5df7ceeb8c4c2a26d53c311dc97bced48 Mon Sep 17 00:00:00 2001 From: dogwithakeyboard <128322708+dogwithakeyboard@users.noreply.github.com> Date: Thu, 4 May 2023 06:03:09 +0100 Subject: [PATCH 39/81] Lightbox movie covers (#3705) * movie page lightbox * Use styling instead of bootstrap classes --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- .../components/Movies/MovieDetails/Movie.tsx | 61 +++++++++++++++++-- ui/v2.5/src/components/Movies/styles.scss | 1 + ui/v2.5/src/components/Performers/styles.scss | 12 +++- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx index a9813698d..4e723858c 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx @@ -1,4 +1,5 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; +import { Button } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { Helmet } from "react-helmet"; import Mousetrap from "mousetrap"; @@ -12,6 +13,7 @@ import { useParams, useHistory } from "react-router-dom"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; +import { useLightbox } from "src/hooks/Lightbox/hooks"; import { ModalComponent } from "src/components/Shared/Modal"; import { useToast } from "src/hooks/Toast"; import { MovieScenesPanel } from "./MovieScenesPanel"; @@ -37,6 +39,43 @@ const MoviePage: React.FC = ({ movie }) => { const [backImage, setBackImage] = useState(); const [encodingImage, setEncodingImage] = useState(false); + const defaultImage = + movie.front_image_path && movie.front_image_path.includes("default=true") + ? true + : false; + + const lightboxImages = useMemo(() => { + const covers = [ + ...(movie.front_image_path && !defaultImage + ? [ + { + paths: { + thumbnail: movie.front_image_path, + image: movie.front_image_path, + }, + }, + ] + : []), + ...(movie.back_image_path + ? [ + { + paths: { + thumbnail: movie.back_image_path, + image: movie.back_image_path, + }, + }, + ] + : []), + ]; + return covers; + }, [movie.front_image_path, movie.back_image_path, defaultImage]); + + const index = lightboxImages.length; + + const showLightbox = useLightbox({ + images: lightboxImages, + }); + const [updateMovie, { loading: updating }] = useMovieUpdate(); const [deleteMovie, { loading: deleting }] = useMovieDestroy({ id: movie.id, @@ -129,12 +168,22 @@ const MoviePage: React.FC = ({ movie }) => { } } - if (image) { + if (image && defaultImage) { return (
Front Cover
); + } else if (image) { + return ( + + ); } } @@ -150,9 +199,13 @@ const MoviePage: React.FC = ({ movie }) => { if (image) { return ( -
+
+ ); } } diff --git a/ui/v2.5/src/components/Movies/styles.scss b/ui/v2.5/src/components/Movies/styles.scss index 09cfa97da..1a8d64e2a 100644 --- a/ui/v2.5/src/components/Movies/styles.scss +++ b/ui/v2.5/src/components/Movies/styles.scss @@ -32,6 +32,7 @@ max-width: 100%; .movie-image-container { + box-shadow: none; margin: 1rem; } diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 7bc932363..bef4fa0eb 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -1,7 +1,13 @@ #performer-page { - .performer-image-container .performer { - max-height: calc(100vh - 6rem); - max-width: 100%; + .performer-image-container { + .btn { + box-shadow: none; + } + + .performer { + max-height: calc(100vh - 6rem); + max-width: 100%; + } } .content-container { From ca45c391da31cd9bf03b78662212dd7796034085 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 5 May 2023 09:39:09 +1000 Subject: [PATCH 40/81] Include missing fields in performer batch tag (#3718) --- internal/manager/task_stash_box_tag.go | 50 ++++++++++++++++---------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index 886da242f..e927a0335 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -119,24 +119,28 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { aliases = []string{} } newPerformer := models.Performer{ - Aliases: models.NewRelatedStrings(aliases), - Birthdate: getDate(performer.Birthdate), - CareerLength: getString(performer.CareerLength), - Country: getString(performer.Country), - CreatedAt: currentTime, - Ethnicity: getString(performer.Ethnicity), - EyeColor: getString(performer.EyeColor), - FakeTits: getString(performer.FakeTits), - Gender: models.GenderEnum(getString(performer.Gender)), - Height: getIntPtr(performer.Height), - Weight: getIntPtr(performer.Weight), - Instagram: getString(performer.Instagram), - Measurements: getString(performer.Measurements), - Name: *performer.Name, - Piercings: getString(performer.Piercings), - Tattoos: getString(performer.Tattoos), - Twitter: getString(performer.Twitter), - URL: getString(performer.URL), + Aliases: models.NewRelatedStrings(aliases), + Disambiguation: getString(performer.Disambiguation), + Details: getString(performer.Details), + Birthdate: getDate(performer.Birthdate), + DeathDate: getDate(performer.DeathDate), + CareerLength: getString(performer.CareerLength), + Country: getString(performer.Country), + CreatedAt: currentTime, + Ethnicity: getString(performer.Ethnicity), + EyeColor: getString(performer.EyeColor), + HairColor: getString(performer.HairColor), + FakeTits: getString(performer.FakeTits), + Gender: models.GenderEnum(getString(performer.Gender)), + Height: getIntPtr(performer.Height), + Weight: getIntPtr(performer.Weight), + Instagram: getString(performer.Instagram), + Measurements: getString(performer.Measurements), + Name: *performer.Name, + Piercings: getString(performer.Piercings), + Tattoos: getString(performer.Tattoos), + Twitter: getString(performer.Twitter), + URL: getString(performer.URL), StashIDs: models.NewRelatedStashIDs([]models.StashID{ { Endpoint: t.box.Endpoint, @@ -192,6 +196,10 @@ func (t *StashBoxPerformerTagTask) getPartial(performer *models.ScrapedPerformer value := getDate(performer.Birthdate) partial.Birthdate = models.NewOptionalDate(*value) } + if performer.DeathDate != nil && *performer.DeathDate != "" && !excluded["deathdate"] { + value := getDate(performer.DeathDate) + partial.Birthdate = models.NewOptionalDate(*value) + } if performer.CareerLength != nil && !excluded["career_length"] { partial.CareerLength = models.NewOptionalString(*performer.CareerLength) } @@ -204,6 +212,9 @@ func (t *StashBoxPerformerTagTask) getPartial(performer *models.ScrapedPerformer if performer.EyeColor != nil && !excluded["eye_color"] { partial.EyeColor = models.NewOptionalString(*performer.EyeColor) } + if performer.HairColor != nil && !excluded["hair_color"] { + partial.HairColor = models.NewOptionalString(*performer.HairColor) + } if performer.FakeTits != nil && !excluded["fake_tits"] { partial.FakeTits = models.NewOptionalString(*performer.FakeTits) } @@ -231,6 +242,9 @@ func (t *StashBoxPerformerTagTask) getPartial(performer *models.ScrapedPerformer if excluded["name"] && performer.Name != nil { partial.Name = models.NewOptionalString(*performer.Name) } + if performer.Disambiguation != nil && !excluded["disambiguation"] { + partial.Disambiguation = models.NewOptionalString(*performer.Disambiguation) + } if performer.Piercings != nil && !excluded["piercings"] { partial.Piercings = models.NewOptionalString(*performer.Piercings) } From c77ff8989b5061d7c6b8155a6448ba2e0f7482ca Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 5 May 2023 09:39:28 +1000 Subject: [PATCH 41/81] Include precision in rating star classname (#3719) --- ui/v2.5/src/components/Shared/Rating/RatingStars.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Shared/Rating/RatingStars.tsx b/ui/v2.5/src/components/Shared/Rating/RatingStars.tsx index 99e2e5be6..d50700ce7 100644 --- a/ui/v2.5/src/components/Shared/Rating/RatingStars.tsx +++ b/ui/v2.5/src/components/Shared/Rating/RatingStars.tsx @@ -240,8 +240,10 @@ export const RatingStars: React.FC = ( ); }; + const precisionClassName = `rating-stars-precision-${props.precision}`; + return ( -
+
{Array.from(Array(max)).map((value, index) => renderRatingButton(index + 1) )} From 490a2aca0880eb317077a8bf477676a99b71b6f9 Mon Sep 17 00:00:00 2001 From: Robin <132836850+robinyoublind2@users.noreply.github.com> Date: Tue, 9 May 2023 20:04:20 -0500 Subject: [PATCH 42/81] Log warning when library overlaps generated folder in scan (#3725) --- internal/manager/task_scan.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index 43d264c22..02ebfbc30 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -246,6 +246,7 @@ func newScanFilter(c *config.Instance, minModTime time.Time) *scanFilter { func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) bool { if fsutil.IsPathInDir(f.generatedPath, path) { + logger.Warnf("Skipping %q as it overlaps with the generated folder", path) return false } From e7abeeb4df663cbc276a1e205a23b88c07b022df Mon Sep 17 00:00:00 2001 From: CJ <72030708+Teda1@users.noreply.github.com> Date: Tue, 9 May 2023 18:06:58 -0700 Subject: [PATCH 43/81] fixes scene card width on front page for mobile (#3724) --- ui/v2.5/src/components/FrontPage/styles.scss | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/v2.5/src/components/FrontPage/styles.scss b/ui/v2.5/src/components/FrontPage/styles.scss index a1661d032..e4049b5aa 100644 --- a/ui/v2.5/src/components/FrontPage/styles.scss +++ b/ui/v2.5/src/components/FrontPage/styles.scss @@ -306,17 +306,17 @@ } @media (max-width: 576px) { - .slick-list .scene-card, - .slick-list .studio-card, - .slick-list .gallery-card { + .slick-list .scene-card.card, + .slick-list .studio-card.card, + .slick-list .gallery-card.card { width: 20rem; } - .slick-list .movie-card { + .slick-list .movie-card.card { width: 16rem; } - .slick-list .performer-card { + .slick-list .performer-card.card { width: 16rem; } From 61c0098ae6c1e4971373fbcc9767cff6de83e8a9 Mon Sep 17 00:00:00 2001 From: puc9 <51006296+puc9@users.noreply.github.com> Date: Tue, 9 May 2023 18:16:49 -0700 Subject: [PATCH 44/81] Close input file so SafeMove can delete it (#3714) * Close input file so SafeMove can delete it This is happening on Windows and over the network but at the end of SafeMove it fails the move with an error that it can't remove the input because it is in use. It turns out it is in use by the SafeMove itself :) * Copy the src file mod time --- pkg/fsutil/file.go | 62 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/pkg/fsutil/file.go b/pkg/fsutil/file.go index 7d91679fe..1bf982666 100644 --- a/pkg/fsutil/file.go +++ b/pkg/fsutil/file.go @@ -11,29 +11,55 @@ import ( "github.com/stashapp/stash/pkg/logger" ) +// CopyFile copies the contents of the file at srcpath to a regular file at dstpath. +// It will copy the last modified timestamp +// If dstpath already exists the function will fail. +func CopyFile(srcpath, dstpath string) (err error) { + r, err := os.Open(srcpath) + if err != nil { + return err + } + + w, err := os.OpenFile(dstpath, os.O_CREATE|os.O_EXCL, 0666) + if err != nil { + r.Close() // We need to close the input file as the defer below would not be called. + return err + } + + defer func() { + r.Close() // ok to ignore error: file was opened read-only. + e := w.Close() + // Report the error from w.Close, if any. + // But do so only if there isn't already an outgoing error. + if e != nil && err == nil { + err = e + } + // Copy modified time + if err == nil { + // io.Copy succeeded, we should fix the dstpath timestamp + srcFileInfo, e := os.Stat(srcpath) + if e != nil { + err = e + return + } + + e = os.Chtimes(dstpath, srcFileInfo.ModTime(), srcFileInfo.ModTime()) + if e != nil { + err = e + } + } + }() + + _, err = io.Copy(w, r) + return err +} + // SafeMove attempts to move the file with path src to dest using os.Rename. If this fails, then it copies src to dest, then deletes src. func SafeMove(src, dst string) error { err := os.Rename(src, dst) if err != nil { - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - - out, err := os.Create(dst) - if err != nil { - return err - } - defer out.Close() - - _, err = io.Copy(out, in) - if err != nil { - return err - } - - err = out.Close() + err = CopyFile(src, dst) if err != nil { return err } From 0069c48e7e9298936dc5494865d0a4f674d6b29a Mon Sep 17 00:00:00 2001 From: yoshnopa <40072150+yoshnopa@users.noreply.github.com> Date: Wed, 10 May 2023 03:37:01 +0200 Subject: [PATCH 45/81] Folder Gallery creation on a per folder basis (#3715) * GalleryInExClusion // Create Gallery from folder based on file, short description in setting * GalleryInExClusion // No Folderiteration, expansion of docs * GalleryInExClusion // Only accept lowercase files * GalleryInExClusion // Correct text in settings --- pkg/image/scan.go | 18 +++++++++++++++++- ui/v2.5/src/docs/en/Manual/Configuration.md | 12 ++++++++++++ ui/v2.5/src/locales/en-GB.json | 4 ++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/pkg/image/scan.go b/pkg/image/scan.go index 4c5280f6b..20bd609dc 100644 --- a/pkg/image/scan.go +++ b/pkg/image/scan.go @@ -307,7 +307,23 @@ func (h *ScanHandler) getOrCreateGallery(ctx context.Context, f file.File) (*mod return h.getOrCreateZipBasedGallery(ctx, f.Base().ZipFile) } - if h.ScanConfig.GetCreateGalleriesFromFolders() { + // Look for specific filename in Folder to find out if the Folder is marked to be handled differently as the setting + folderPath := filepath.Dir(f.Base().Path) + + forceGallery := false + if _, err := os.Stat(filepath.Join(folderPath, ".forcegallery")); err == nil { + forceGallery = true + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("Could not test Path %s: %w", folderPath, err) + } + exemptGallery := false + if _, err := os.Stat(filepath.Join(folderPath, ".nogallery")); err == nil { + exemptGallery = true + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("Could not test Path %s: %w", folderPath, err) + } + + if forceGallery || (h.ScanConfig.GetCreateGalleriesFromFolders() && !exemptGallery) { return h.getOrCreateFolderBasedGallery(ctx, f) } diff --git a/ui/v2.5/src/docs/en/Manual/Configuration.md b/ui/v2.5/src/docs/en/Manual/Configuration.md index 99b00f219..ee5cd131a 100644 --- a/ui/v2.5/src/docs/en/Manual/Configuration.md +++ b/ui/v2.5/src/docs/en/Manual/Configuration.md @@ -36,6 +36,18 @@ exclude: _a useful [link](https://regex101.com/) to experiment with regexps_ +## Gallery Creation from Folders + +In the Library section you can find an option to create a gallery from each folder containing images. This will be applied on all libraries when activated, including the base folder of a library. + +If you wish to apply this on a per folder basis, you can create a file called **.nogallery** or **.forcegallery** in a folder that should act different than this global setting. + +This will either exclude the folder from becoming a gallery even if the setting is set, or create a gallery from the folder even if the setting is not set. + +The file will only be recognized if written in lower case letters. + +Files with a dot in front are handled as hidden in the Linux OS and Mac OS, so you will not see those files after creation on your system without setting your file manager accordingly. + ## Hashing algorithms Stash identifies video files by calculating a hash of the file. There are two algorithms available for hashing: `oshash` and `MD5`. `MD5` requires reading the entire file, and can therefore be slow, particularly when reading files over a network. `oshash` (which uses OpenSubtitle's hashing algorithm) only reads 64k from each end of the file. diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index e049b1792..7226bd4ba 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -283,7 +283,7 @@ "check_for_insecure_certificates_desc": "Some sites use insecure ssl certificates. When unticked the scraper skips the insecure certificates check and allows scraping of those sites. If you get a certificate error when scraping untick this.", "chrome_cdp_path": "Chrome CDP path", "chrome_cdp_path_desc": "File path to the Chrome executable, or a remote address (starting with http:// or https://, for example http://localhost:9222/json/version) to a Chrome instance.", - "create_galleries_from_folders_desc": "If true, creates galleries from folders containing images.", + "create_galleries_from_folders_desc": "If true, creates galleries from folders containing images by default. Create a File called .forcegallery or .nogallery in a folder to enforce/prevent this.", "create_galleries_from_folders_label": "Create galleries from folders containing images", "database": "Database", "db_path_head": "Database Path", @@ -1243,4 +1243,4 @@ "weight_kg": "Weight (kg)", "years_old": "years old", "zip_file_count": "Zip File Count" -} \ No newline at end of file +} From 0e199a525f3991b55f44e581cb28b80b35130703 Mon Sep 17 00:00:00 2001 From: yoshnopa <40072150+yoshnopa@users.noreply.github.com> Date: Tue, 16 May 2023 02:26:35 +0200 Subject: [PATCH 46/81] ChapterBug // Fix jump to wrong page if chapter number if (number - 1) % pagelength = 0 (#3730) --- ui/v2.5/src/hooks/Lightbox/Lightbox.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index ca79805d7..bae92ab0c 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -466,7 +466,7 @@ export const LightboxComponent: React.FC = ({ function gotoPage(imageIndex: number) { const indexInPage = (imageIndex - 1) % pageSize; if (pageCallback) { - let jumppage = Math.floor(imageIndex / pageSize) + 1; + let jumppage = Math.floor((imageIndex - 1) / pageSize) + 1; if (page !== jumppage) { pageCallback({ page: jumppage }); oldImages.current = images; From a2e477e1a753fc1852665f459685a8070d01c415 Mon Sep 17 00:00:00 2001 From: yoshnopa <40072150+yoshnopa@users.noreply.github.com> Date: Wed, 17 May 2023 01:30:51 +0200 Subject: [PATCH 47/81] Support image clips/gifs (#3583) Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/documents/data/config.graphql | 3 + graphql/documents/data/file.graphql | 44 +++++- graphql/documents/data/image-slim.graphql | 5 + graphql/documents/data/image.graphql | 5 + graphql/schema/types/config.graphql | 4 + graphql/schema/types/file.graphql | 4 +- graphql/schema/types/image.graphql | 8 +- graphql/schema/types/metadata.graphql | 6 + internal/api/resolver_model_image.go | 108 +++++++++----- internal/api/resolver_model_scene.go | 54 +++---- internal/api/resolver_mutation_configure.go | 4 + internal/api/resolver_mutation_image.go | 4 +- internal/api/resolver_query_configuration.go | 1 + internal/api/routes_image.go | 21 ++- internal/api/urlbuilders/image.go | 12 ++ internal/manager/config/config.go | 8 ++ internal/manager/config/tasks.go | 2 + internal/manager/fingerprint.go | 2 +- internal/manager/manager.go | 10 +- internal/manager/manager_tasks.go | 14 ++ internal/manager/repository.go | 1 - internal/manager/scene.go | 2 +- internal/manager/task_clean.go | 4 +- internal/manager/task_generate.go | 51 +++++++ .../manager/task_generate_clip_preview.go | 68 +++++++++ internal/manager/task_scan.go | 106 +++++++++++--- pkg/ffmpeg/transcoder/image.go | 6 +- pkg/file/frame.go | 20 +++ pkg/file/image/scan.go | 35 ++++- pkg/file/image_file.go | 12 ++ pkg/file/video_file.go | 19 +-- pkg/image/delete.go | 14 +- pkg/image/export_test.go | 8 +- pkg/image/import.go | 8 +- pkg/image/scan.go | 39 ++--- pkg/image/service.go | 2 +- pkg/image/thumbnail.go | 133 ++++++++++++++---- pkg/models/generate.go | 1 + pkg/models/model_image.go | 19 +-- pkg/models/paths/paths_generated.go | 5 + pkg/models/relationships.go | 87 ------------ pkg/sqlite/image.go | 15 +- pkg/sqlite/image_test.go | 17 ++- scripts/test_db_generator/makeTestDB.go | 5 + .../components/Galleries/GalleryViewer.tsx | 4 +- ui/v2.5/src/components/Help/Manual.tsx | 8 +- ui/v2.5/src/components/Images/ImageCard.tsx | 18 ++- .../components/Images/ImageDetails/Image.tsx | 18 ++- .../ImageDetails/ImageFileInfoPanel.tsx | 14 +- ui/v2.5/src/components/Images/ImageList.tsx | 11 +- .../src/components/Images/ImageWallItem.tsx | 57 ++++++++ .../Settings/SettingsLibraryPanel.tsx | 8 ++ .../Settings/Tasks/GenerateOptions.tsx | 6 + .../components/Settings/Tasks/ScanOptions.tsx | 7 + ui/v2.5/src/core/createClient.ts | 9 +- ui/v2.5/src/docs/en/Manual/Galleries.md | 12 -- ui/v2.5/src/docs/en/Manual/Images.md | 27 ++++ ui/v2.5/src/docs/en/Manual/Tasks.md | 2 + ui/v2.5/src/hooks/Lightbox/Lightbox.tsx | 32 +++-- ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx | 119 ++++++++++++++-- ui/v2.5/src/hooks/Lightbox/types.ts | 8 ++ ui/v2.5/src/locales/en-GB.json | 6 + 62 files changed, 999 insertions(+), 363 deletions(-) create mode 100644 internal/manager/task_generate_clip_preview.go create mode 100644 pkg/file/frame.go create mode 100644 ui/v2.5/src/components/Images/ImageWallItem.tsx delete mode 100644 ui/v2.5/src/docs/en/Manual/Galleries.md create mode 100644 ui/v2.5/src/docs/en/Manual/Images.md diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index a96341653..2a56e9512 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -25,6 +25,7 @@ fragment ConfigGeneralData on ConfigGeneralResult { maxTranscodeSize maxStreamingTranscodeSize writeImageThumbnails + createImageClipsFromVideos apiKey username password @@ -140,6 +141,7 @@ fragment ConfigDefaultSettingsData on ConfigDefaultSettingsResult { scanGenerateSprites scanGeneratePhashes scanGenerateThumbnails + scanGenerateClipPreviews } identify { @@ -180,6 +182,7 @@ fragment ConfigDefaultSettingsData on ConfigDefaultSettingsResult { transcodes phashes interactiveHeatmapsSpeeds + clipPreviews } deleteFile diff --git a/graphql/documents/data/file.graphql b/graphql/documents/data/file.graphql index 7acb95feb..52a4c50f8 100644 --- a/graphql/documents/data/file.graphql +++ b/graphql/documents/data/file.graphql @@ -43,4 +43,46 @@ fragment GalleryFileData on GalleryFile { type value } -} \ No newline at end of file +} + +fragment VisualFileData on VisualFile { + ... on BaseFile { + id + path + size + mod_time + fingerprints { + type + value + } + } + ... on ImageFile { + id + path + size + mod_time + width + height + fingerprints { + type + value + } + } + ... on VideoFile { + id + path + size + mod_time + duration + video_codec + audio_codec + width + height + frame_rate + bit_rate + fingerprints { + type + value + } + } +} diff --git a/graphql/documents/data/image-slim.graphql b/graphql/documents/data/image-slim.graphql index 4f787d36e..9f84904dc 100644 --- a/graphql/documents/data/image-slim.graphql +++ b/graphql/documents/data/image-slim.graphql @@ -13,6 +13,7 @@ fragment SlimImageData on Image { paths { thumbnail + preview image } @@ -45,4 +46,8 @@ fragment SlimImageData on Image { favorite image_path } + + visual_files { + ...VisualFileData + } } diff --git a/graphql/documents/data/image.graphql b/graphql/documents/data/image.graphql index f9adb5515..155c940e4 100644 --- a/graphql/documents/data/image.graphql +++ b/graphql/documents/data/image.graphql @@ -15,6 +15,7 @@ fragment ImageData on Image { paths { thumbnail + preview image } @@ -33,4 +34,8 @@ fragment ImageData on Image { performers { ...PerformerData } + + visual_files { + ...VisualFileData + } } diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 904d235dd..6c9939385 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -106,6 +106,8 @@ input ConfigGeneralInput { """Write image thumbnails to disk when generating on the fly""" writeImageThumbnails: Boolean + """Create Image Clips from Video extensions when Videos are disabled in Library""" + createImageClipsFromVideos: Boolean """Username""" username: String """Password""" @@ -215,6 +217,8 @@ type ConfigGeneralResult { """Write image thumbnails to disk when generating on the fly""" writeImageThumbnails: Boolean! + """Create Image Clips from Video extensions when Videos are disabled in Library""" + createImageClipsFromVideos: Boolean! """API Key""" apiKey: String! """Username""" diff --git a/graphql/schema/types/file.graphql b/graphql/schema/types/file.graphql index 09b733c39..755d63215 100644 --- a/graphql/schema/types/file.graphql +++ b/graphql/schema/types/file.graphql @@ -73,12 +73,14 @@ type ImageFile implements BaseFile { fingerprints: [Fingerprint!]! width: Int! - height: Int! + height: Int! created_at: Time! updated_at: Time! } +union VisualFile = VideoFile | ImageFile + type GalleryFile implements BaseFile { id: ID! path: String! diff --git a/graphql/schema/types/image.graphql b/graphql/schema/types/image.graphql index 6832cab24..c2e34f085 100644 --- a/graphql/schema/types/image.graphql +++ b/graphql/schema/types/image.graphql @@ -16,8 +16,9 @@ type Image { file_mod_time: Time @deprecated(reason: "Use files.mod_time") - file: ImageFileType! @deprecated(reason: "Use files.mod_time") - files: [ImageFile!]! + file: ImageFileType! @deprecated(reason: "Use visual_files") + files: [ImageFile!]! @deprecated(reason: "Use visual_files") + visual_files: [VisualFile!]! paths: ImagePathsType! # Resolver galleries: [Gallery!]! @@ -35,6 +36,7 @@ type ImageFileType { type ImagePathsType { thumbnail: String # Resolver + preview: String # Resolver image: String # Resolver } @@ -95,4 +97,4 @@ type FindImagesResultType { """Total file size in bytes""" filesize: Float! images: [Image!]! -} \ No newline at end of file +} diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index ecde11eac..8e575b3ec 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -14,6 +14,7 @@ input GenerateMetadataInput { forceTranscodes: Boolean phashes: Boolean interactiveHeatmapsSpeeds: Boolean + clipPreviews: Boolean """scene ids to generate for""" sceneIDs: [ID!] @@ -49,6 +50,7 @@ type GenerateMetadataOptions { transcodes: Boolean phashes: Boolean interactiveHeatmapsSpeeds: Boolean + clipPreviews: Boolean } type GeneratePreviewOptions { @@ -98,6 +100,8 @@ input ScanMetadataInput { scanGeneratePhashes: Boolean """Generate image thumbnails during scan""" scanGenerateThumbnails: Boolean + """Generate image clip previews during scan""" + scanGenerateClipPreviews: Boolean "Filter options for the scan" filter: ScanMetaDataFilterInput @@ -120,6 +124,8 @@ type ScanMetadataOptions { scanGeneratePhashes: Boolean! """Generate image thumbnails during scan""" scanGenerateThumbnails: Boolean! + """Generate image clip previews during scan""" + scanGenerateClipPreviews: Boolean! } input CleanMetadataInput { diff --git a/internal/api/resolver_model_image.go b/internal/api/resolver_model_image.go index 2a1965c4e..9bfadafc7 100644 --- a/internal/api/resolver_model_image.go +++ b/internal/api/resolver_model_image.go @@ -12,42 +12,55 @@ import ( "github.com/stashapp/stash/pkg/models" ) -func (r *imageResolver) getPrimaryFile(ctx context.Context, obj *models.Image) (*file.ImageFile, error) { +func convertImageFile(f *file.ImageFile) *ImageFile { + ret := &ImageFile{ + ID: strconv.Itoa(int(f.ID)), + Path: f.Path, + Basename: f.Basename, + ParentFolderID: strconv.Itoa(int(f.ParentFolderID)), + ModTime: f.ModTime, + Size: f.Size, + Width: f.Width, + Height: f.Height, + CreatedAt: f.CreatedAt, + UpdatedAt: f.UpdatedAt, + Fingerprints: resolveFingerprints(f.Base()), + } + + if f.ZipFileID != nil { + zipFileID := strconv.Itoa(int(*f.ZipFileID)) + ret.ZipFileID = &zipFileID + } + + return ret +} + +func (r *imageResolver) getPrimaryFile(ctx context.Context, obj *models.Image) (file.VisualFile, error) { if obj.PrimaryFileID != nil { f, err := loaders.From(ctx).FileByID.Load(*obj.PrimaryFileID) if err != nil { return nil, err } - ret, ok := f.(*file.ImageFile) + asFrame, ok := f.(file.VisualFile) if !ok { - return nil, fmt.Errorf("file %T is not an image file", f) + return nil, fmt.Errorf("file %T is not an frame", f) } - return ret, nil + return asFrame, nil } return nil, nil } -func (r *imageResolver) getFiles(ctx context.Context, obj *models.Image) ([]*file.ImageFile, error) { +func (r *imageResolver) getFiles(ctx context.Context, obj *models.Image) ([]file.File, error) { fileIDs, err := loaders.From(ctx).ImageFiles.Load(obj.ID) if err != nil { return nil, err } files, errs := loaders.From(ctx).FileByID.LoadAll(fileIDs) - ret := make([]*file.ImageFile, len(files)) - for i, bf := range files { - f, ok := bf.(*file.ImageFile) - if !ok { - return nil, fmt.Errorf("file %T is not an image file", f) - } - - ret[i] = f - } - - return ret, firstError(errs) + return files, firstError(errs) } func (r *imageResolver) Title(ctx context.Context, obj *models.Image) (*string, error) { @@ -65,9 +78,9 @@ func (r *imageResolver) File(ctx context.Context, obj *models.Image) (*ImageFile return nil, nil } - width := f.Width - height := f.Height - size := f.Size + width := f.GetWidth() + height := f.GetHeight() + size := f.Base().Size return &ImageFileType{ Size: int(size), Width: width, @@ -75,6 +88,32 @@ func (r *imageResolver) File(ctx context.Context, obj *models.Image) (*ImageFile }, nil } +func convertVisualFile(f file.File) VisualFile { + switch f := f.(type) { + case *file.ImageFile: + return convertImageFile(f) + case *file.VideoFile: + return convertVideoFile(f) + default: + panic(fmt.Sprintf("unknown file type %T", f)) + } +} + +func (r *imageResolver) VisualFiles(ctx context.Context, obj *models.Image) ([]VisualFile, error) { + fileIDs, err := loaders.From(ctx).ImageFiles.Load(obj.ID) + if err != nil { + return nil, err + } + + files, errs := loaders.From(ctx).FileByID.LoadAll(fileIDs) + ret := make([]VisualFile, len(files)) + for i, f := range files { + ret[i] = convertVisualFile(f) + } + + return ret, firstError(errs) +} + func (r *imageResolver) Date(ctx context.Context, obj *models.Image) (*string, error) { if obj.Date != nil { result := obj.Date.String() @@ -89,27 +128,18 @@ func (r *imageResolver) Files(ctx context.Context, obj *models.Image) ([]*ImageF return nil, err } - ret := make([]*ImageFile, len(files)) + var ret []*ImageFile - for i, f := range files { - ret[i] = &ImageFile{ - ID: strconv.Itoa(int(f.ID)), - Path: f.Path, - Basename: f.Basename, - ParentFolderID: strconv.Itoa(int(f.ParentFolderID)), - ModTime: f.ModTime, - Size: f.Size, - Width: f.Width, - Height: f.Height, - CreatedAt: f.CreatedAt, - UpdatedAt: f.UpdatedAt, - Fingerprints: resolveFingerprints(f.Base()), + for _, f := range files { + // filter out non-image files + imageFile, ok := f.(*file.ImageFile) + if !ok { + continue } - if f.ZipFileID != nil { - zipFileID := strconv.Itoa(int(*f.ZipFileID)) - ret[i].ZipFileID = &zipFileID - } + thisFile := convertImageFile(imageFile) + + ret = append(ret, thisFile) } return ret, nil @@ -121,7 +151,7 @@ func (r *imageResolver) FileModTime(ctx context.Context, obj *models.Image) (*ti return nil, err } if f != nil { - return &f.ModTime, nil + return &f.Base().ModTime, nil } return nil, nil @@ -131,10 +161,12 @@ func (r *imageResolver) Paths(ctx context.Context, obj *models.Image) (*ImagePat baseURL, _ := ctx.Value(BaseURLCtxKey).(string) builder := urlbuilders.NewImageURLBuilder(baseURL, obj) thumbnailPath := builder.GetThumbnailURL() + previewPath := builder.GetPreviewURL() imagePath := builder.GetImageURL() return &ImagePathsType{ Image: &imagePath, Thumbnail: &thumbnailPath, + Preview: &previewPath, }, nil } diff --git a/internal/api/resolver_model_scene.go b/internal/api/resolver_model_scene.go index 99f42e64f..cd6f16a57 100644 --- a/internal/api/resolver_model_scene.go +++ b/internal/api/resolver_model_scene.go @@ -14,6 +14,35 @@ import ( "github.com/stashapp/stash/pkg/utils" ) +func convertVideoFile(f *file.VideoFile) *VideoFile { + ret := &VideoFile{ + ID: strconv.Itoa(int(f.ID)), + Path: f.Path, + Basename: f.Basename, + ParentFolderID: strconv.Itoa(int(f.ParentFolderID)), + ModTime: f.ModTime, + Format: f.Format, + Size: f.Size, + Duration: handleFloat64Value(f.Duration), + VideoCodec: f.VideoCodec, + AudioCodec: f.AudioCodec, + Width: f.Width, + Height: f.Height, + FrameRate: handleFloat64Value(f.FrameRate), + BitRate: int(f.BitRate), + CreatedAt: f.CreatedAt, + UpdatedAt: f.UpdatedAt, + Fingerprints: resolveFingerprints(f.Base()), + } + + if f.ZipFileID != nil { + zipFileID := strconv.Itoa(int(*f.ZipFileID)) + ret.ZipFileID = &zipFileID + } + + return ret +} + func (r *sceneResolver) getPrimaryFile(ctx context.Context, obj *models.Scene) (*file.VideoFile, error) { if obj.PrimaryFileID != nil { f, err := loaders.From(ctx).FileByID.Load(*obj.PrimaryFileID) @@ -112,30 +141,7 @@ func (r *sceneResolver) Files(ctx context.Context, obj *models.Scene) ([]*VideoF ret := make([]*VideoFile, len(files)) for i, f := range files { - ret[i] = &VideoFile{ - ID: strconv.Itoa(int(f.ID)), - Path: f.Path, - Basename: f.Basename, - ParentFolderID: strconv.Itoa(int(f.ParentFolderID)), - ModTime: f.ModTime, - Format: f.Format, - Size: f.Size, - Duration: handleFloat64Value(f.Duration), - VideoCodec: f.VideoCodec, - AudioCodec: f.AudioCodec, - Width: f.Width, - Height: f.Height, - FrameRate: handleFloat64Value(f.FrameRate), - BitRate: int(f.BitRate), - CreatedAt: f.CreatedAt, - UpdatedAt: f.UpdatedAt, - Fingerprints: resolveFingerprints(f.Base()), - } - - if f.ZipFileID != nil { - zipFileID := strconv.Itoa(int(*f.ZipFileID)) - ret[i].ZipFileID = &zipFileID - } + ret[i] = convertVideoFile(f) } return ret, nil diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 2a102af6e..bdc93137f 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -218,6 +218,10 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen c.Set(config.WriteImageThumbnails, *input.WriteImageThumbnails) } + if input.CreateImageClipsFromVideos != nil { + c.Set(config.CreateImageClipsFromVideos, *input.CreateImageClipsFromVideos) + } + if input.GalleryCoverRegex != nil { _, err := regexp.Compile(*input.GalleryCoverRegex) diff --git a/internal/api/resolver_mutation_image.go b/internal/api/resolver_mutation_image.go index 6a482ff04..353dab744 100644 --- a/internal/api/resolver_mutation_image.go +++ b/internal/api/resolver_mutation_image.go @@ -126,9 +126,9 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInp } // ensure that new primary file is associated with scene - var f *file.ImageFile + var f file.File for _, ff := range i.Files.List() { - if ff.ID == converted { + if ff.Base().ID == converted { f = ff } } diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index 643aa263b..4c9f00aea 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -106,6 +106,7 @@ func makeConfigGeneralResult() *ConfigGeneralResult { MaxTranscodeSize: &maxTranscodeSize, MaxStreamingTranscodeSize: &maxStreamingTranscodeSize, WriteImageThumbnails: config.IsWriteImageThumbnails(), + CreateImageClipsFromVideos: config.IsCreateImageClipsFromVideos(), GalleryCoverRegex: config.GetGalleryCoverRegex(), APIKey: config.GetAPIKey(), Username: config.GetUsername(), diff --git a/internal/api/routes_image.go b/internal/api/routes_image.go index 2685a7a76..4ea612d3b 100644 --- a/internal/api/routes_image.go +++ b/internal/api/routes_image.go @@ -40,6 +40,7 @@ func (rs imageRoutes) Routes() chi.Router { r.Get("/image", rs.Image) r.Get("/thumbnail", rs.Thumbnail) + r.Get("/preview", rs.Preview) }) return r @@ -64,13 +65,19 @@ func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) { return } - encoder := image.NewThumbnailEncoder(manager.GetInstance().FFMPEG) + clipPreviewOptions := image.ClipPreviewOptions{ + InputArgs: manager.GetInstance().Config.GetTranscodeInputArgs(), + OutputArgs: manager.GetInstance().Config.GetTranscodeOutputArgs(), + Preset: manager.GetInstance().Config.GetPreviewPreset().String(), + } + + encoder := image.NewThumbnailEncoder(manager.GetInstance().FFMPEG, manager.GetInstance().FFProbe, clipPreviewOptions) data, err := encoder.GetThumbnail(f, models.DefaultGthumbWidth) if err != nil { // don't log for unsupported image format // don't log for file not found - can optionally be logged in serveImage if !errors.Is(err, image.ErrNotSupportedForThumbnail) && !errors.Is(err, fs.ErrNotExist) { - logger.Errorf("error generating thumbnail for %s: %v", f.Path, err) + logger.Errorf("error generating thumbnail for %s: %v", f.Base().Path, err) var exitErr *exec.ExitError if errors.As(err, &exitErr) { @@ -96,6 +103,14 @@ func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) { } } +func (rs imageRoutes) Preview(w http.ResponseWriter, r *http.Request) { + img := r.Context().Value(imageKey).(*models.Image) + filepath := manager.GetInstance().Paths.Generated.GetClipPreviewPath(img.Checksum, models.DefaultGthumbWidth) + + // don't check if the preview exists - we'll just return a 404 if it doesn't + utils.ServeStaticFile(w, r, filepath) +} + func (rs imageRoutes) Image(w http.ResponseWriter, r *http.Request) { i := r.Context().Value(imageKey).(*models.Image) @@ -107,7 +122,7 @@ func (rs imageRoutes) serveImage(w http.ResponseWriter, r *http.Request, i *mode const defaultImageImage = "image/image.svg" if i.Files.Primary() != nil { - err := i.Files.Primary().Serve(&file.OsFS{}, w, r) + err := i.Files.Primary().Base().Serve(&file.OsFS{}, w, r) if err == nil { return } diff --git a/internal/api/urlbuilders/image.go b/internal/api/urlbuilders/image.go index 735ce9610..3bc77d30b 100644 --- a/internal/api/urlbuilders/image.go +++ b/internal/api/urlbuilders/image.go @@ -3,12 +3,15 @@ package urlbuilders import ( "strconv" + "github.com/stashapp/stash/internal/manager" + "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/models" ) type ImageURLBuilder struct { BaseURL string ImageID string + Checksum string UpdatedAt string } @@ -16,6 +19,7 @@ func NewImageURLBuilder(baseURL string, image *models.Image) ImageURLBuilder { return ImageURLBuilder{ BaseURL: baseURL, ImageID: strconv.Itoa(image.ID), + Checksum: image.Checksum, UpdatedAt: strconv.FormatInt(image.UpdatedAt.Unix(), 10), } } @@ -27,3 +31,11 @@ func (b ImageURLBuilder) GetImageURL() string { func (b ImageURLBuilder) GetThumbnailURL() string { return b.BaseURL + "/image/" + b.ImageID + "/thumbnail?t=" + b.UpdatedAt } + +func (b ImageURLBuilder) GetPreviewURL() string { + if exists, err := fsutil.FileExists(manager.GetInstance().Paths.Generated.GetClipPreviewPath(b.Checksum, models.DefaultGthumbWidth)); exists && err == nil { + return b.BaseURL + "/image/" + b.ImageID + "/preview?" + b.UpdatedAt + } else { + return "" + } +} diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index fe9730219..44c643925 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -96,6 +96,9 @@ const ( WriteImageThumbnails = "write_image_thumbnails" writeImageThumbnailsDefault = true + CreateImageClipsFromVideos = "create_image_clip_from_videos" + createImageClipsFromVideosDefault = false + Host = "host" hostDefault = "0.0.0.0" @@ -865,6 +868,10 @@ func (i *Instance) IsWriteImageThumbnails() bool { return i.getBool(WriteImageThumbnails) } +func (i *Instance) IsCreateImageClipsFromVideos() bool { + return i.getBool(CreateImageClipsFromVideos) +} + func (i *Instance) GetAPIKey() string { return i.getString(ApiKey) } @@ -1513,6 +1520,7 @@ func (i *Instance) setDefaultValues(write bool) error { i.main.SetDefault(ThemeColor, DefaultThemeColor) i.main.SetDefault(WriteImageThumbnails, writeImageThumbnailsDefault) + i.main.SetDefault(CreateImageClipsFromVideos, createImageClipsFromVideosDefault) i.main.SetDefault(Database, defaultDatabaseFilePath) diff --git a/internal/manager/config/tasks.go b/internal/manager/config/tasks.go index 1e541fcc5..b87a1d23a 100644 --- a/internal/manager/config/tasks.go +++ b/internal/manager/config/tasks.go @@ -19,6 +19,8 @@ type ScanMetadataOptions struct { ScanGeneratePhashes bool `json:"scanGeneratePhashes"` // Generate image thumbnails during scan ScanGenerateThumbnails bool `json:"scanGenerateThumbnails"` + // Generate image thumbnails during scan + ScanGenerateClipPreviews bool `json:"scanGenerateClipPreviews"` } type AutoTagMetadataOptions struct { diff --git a/internal/manager/fingerprint.go b/internal/manager/fingerprint.go index 5c2c66352..fc183cc6a 100644 --- a/internal/manager/fingerprint.go +++ b/internal/manager/fingerprint.go @@ -63,7 +63,7 @@ func (c *fingerprintCalculator) CalculateFingerprints(f *file.BaseFile, o file.O var ret []file.Fingerprint calculateMD5 := true - if isVideo(f.Basename) { + if useAsVideo(f.Path) { var ( fp *file.Fingerprint err error diff --git a/internal/manager/manager.go b/internal/manager/manager.go index a952b712c..6d776fcf7 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -279,11 +279,11 @@ func initialize() error { } func videoFileFilter(ctx context.Context, f file.File) bool { - return isVideo(f.Base().Basename) + return useAsVideo(f.Base().Path) } func imageFileFilter(ctx context.Context, f file.File) bool { - return isImage(f.Base().Basename) + return useAsImage(f.Base().Path) } func galleryFileFilter(ctx context.Context, f file.File) bool { @@ -306,8 +306,10 @@ func makeScanner(db *sqlite.Database, pluginCache *plugin.Cache) *file.Scanner { Filter: file.FilterFunc(videoFileFilter), }, &file.FilteredDecorator{ - Decorator: &file_image.Decorator{}, - Filter: file.FilterFunc(imageFileFilter), + Decorator: &file_image.Decorator{ + FFProbe: instance.FFProbe, + }, + Filter: file.FilterFunc(imageFileFilter), }, }, FingerprintCalculator: &fingerprintCalculator{instance.Config}, diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index 10bcacab0..3987fb9ba 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -15,6 +15,20 @@ import ( "github.com/stashapp/stash/pkg/models" ) +func useAsVideo(pathname string) bool { + if instance.Config.IsCreateImageClipsFromVideos() && config.StashConfigs.GetStashFromDirPath(instance.Config.GetStashPaths(), pathname).ExcludeVideo { + return false + } + return isVideo(pathname) +} + +func useAsImage(pathname string) bool { + if instance.Config.IsCreateImageClipsFromVideos() && config.StashConfigs.GetStashFromDirPath(instance.Config.GetStashPaths(), pathname).ExcludeVideo { + return isImage(pathname) || isVideo(pathname) + } + return isImage(pathname) +} + func isZip(pathname string) bool { gExt := config.GetInstance().GetGalleryExtensions() return fsutil.MatchExtension(pathname, gExt) diff --git a/internal/manager/repository.go b/internal/manager/repository.go index 41ac5f12e..dd49c4af7 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -15,7 +15,6 @@ import ( type ImageReaderWriter interface { models.ImageReaderWriter image.FinderCreatorUpdater - models.ImageFileLoader GetManyFileIDs(ctx context.Context, ids []int) ([][]file.ID, error) } diff --git a/internal/manager/scene.go b/internal/manager/scene.go index a653cb632..39b96fec7 100644 --- a/internal/manager/scene.go +++ b/internal/manager/scene.go @@ -88,7 +88,7 @@ func GetSceneStreamPaths(scene *models.Scene, directStreamURL *url.URL, maxStrea // convert StreamingResolutionEnum to ResolutionEnum maxStreamingResolution := models.ResolutionEnum(maxStreamingTranscodeSize) - sceneResolution := pf.GetMinResolution() + sceneResolution := file.GetMinResolution(pf) includeSceneStreamPath := func(streamingResolution models.StreamingResolutionEnum) bool { var minResolution int if streamingResolution == models.StreamingResolutionEnumOriginal { diff --git a/internal/manager/task_clean.go b/internal/manager/task_clean.go index b90f11be8..5eb4d20a9 100644 --- a/internal/manager/task_clean.go +++ b/internal/manager/task_clean.go @@ -201,9 +201,9 @@ func (f *cleanFilter) shouldCleanFile(path string, info fs.FileInfo, stash *conf switch { case info.IsDir() || fsutil.MatchExtension(path, f.zipExt): return f.shouldCleanGallery(path, stash) - case fsutil.MatchExtension(path, f.vidExt): + case useAsVideo(path): return f.shouldCleanVideoFile(path, stash) - case fsutil.MatchExtension(path, f.imgExt): + case useAsImage(path): return f.shouldCleanImage(path, stash) default: logger.Infof("File extension does not match any media extensions. Marking to clean: \"%s\"", path) diff --git a/internal/manager/task_generate.go b/internal/manager/task_generate.go index c457ddedf..ce3d71000 100644 --- a/internal/manager/task_generate.go +++ b/internal/manager/task_generate.go @@ -7,6 +7,7 @@ import ( "github.com/remeh/sizedwaitgroup" "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" @@ -29,6 +30,7 @@ type GenerateMetadataInput struct { ForceTranscodes bool `json:"forceTranscodes"` Phashes bool `json:"phashes"` InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"` + ClipPreviews bool `json:"clipPreviews"` // scene ids to generate for SceneIDs []string `json:"sceneIDs"` // marker ids to generate for @@ -69,6 +71,7 @@ type totalsGenerate struct { transcodes int64 phashes int64 interactiveHeatmapSpeeds int64 + clipPreviews int64 tasks int } @@ -167,6 +170,9 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) { if j.input.InteractiveHeatmapsSpeeds { logMsg += fmt.Sprintf(" %d heatmaps & speeds", totals.interactiveHeatmapSpeeds) } + if j.input.ClipPreviews { + logMsg += fmt.Sprintf(" %d Image Clip Previews", totals.clipPreviews) + } if logMsg == "Generating" { logMsg = "Nothing selected to generate" } @@ -254,6 +260,38 @@ func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, que } } + *findFilter.Page = 1 + for more := j.input.ClipPreviews; more; { + if job.IsCancelled(ctx) { + return totals + } + + images, err := image.Query(ctx, j.txnManager.Image, nil, findFilter) + if err != nil { + logger.Errorf("Error encountered queuing files to scan: %s", err.Error()) + return totals + } + + for _, ss := range images { + if job.IsCancelled(ctx) { + return totals + } + + if err := ss.LoadFiles(ctx, j.txnManager.Image); err != nil { + logger.Errorf("Error encountered queuing files to scan: %s", err.Error()) + return totals + } + + j.queueImageJob(g, ss, queue, &totals) + } + + if len(images) != batchSize { + more = false + } else { + *findFilter.Page++ + } + } + return totals } @@ -434,3 +472,16 @@ func (j *GenerateJob) queueMarkerJob(g *generate.Generator, marker *models.Scene totals.tasks++ queue <- task } + +func (j *GenerateJob) queueImageJob(g *generate.Generator, image *models.Image, queue chan<- Task, totals *totalsGenerate) { + task := &GenerateClipPreviewTask{ + Image: *image, + Overwrite: j.overwrite, + } + + if task.required() { + totals.clipPreviews++ + totals.tasks++ + queue <- task + } +} diff --git a/internal/manager/task_generate_clip_preview.go b/internal/manager/task_generate_clip_preview.go new file mode 100644 index 000000000..b43ca7514 --- /dev/null +++ b/internal/manager/task_generate_clip_preview.go @@ -0,0 +1,68 @@ +package manager + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/file" + "github.com/stashapp/stash/pkg/fsutil" + "github.com/stashapp/stash/pkg/image" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" +) + +type GenerateClipPreviewTask struct { + Image models.Image + Overwrite bool +} + +func (t *GenerateClipPreviewTask) GetDescription() string { + return fmt.Sprintf("Generating Preview for image Clip %s", t.Image.Path) +} + +func (t *GenerateClipPreviewTask) Start(ctx context.Context) { + if !t.required() { + return + } + + prevPath := GetInstance().Paths.Generated.GetClipPreviewPath(t.Image.Checksum, models.DefaultGthumbWidth) + filePath := t.Image.Files.Primary().Base().Path + + clipPreviewOptions := image.ClipPreviewOptions{ + InputArgs: GetInstance().Config.GetTranscodeInputArgs(), + OutputArgs: GetInstance().Config.GetTranscodeOutputArgs(), + Preset: GetInstance().Config.GetPreviewPreset().String(), + } + + encoder := image.NewThumbnailEncoder(GetInstance().FFMPEG, GetInstance().FFProbe, clipPreviewOptions) + data, err := encoder.GetPreview(t.Image.Files.Primary(), models.DefaultGthumbWidth) + if err != nil { + logger.Errorf("getting preview for image %s: %w", filePath, err) + return + } + + err = fsutil.WriteFile(prevPath, data) + if err != nil { + logger.Errorf("writing preview for image %s: %w", filePath, err) + return + } + +} + +func (t *GenerateClipPreviewTask) required() bool { + _, ok := t.Image.Files.Primary().(*file.VideoFile) + if !ok { + return false + } + + if t.Overwrite { + return true + } + + prevPath := GetInstance().Paths.Generated.GetClipPreviewPath(t.Image.Checksum, models.DefaultGthumbWidth) + if exists, _ := fsutil.FileExists(prevPath); exists { + return false + } + + return true +} diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index 02ebfbc30..7c5e20156 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -141,8 +141,8 @@ func newHandlerRequiredFilter(c *config.Instance) *handlerRequiredFilter { func (f *handlerRequiredFilter) Accept(ctx context.Context, ff file.File) bool { path := ff.Base().Path - isVideoFile := fsutil.MatchExtension(path, f.vidExt) - isImageFile := fsutil.MatchExtension(path, f.imgExt) + isVideoFile := useAsVideo(path) + isImageFile := useAsImage(path) isZipFile := fsutil.MatchExtension(path, f.zipExt) var counter fileCounter @@ -255,8 +255,8 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) return false } - isVideoFile := fsutil.MatchExtension(path, f.vidExt) - isImageFile := fsutil.MatchExtension(path, f.imgExt) + isVideoFile := useAsVideo(path) + isImageFile := useAsImage(path) isZipFile := fsutil.MatchExtension(path, f.zipExt) // handle caption files @@ -289,7 +289,7 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) // shortcut: skip the directory entirely if it matches both exclusion patterns // add a trailing separator so that it correctly matches against patterns like path/.* pathExcludeTest := path + string(filepath.Separator) - if (s.ExcludeVideo || matchFileRegex(pathExcludeTest, f.videoExcludeRegex)) && (s.ExcludeImage || matchFileRegex(pathExcludeTest, f.imageExcludeRegex)) { + if (matchFileRegex(pathExcludeTest, f.videoExcludeRegex)) && (s.ExcludeImage || matchFileRegex(pathExcludeTest, f.imageExcludeRegex)) { logger.Debugf("Skipping directory %s as it matches video and image exclusion patterns", path) return false } @@ -306,17 +306,14 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) } type scanConfig struct { - isGenerateThumbnails bool + isGenerateThumbnails bool + isGenerateClipPreviews bool } func (c *scanConfig) GetCreateGalleriesFromFolders() bool { return instance.Config.GetCreateGalleriesFromFolders() } -func (c *scanConfig) IsGenerateThumbnails() bool { - return c.isGenerateThumbnails -} - func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progress *job.Progress) []file.Handler { db := instance.Database pluginCache := instance.PluginCache @@ -325,11 +322,16 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre &file.FilteredHandler{ Filter: file.FilterFunc(imageFileFilter), Handler: &image.ScanHandler{ - CreatorUpdater: db.Image, - GalleryFinder: db.Gallery, - ThumbnailGenerator: &imageThumbnailGenerator{}, + CreatorUpdater: db.Image, + GalleryFinder: db.Gallery, + ScanGenerator: &imageGenerators{ + input: options, + taskQueue: taskQueue, + progress: progress, + }, ScanConfig: &scanConfig{ - isGenerateThumbnails: options.ScanGenerateThumbnails, + isGenerateThumbnails: options.ScanGenerateThumbnails, + isGenerateClipPreviews: options.ScanGenerateClipPreviews, }, PluginCache: pluginCache, Paths: instance.Paths, @@ -362,35 +364,97 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre } } -type imageThumbnailGenerator struct{} +type imageGenerators struct { + input ScanMetadataInput + taskQueue *job.TaskQueue + progress *job.Progress +} -func (g *imageThumbnailGenerator) GenerateThumbnail(ctx context.Context, i *models.Image, f *file.ImageFile) error { +func (g *imageGenerators) Generate(ctx context.Context, i *models.Image, f file.File) error { + const overwrite = false + + progress := g.progress + t := g.input + path := f.Base().Path + config := instance.Config + sequentialScanning := config.GetSequentialScanning() + + if t.ScanGenerateThumbnails { + // this should be quick, so always generate sequentially + if err := g.generateThumbnail(ctx, i, f); err != nil { + logger.Errorf("Error generating thumbnail for %s: %v", path, err) + } + } + + // avoid adding a task if the file isn't a video file + _, isVideo := f.(*file.VideoFile) + if isVideo && t.ScanGenerateClipPreviews { + // this is a bit of a hack: the task requires files to be loaded, but + // we don't really need to since we already have the file + ii := *i + ii.Files = models.NewRelatedFiles([]file.File{f}) + + progress.AddTotal(1) + previewsFn := func(ctx context.Context) { + taskPreview := GenerateClipPreviewTask{ + Image: ii, + Overwrite: overwrite, + } + + taskPreview.Start(ctx) + progress.Increment() + } + + if sequentialScanning { + previewsFn(ctx) + } else { + g.taskQueue.Add(fmt.Sprintf("Generating preview for %s", path), previewsFn) + } + } + + return nil +} + +func (g *imageGenerators) generateThumbnail(ctx context.Context, i *models.Image, f file.File) error { thumbPath := GetInstance().Paths.Generated.GetThumbnailPath(i.Checksum, models.DefaultGthumbWidth) exists, _ := fsutil.FileExists(thumbPath) if exists { return nil } - if f.Height <= models.DefaultGthumbWidth && f.Width <= models.DefaultGthumbWidth { + path := f.Base().Path + + asFrame, ok := f.(file.VisualFile) + if !ok { + return fmt.Errorf("file %s does not implement Frame", path) + } + + if asFrame.GetHeight() <= models.DefaultGthumbWidth && asFrame.GetWidth() <= models.DefaultGthumbWidth { return nil } - logger.Debugf("Generating thumbnail for %s", f.Path) + logger.Debugf("Generating thumbnail for %s", path) - encoder := image.NewThumbnailEncoder(instance.FFMPEG) + clipPreviewOptions := image.ClipPreviewOptions{ + InputArgs: instance.Config.GetTranscodeInputArgs(), + OutputArgs: instance.Config.GetTranscodeOutputArgs(), + Preset: instance.Config.GetPreviewPreset().String(), + } + + encoder := image.NewThumbnailEncoder(instance.FFMPEG, instance.FFProbe, clipPreviewOptions) data, err := encoder.GetThumbnail(f, models.DefaultGthumbWidth) if err != nil { // don't log for animated images if !errors.Is(err, image.ErrNotSupportedForThumbnail) { - return fmt.Errorf("getting thumbnail for image %s: %w", f.Path, err) + return fmt.Errorf("getting thumbnail for image %s: %w", path, err) } return nil } err = fsutil.WriteFile(thumbPath, data) if err != nil { - return fmt.Errorf("writing thumbnail for image %s: %w", f.Path, err) + return fmt.Errorf("writing thumbnail for image %s: %w", path, err) } return nil diff --git a/pkg/ffmpeg/transcoder/image.go b/pkg/ffmpeg/transcoder/image.go index a476dff42..4221a9a54 100644 --- a/pkg/ffmpeg/transcoder/image.go +++ b/pkg/ffmpeg/transcoder/image.go @@ -10,6 +10,7 @@ var ErrUnsupportedFormat = errors.New("unsupported image format") type ImageThumbnailOptions struct { InputFormat ffmpeg.ImageFormat + OutputFormat ffmpeg.ImageFormat OutputPath string MaxDimensions int Quality int @@ -29,12 +30,15 @@ func ImageThumbnail(input string, options ImageThumbnailOptions) ffmpeg.Args { VideoFilter(videoFilter). VideoCodec(ffmpeg.VideoCodecMJpeg) + args = append(args, "-frames:v", "1") + if options.Quality > 0 { args = args.FixedQualityScaleVideo(options.Quality) } args = args.ImageFormat(ffmpeg.ImageFormatImage2Pipe). - Output(options.OutputPath) + Output(options.OutputPath). + ImageFormat(options.OutputFormat) return args } diff --git a/pkg/file/frame.go b/pkg/file/frame.go new file mode 100644 index 000000000..de9f74662 --- /dev/null +++ b/pkg/file/frame.go @@ -0,0 +1,20 @@ +package file + +// VisualFile is an interface for files that have a width and height. +type VisualFile interface { + File + GetWidth() int + GetHeight() int + GetFormat() string +} + +func GetMinResolution(f VisualFile) int { + w := f.GetWidth() + h := f.GetHeight() + + if w < h { + return w + } + + return h +} diff --git a/pkg/file/image/scan.go b/pkg/file/image/scan.go index a029f5cce..afe4210e0 100644 --- a/pkg/file/image/scan.go +++ b/pkg/file/image/scan.go @@ -9,12 +9,15 @@ import ( _ "image/jpeg" _ "image/png" + "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/file" + "github.com/stashapp/stash/pkg/file/video" _ "golang.org/x/image/webp" ) // Decorator adds image specific fields to a File. type Decorator struct { + FFProbe ffmpeg.FFProbe } func (d *Decorator) Decorate(ctx context.Context, fs file.FS, f file.File) (file.File, error) { @@ -25,16 +28,38 @@ func (d *Decorator) Decorate(ctx context.Context, fs file.FS, f file.File) (file } defer r.Close() - c, format, err := image.DecodeConfig(r) + probe, err := d.FFProbe.NewVideoFile(base.Path) if err != nil { - return f, fmt.Errorf("decoding image file %q: %w", base.Path, err) + fmt.Printf("Warning: File %q could not be read with ffprobe: %s, assuming ImageFile", base.Path, err) + c, format, err := image.DecodeConfig(r) + if err != nil { + return f, fmt.Errorf("decoding image file %q: %w", base.Path, err) + } + return &file.ImageFile{ + BaseFile: base, + Format: format, + Width: c.Width, + Height: c.Height, + }, nil + } + + isClip := true + // This list is derived from ffmpegImageThumbnail in pkg/image/thumbnail. If one gets updated, the other should be as well + for _, item := range []string{"png", "mjpeg", "webp"} { + if item == probe.VideoCodec { + isClip = false + } + } + if isClip { + videoFileDecorator := video.Decorator{FFProbe: d.FFProbe} + return videoFileDecorator.Decorate(ctx, fs, f) } return &file.ImageFile{ BaseFile: base, - Format: format, - Width: c.Width, - Height: c.Height, + Format: probe.VideoCodec, + Width: probe.Width, + Height: probe.Height, }, nil } diff --git a/pkg/file/image_file.go b/pkg/file/image_file.go index 4e1f5690a..0de2d9b98 100644 --- a/pkg/file/image_file.go +++ b/pkg/file/image_file.go @@ -7,3 +7,15 @@ type ImageFile struct { Width int `json:"width"` Height int `json:"height"` } + +func (f ImageFile) GetWidth() int { + return f.Width +} + +func (f ImageFile) GetHeight() int { + return f.Height +} + +func (f ImageFile) GetFormat() string { + return f.Format +} diff --git a/pkg/file/video_file.go b/pkg/file/video_file.go index ec08aad87..382c81e19 100644 --- a/pkg/file/video_file.go +++ b/pkg/file/video_file.go @@ -16,13 +16,14 @@ type VideoFile struct { InteractiveSpeed *int `json:"interactive_speed"` } -func (f VideoFile) GetMinResolution() int { - w := f.Width - h := f.Height - - if w < h { - return w - } - - return h +func (f VideoFile) GetWidth() int { + return f.Width +} + +func (f VideoFile) GetHeight() int { + return f.Height +} + +func (f VideoFile) GetFormat() string { + return f.Format } diff --git a/pkg/image/delete.go b/pkg/image/delete.go index b61e77045..dba0fd587 100644 --- a/pkg/image/delete.go +++ b/pkg/image/delete.go @@ -22,13 +22,19 @@ type FileDeleter struct { // MarkGeneratedFiles marks for deletion the generated files for the provided image. func (d *FileDeleter) MarkGeneratedFiles(image *models.Image) error { + var files []string thumbPath := d.Paths.Generated.GetThumbnailPath(image.Checksum, models.DefaultGthumbWidth) exists, _ := fsutil.FileExists(thumbPath) if exists { - return d.Files([]string{thumbPath}) + files = append(files, thumbPath) + } + prevPath := d.Paths.Generated.GetClipPreviewPath(image.Checksum, models.DefaultGthumbWidth) + exists, _ = fsutil.FileExists(prevPath) + if exists { + files = append(files, prevPath) } - return nil + return d.Files(files) } // Destroy destroys an image, optionally marking the file and generated files for deletion. @@ -87,7 +93,7 @@ func (s *Service) deleteFiles(ctx context.Context, i *models.Image, fileDeleter for _, f := range i.Files.List() { // only delete files where there is no other associated image - otherImages, err := s.Repository.FindByFileID(ctx, f.ID) + otherImages, err := s.Repository.FindByFileID(ctx, f.Base().ID) if err != nil { return err } @@ -99,7 +105,7 @@ func (s *Service) deleteFiles(ctx context.Context, i *models.Image, fileDeleter // don't delete files in zip archives const deleteFile = true - if f.ZipFileID == nil { + if f.Base().ZipFileID == nil { if err := file.Destroy(ctx, s.File, f, fileDeleter.Deleter, deleteFile); err != nil { return err } diff --git a/pkg/image/export_test.go b/pkg/image/export_test.go index 7f3393d6f..64a0ebb28 100644 --- a/pkg/image/export_test.go +++ b/pkg/image/export_test.go @@ -45,11 +45,9 @@ var ( func createFullImage(id int) models.Image { return models.Image{ ID: id, - Files: models.NewRelatedImageFiles([]*file.ImageFile{ - { - BaseFile: &file.BaseFile{ - Path: path, - }, + Files: models.NewRelatedFiles([]file.File{ + &file.BaseFile{ + Path: path, }, }), Title: title, diff --git a/pkg/image/import.go b/pkg/image/import.go index b5e54e594..6dfc0bde8 100644 --- a/pkg/image/import.go +++ b/pkg/image/import.go @@ -97,7 +97,7 @@ func (i *Importer) imageJSONToImage(imageJSON jsonschema.Image) models.Image { } func (i *Importer) populateFiles(ctx context.Context) error { - files := make([]*file.ImageFile, 0) + files := make([]file.File, 0) for _, ref := range i.Input.Files { path := ref @@ -109,11 +109,11 @@ func (i *Importer) populateFiles(ctx context.Context) error { if f == nil { return fmt.Errorf("image file '%s' not found", path) } else { - files = append(files, f.(*file.ImageFile)) + files = append(files, f) } } - i.image.Files = models.NewRelatedImageFiles(files) + i.image.Files = models.NewRelatedFiles(files) return nil } @@ -311,7 +311,7 @@ func (i *Importer) FindExistingID(ctx context.Context) (*int, error) { var err error for _, f := range i.image.Files.List() { - existing, err = i.ReaderWriter.FindByFileID(ctx, f.ID) + existing, err = i.ReaderWriter.FindByFileID(ctx, f.Base().ID) if err != nil { return nil, err } diff --git a/pkg/image/scan.go b/pkg/image/scan.go index 20bd609dc..55eafdd97 100644 --- a/pkg/image/scan.go +++ b/pkg/image/scan.go @@ -29,7 +29,7 @@ type FinderCreatorUpdater interface { UpdatePartial(ctx context.Context, id int, updatedImage models.ImagePartial) (*models.Image, error) AddFileID(ctx context.Context, id int, fileID file.ID) error models.GalleryIDLoader - models.ImageFileLoader + models.FileLoader } type GalleryFinderCreator interface { @@ -40,14 +40,17 @@ type GalleryFinderCreator interface { type ScanConfig interface { GetCreateGalleriesFromFolders() bool - IsGenerateThumbnails() bool +} + +type ScanGenerator interface { + Generate(ctx context.Context, i *models.Image, f file.File) error } type ScanHandler struct { CreatorUpdater FinderCreatorUpdater GalleryFinder GalleryFinderCreator - ThumbnailGenerator ThumbnailGenerator + ScanGenerator ScanGenerator ScanConfig ScanConfig @@ -60,6 +63,9 @@ func (h *ScanHandler) validate() error { if h.CreatorUpdater == nil { return errors.New("CreatorUpdater is required") } + if h.ScanGenerator == nil { + return errors.New("ScanGenerator is required") + } if h.GalleryFinder == nil { return errors.New("GalleryFinder is required") } @@ -78,10 +84,7 @@ func (h *ScanHandler) Handle(ctx context.Context, f file.File, oldFile file.File return err } - imageFile, ok := f.(*file.ImageFile) - if !ok { - return ErrNotImageFile - } + imageFile := f.Base() // try to match the file to an image existing, err := h.CreatorUpdater.FindByFileID(ctx, imageFile.ID) @@ -141,22 +144,20 @@ func (h *ScanHandler) Handle(ctx context.Context, f file.File, oldFile file.File } } - if h.ScanConfig.IsGenerateThumbnails() { - // do this after the commit so that the transaction isn't held up - txn.AddPostCommitHook(ctx, func(ctx context.Context) { - for _, s := range existing { - if err := h.ThumbnailGenerator.GenerateThumbnail(ctx, s, imageFile); err != nil { - // just log if cover generation fails. We can try again on rescan - logger.Errorf("Error generating thumbnail for %s: %v", imageFile.Path, err) - } + // do this after the commit so that generation doesn't hold up the transaction + txn.AddPostCommitHook(ctx, func(ctx context.Context) { + for _, s := range existing { + if err := h.ScanGenerator.Generate(ctx, s, f); err != nil { + // just log if cover generation fails. We can try again on rescan + logger.Errorf("Error generating content for %s: %v", imageFile.Path, err) } - }) - } + } + }) return nil } -func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.Image, f *file.ImageFile, updateExisting bool) error { +func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.Image, f *file.BaseFile, updateExisting bool) error { for _, i := range existing { if err := i.LoadFiles(ctx, h.CreatorUpdater); err != nil { return err @@ -164,7 +165,7 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. found := false for _, sf := range i.Files.List() { - if sf.ID == f.Base().ID { + if sf.Base().ID == f.Base().ID { found = true break } diff --git a/pkg/image/service.go b/pkg/image/service.go index 667317735..5aacc4e59 100644 --- a/pkg/image/service.go +++ b/pkg/image/service.go @@ -15,7 +15,7 @@ type FinderByFile interface { type Repository interface { FinderByFile Destroyer - models.ImageFileLoader + models.FileLoader } type Service struct { diff --git a/pkg/image/thumbnail.go b/pkg/image/thumbnail.go index 80c2139cc..ca6fd40b9 100644 --- a/pkg/image/thumbnail.go +++ b/pkg/image/thumbnail.go @@ -12,7 +12,6 @@ import ( "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/ffmpeg/transcoder" "github.com/stashapp/stash/pkg/file" - "github.com/stashapp/stash/pkg/models" ) const ffmpegImageQuality = 5 @@ -27,13 +26,17 @@ var ( ErrNotSupportedForThumbnail = errors.New("unsupported image format for thumbnail") ) -type ThumbnailGenerator interface { - GenerateThumbnail(ctx context.Context, i *models.Image, f *file.ImageFile) error +type ThumbnailEncoder struct { + FFMpeg *ffmpeg.FFMpeg + FFProbe ffmpeg.FFProbe + ClipPreviewOptions ClipPreviewOptions + vips *vipsEncoder } -type ThumbnailEncoder struct { - ffmpeg *ffmpeg.FFMpeg - vips *vipsEncoder +type ClipPreviewOptions struct { + InputArgs []string + OutputArgs []string + Preset string } func GetVipsPath() string { @@ -43,9 +46,11 @@ func GetVipsPath() string { return vipsPath } -func NewThumbnailEncoder(ffmpegEncoder *ffmpeg.FFMpeg) ThumbnailEncoder { +func NewThumbnailEncoder(ffmpegEncoder *ffmpeg.FFMpeg, ffProbe ffmpeg.FFProbe, clipPreviewOptions ClipPreviewOptions) ThumbnailEncoder { ret := ThumbnailEncoder{ - ffmpeg: ffmpegEncoder, + FFMpeg: ffmpegEncoder, + FFProbe: ffProbe, + ClipPreviewOptions: clipPreviewOptions, } vipsPath := GetVipsPath() @@ -61,7 +66,7 @@ func NewThumbnailEncoder(ffmpegEncoder *ffmpeg.FFMpeg) ThumbnailEncoder { // the provided max size. It resizes based on the largest X/Y direction. // It returns nil and an error if an error occurs reading, decoding or encoding // the image, or if the image is not suitable for thumbnails. -func (e *ThumbnailEncoder) GetThumbnail(f *file.ImageFile, maxSize int) ([]byte, error) { +func (e *ThumbnailEncoder) GetThumbnail(f file.File, maxSize int) ([]byte, error) { reader, err := f.Open(&file.OsFS{}) if err != nil { return nil, err @@ -75,47 +80,113 @@ func (e *ThumbnailEncoder) GetThumbnail(f *file.ImageFile, maxSize int) ([]byte, data := buf.Bytes() - format := f.Format - animated := f.Format == formatGif + if imageFile, ok := f.(*file.ImageFile); ok { + format := imageFile.Format + animated := imageFile.Format == formatGif - // #2266 - if image is webp, then determine if it is animated - if format == formatWebP { - animated = isWebPAnimated(data) + // #2266 - if image is webp, then determine if it is animated + if format == formatWebP { + animated = isWebPAnimated(data) + } + + // #2266 - don't generate a thumbnail for animated images + if animated { + return nil, fmt.Errorf("%w: %s", ErrNotSupportedForThumbnail, format) + } } - // #2266 - don't generate a thumbnail for animated images - if animated { - return nil, fmt.Errorf("%w: %s", ErrNotSupportedForThumbnail, format) + // Videofiles can only be thumbnailed with ffmpeg + if _, ok := f.(*file.VideoFile); ok { + return e.ffmpegImageThumbnail(buf, maxSize) } // vips has issues loading files from stdin on Windows if e.vips != nil && runtime.GOOS != "windows" { return e.vips.ImageThumbnail(buf, maxSize) } else { - return e.ffmpegImageThumbnail(buf, format, maxSize) + return e.ffmpegImageThumbnail(buf, maxSize) } } -func (e *ThumbnailEncoder) ffmpegImageThumbnail(image *bytes.Buffer, format string, maxSize int) ([]byte, error) { - var ffmpegFormat ffmpeg.ImageFormat +// GetPreview returns the preview clip of the provided image clip resized to +// the provided max size. It resizes based on the largest X/Y direction. +// It returns nil and an error if an error occurs reading, decoding or encoding +// the image, or if the image is not suitable for thumbnails. +// It is hardcoded to 30 seconds maximum right now +func (e *ThumbnailEncoder) GetPreview(f file.File, maxSize int) ([]byte, error) { + reader, err := f.Open(&file.OsFS{}) + if err != nil { + return nil, err + } + defer reader.Close() - switch format { - case "jpeg": - ffmpegFormat = ffmpeg.ImageFormatJpeg - case "png": - ffmpegFormat = ffmpeg.ImageFormatPng - case "webp": - ffmpegFormat = ffmpeg.ImageFormatWebp - default: - return nil, ErrUnsupportedImageFormat + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(reader); err != nil { + return nil, err } + fileData, err := e.FFProbe.NewVideoFile(f.Base().Path) + if err != nil { + return nil, err + } + if fileData.Width <= maxSize { + maxSize = fileData.Width + } + clipDuration := fileData.VideoStreamDuration + if clipDuration > 30.0 { + clipDuration = 30.0 + } + return e.getClipPreview(buf, maxSize, clipDuration, fileData.FrameRate) +} + +func (e *ThumbnailEncoder) ffmpegImageThumbnail(image *bytes.Buffer, maxSize int) ([]byte, error) { args := transcoder.ImageThumbnail("-", transcoder.ImageThumbnailOptions{ - InputFormat: ffmpegFormat, + OutputFormat: ffmpeg.ImageFormatJpeg, OutputPath: "-", MaxDimensions: maxSize, Quality: ffmpegImageQuality, }) - return e.ffmpeg.GenerateOutput(context.TODO(), args, image) + return e.FFMpeg.GenerateOutput(context.TODO(), args, image) +} + +func (e *ThumbnailEncoder) getClipPreview(image *bytes.Buffer, maxSize int, clipDuration float64, frameRate float64) ([]byte, error) { + var thumbFilter ffmpeg.VideoFilter + thumbFilter = thumbFilter.ScaleMaxSize(maxSize) + + var thumbArgs ffmpeg.Args + thumbArgs = thumbArgs.VideoFilter(thumbFilter) + + o := e.ClipPreviewOptions + + thumbArgs = append(thumbArgs, + "-pix_fmt", "yuv420p", + "-preset", o.Preset, + "-crf", "25", + "-threads", "4", + "-strict", "-2", + "-f", "webm", + ) + + if frameRate <= 0.01 { + thumbArgs = append(thumbArgs, "-vsync", "2") + } + + thumbOptions := transcoder.TranscodeOptions{ + OutputPath: "-", + StartTime: 0, + Duration: clipDuration, + + XError: true, + SlowSeek: false, + + VideoCodec: ffmpeg.VideoCodecVP9, + VideoArgs: thumbArgs, + + ExtraInputArgs: o.InputArgs, + ExtraOutputArgs: o.OutputArgs, + } + + args := transcoder.Transcode("-", thumbOptions) + return e.FFMpeg.GenerateOutput(context.TODO(), args, image) } diff --git a/pkg/models/generate.go b/pkg/models/generate.go index 2fc66248c..c8fa9785c 100644 --- a/pkg/models/generate.go +++ b/pkg/models/generate.go @@ -18,6 +18,7 @@ type GenerateMetadataOptions struct { Transcodes bool `json:"transcodes"` Phashes bool `json:"phashes"` InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"` + ClipPreviews bool `json:"clipPreviews"` } type GeneratePreviewOptions struct { diff --git a/pkg/models/model_image.go b/pkg/models/model_image.go index 42425c455..e025ba0b1 100644 --- a/pkg/models/model_image.go +++ b/pkg/models/model_image.go @@ -2,7 +2,6 @@ package models import ( "context" - "errors" "path/filepath" "strconv" "time" @@ -24,7 +23,7 @@ type Image struct { Date *Date `json:"date"` // transient - not persisted - Files RelatedImageFiles + Files RelatedFiles PrimaryFileID *file.ID // transient - path of primary file - empty if no files Path string @@ -39,14 +38,14 @@ type Image struct { PerformerIDs RelatedIDs `json:"performer_ids"` } -func (i *Image) LoadFiles(ctx context.Context, l ImageFileLoader) error { - return i.Files.load(func() ([]*file.ImageFile, error) { +func (i *Image) LoadFiles(ctx context.Context, l FileLoader) error { + return i.Files.load(func() ([]file.File, error) { return l.GetFiles(ctx, i.ID) }) } func (i *Image) LoadPrimaryFile(ctx context.Context, l file.Finder) error { - return i.Files.loadPrimary(func() (*file.ImageFile, error) { + return i.Files.loadPrimary(func() (file.File, error) { if i.PrimaryFileID == nil { return nil, nil } @@ -56,15 +55,11 @@ func (i *Image) LoadPrimaryFile(ctx context.Context, l file.Finder) error { return nil, err } - var vf *file.ImageFile if len(f) > 0 { - var ok bool - vf, ok = f[0].(*file.ImageFile) - if !ok { - return nil, errors.New("not an image file") - } + return f[0], nil } - return vf, nil + + return nil, nil }) } diff --git a/pkg/models/paths/paths_generated.go b/pkg/models/paths/paths_generated.go index aa65ea918..d87e1eed6 100644 --- a/pkg/models/paths/paths_generated.go +++ b/pkg/models/paths/paths_generated.go @@ -78,3 +78,8 @@ func (gp *generatedPaths) GetThumbnailPath(checksum string, width int) string { fname := fmt.Sprintf("%s_%d.jpg", checksum, width) return filepath.Join(gp.Thumbnails, fsutil.GetIntraDir(checksum, thumbDirDepth, thumbDirLength), fname) } + +func (gp *generatedPaths) GetClipPreviewPath(checksum string, width int) string { + fname := fmt.Sprintf("%s_%d.webm", checksum, width) + return filepath.Join(gp.Thumbnails, fsutil.GetIntraDir(checksum, thumbDirDepth, thumbDirLength), fname) +} diff --git a/pkg/models/relationships.go b/pkg/models/relationships.go index b3afcad9e..3975bffc3 100644 --- a/pkg/models/relationships.go +++ b/pkg/models/relationships.go @@ -34,10 +34,6 @@ type VideoFileLoader interface { GetFiles(ctx context.Context, relatedID int) ([]*file.VideoFile, error) } -type ImageFileLoader interface { - GetFiles(ctx context.Context, relatedID int) ([]*file.ImageFile, error) -} - type FileLoader interface { GetFiles(ctx context.Context, relatedID int) ([]file.File, error) } @@ -320,89 +316,6 @@ func (r *RelatedVideoFiles) loadPrimary(fn func() (*file.VideoFile, error)) erro return nil } -type RelatedImageFiles struct { - primaryFile *file.ImageFile - files []*file.ImageFile - primaryLoaded bool -} - -func NewRelatedImageFiles(files []*file.ImageFile) RelatedImageFiles { - ret := RelatedImageFiles{ - files: files, - primaryLoaded: true, - } - - if len(files) > 0 { - ret.primaryFile = files[0] - } - - return ret -} - -// Loaded returns true if the relationship has been loaded. -func (r RelatedImageFiles) Loaded() bool { - return r.files != nil -} - -// Loaded returns true if the primary file relationship has been loaded. -func (r RelatedImageFiles) PrimaryLoaded() bool { - return r.primaryLoaded -} - -// List returns the related files. Panics if the relationship has not been loaded. -func (r RelatedImageFiles) List() []*file.ImageFile { - if !r.Loaded() { - panic("relationship has not been loaded") - } - - return r.files -} - -// Primary returns the primary file. Panics if the relationship has not been loaded. -func (r RelatedImageFiles) Primary() *file.ImageFile { - if !r.PrimaryLoaded() { - panic("relationship has not been loaded") - } - - return r.primaryFile -} - -func (r *RelatedImageFiles) load(fn func() ([]*file.ImageFile, error)) error { - if r.Loaded() { - return nil - } - - var err error - r.files, err = fn() - if err != nil { - return err - } - - if len(r.files) > 0 { - r.primaryFile = r.files[0] - } - - r.primaryLoaded = true - - return nil -} - -func (r *RelatedImageFiles) loadPrimary(fn func() (*file.ImageFile, error)) error { - if r.PrimaryLoaded() { - return nil - } - - var err error - r.primaryFile, err = fn() - if err != nil { - return err - } - - r.primaryLoaded = true - - return nil -} - type RelatedFiles struct { primaryFile file.File files []file.File diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 58ec592a9..f22cacf92 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -241,7 +241,7 @@ func (qb *ImageStore) Update(ctx context.Context, updatedObject *models.Image) e if updatedObject.Files.Loaded() { fileIDs := make([]file.ID, len(updatedObject.Files.List())) for i, f := range updatedObject.Files.List() { - fileIDs[i] = f.ID + fileIDs[i] = f.Base().ID } if err := imagesFilesTableMgr.replaceJoins(ctx, updatedObject.ID, fileIDs); err != nil { @@ -360,7 +360,7 @@ func (qb *ImageStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*mo return ret, nil } -func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]*file.ImageFile, error) { +func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]file.File, error) { fileIDs, err := qb.filesRepository().get(ctx, id) if err != nil { return nil, err @@ -372,16 +372,7 @@ func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]*file.ImageFile, return nil, err } - ret := make([]*file.ImageFile, len(files)) - for i, f := range files { - var ok bool - ret[i], ok = f.(*file.ImageFile) - if !ok { - return nil, fmt.Errorf("expected file to be *file.ImageFile not %T", f) - } - } - - return ret, nil + return files, nil } func (qb *ImageStore) GetManyFileIDs(ctx context.Context, ids []int) ([][]file.ID, error) { diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index 31f6d4876..1a0fceb29 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -97,7 +97,7 @@ func Test_imageQueryBuilder_Create(t *testing.T) { Organized: true, OCounter: ocounter, StudioID: &studioIDs[studioIdxWithImage], - Files: models.NewRelatedImageFiles([]*file.ImageFile{ + Files: models.NewRelatedFiles([]file.File{ imageFile.(*file.ImageFile), }), PrimaryFileID: &imageFile.Base().ID, @@ -149,7 +149,7 @@ func Test_imageQueryBuilder_Create(t *testing.T) { var fileIDs []file.ID if tt.newObject.Files.Loaded() { for _, f := range tt.newObject.Files.List() { - fileIDs = append(fileIDs, f.ID) + fileIDs = append(fileIDs, f.Base().ID) } } s := tt.newObject @@ -444,7 +444,7 @@ func Test_imageQueryBuilder_UpdatePartial(t *testing.T) { Organized: true, OCounter: ocounter, StudioID: &studioIDs[studioIdxWithImage], - Files: models.NewRelatedImageFiles([]*file.ImageFile{ + Files: models.NewRelatedFiles([]file.File{ makeImageFile(imageIdx1WithGallery), }), CreatedAt: createdAt, @@ -462,7 +462,7 @@ func Test_imageQueryBuilder_UpdatePartial(t *testing.T) { models.Image{ ID: imageIDs[imageIdx1WithGallery], OCounter: getOCounter(imageIdx1WithGallery), - Files: models.NewRelatedImageFiles([]*file.ImageFile{ + Files: models.NewRelatedFiles([]file.File{ makeImageFile(imageIdx1WithGallery), }), GalleryIDs: models.NewRelatedIDs([]int{}), @@ -965,7 +965,7 @@ func makeImageWithID(index int) *models.Image { ret := makeImage(index, true) ret.ID = imageIDs[index] - ret.Files = models.NewRelatedImageFiles([]*file.ImageFile{makeImageFile(index)}) + ret.Files = models.NewRelatedFiles([]file.File{makeImageFile(index)}) return ret } @@ -1868,8 +1868,11 @@ func verifyImagesResolution(t *testing.T, resolution models.ResolutionEnum) { t.Errorf("Error loading primary file: %s", err.Error()) return nil } - - verifyImageResolution(t, image.Files.Primary().Height, resolution) + asFrame, ok := image.Files.Primary().(file.VisualFile) + if !ok { + t.Errorf("Error: Associated primary file of image is not of type VisualFile") + } + verifyImageResolution(t, asFrame.GetHeight(), resolution) } return nil diff --git a/scripts/test_db_generator/makeTestDB.go b/scripts/test_db_generator/makeTestDB.go index bfdb042df..a54e07a87 100644 --- a/scripts/test_db_generator/makeTestDB.go +++ b/scripts/test_db_generator/makeTestDB.go @@ -347,6 +347,10 @@ func getResolution() (int, int) { return w, h } +func getBool() { + return rand.Intn(2) == 0 +} + func getDate() time.Time { s := rand.Int63n(time.Now().Unix()) @@ -371,6 +375,7 @@ func generateImageFile(parentFolderID file.FolderID, path string) file.File { BaseFile: generateBaseFile(parentFolderID, path), Height: h, Width: w, + Clip: getBool(), } } diff --git a/ui/v2.5/src/components/Galleries/GalleryViewer.tsx b/ui/v2.5/src/components/Galleries/GalleryViewer.tsx index 5eb9deae6..3a860e48b 100644 --- a/ui/v2.5/src/components/Galleries/GalleryViewer.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryViewer.tsx @@ -67,8 +67,8 @@ export const GalleryViewer: React.FC = ({ galleryId }) => { images.forEach((image, index) => { let imageData = { src: image.paths.thumbnail!, - width: image.files[0].width, - height: image.files[0].height, + width: image.visual_files[0].width, + height: image.visual_files[0].height, tabIndex: index, key: image.id ?? index, loading: "lazy", diff --git a/ui/v2.5/src/components/Help/Manual.tsx b/ui/v2.5/src/components/Help/Manual.tsx index f9f18fa68..0004325cf 100644 --- a/ui/v2.5/src/components/Help/Manual.tsx +++ b/ui/v2.5/src/components/Help/Manual.tsx @@ -6,7 +6,7 @@ import AutoTagging from "src/docs/en/Manual/AutoTagging.md"; import JSONSpec from "src/docs/en/Manual/JSONSpec.md"; import Configuration from "src/docs/en/Manual/Configuration.md"; import Interface from "src/docs/en/Manual/Interface.md"; -import Galleries from "src/docs/en/Manual/Galleries.md"; +import Images from "src/docs/en/Manual/Images.md"; import Scraping from "src/docs/en/Manual/Scraping.md"; import ScraperDevelopment from "src/docs/en/Manual/ScraperDevelopment.md"; import Plugins from "src/docs/en/Manual/Plugins.md"; @@ -88,9 +88,9 @@ export const Manual: React.FC = ({ content: Browsing, }, { - key: "Galleries.md", - title: "Image Galleries", - content: Galleries, + key: "Images.md", + title: "Images and Galleries", + content: Images, }, { key: "Scraping.md", diff --git a/ui/v2.5/src/components/Images/ImageCard.tsx b/ui/v2.5/src/components/Images/ImageCard.tsx index 50ae8bcc4..28598d417 100644 --- a/ui/v2.5/src/components/Images/ImageCard.tsx +++ b/ui/v2.5/src/components/Images/ImageCard.tsx @@ -30,7 +30,10 @@ export const ImageCard: React.FC = ( props: IImageCardProps ) => { const file = useMemo( - () => (props.image.files.length > 0 ? props.image.files[0] : undefined), + () => + props.image.visual_files.length > 0 + ? props.image.visual_files[0] + : undefined, [props.image] ); @@ -138,6 +141,13 @@ export const ImageCard: React.FC = ( return height > width; } + const source = + props.image.paths.preview != "" + ? props.image.paths.preview ?? "" + : props.image.paths.thumbnail ?? ""; + const video = source.includes("preview"); + const ImagePreview = video ? "video" : "img"; + return ( = ( image={ <>
- {props.image.title {props.onPreview ? (
diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index eb3d1211c..dda47e9d2 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -51,7 +51,7 @@ export const Image: React.FC = () => { const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); async function onRescan() { - if (!image || !image.files.length) { + if (!image || !image.visual_files.length) { return; } @@ -181,8 +181,8 @@ export const Image: React.FC = () => { - {image.files.length > 1 && ( - + {image.visual_files.length > 1 && ( + )} @@ -260,6 +260,8 @@ export const Image: React.FC = () => { } const title = objectTitle(image); + const ImageView = + image.visual_files[0].__typename == "VideoFile" ? "video" : "img"; return (
@@ -286,8 +288,16 @@ export const Image: React.FC = () => { {renderTabs()}
- {title} diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx index 026c51dea..2b906c6d5 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx @@ -10,7 +10,7 @@ import TextUtils from "src/utils/text"; import { TextField, URLField } from "src/utils/field"; interface IFileInfoPanelProps { - file: GQL.ImageFileDataFragment; + file: GQL.ImageFileDataFragment | GQL.VideoFileDataFragment; primary?: boolean; ofMany?: boolean; onSetPrimaryFile?: () => void; @@ -110,17 +110,17 @@ export const ImageFileInfoPanel: React.FC = ( const [loading, setLoading] = useState(false); const [deletingFile, setDeletingFile] = useState< - GQL.ImageFileDataFragment | undefined + GQL.ImageFileDataFragment | GQL.VideoFileDataFragment | undefined >(); - if (props.image.files.length === 0) { + if (props.image.visual_files.length === 0) { return <>; } - if (props.image.files.length === 1) { + if (props.image.visual_files.length === 1) { return ( <> - + {props.image.url ? (
@@ -150,14 +150,14 @@ export const ImageFileInfoPanel: React.FC = ( } return ( - + {deletingFile && ( setDeletingFile(undefined)} selected={[deletingFile]} /> )} - {props.image.files.map((file, index) => ( + {props.image.visual_files.map((file, index) => ( diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index 2b3cc8c46..2b3b359a6 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -22,6 +22,7 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { ImageCard } from "./ImageCard"; +import { ImageWallItem } from "./ImageWallItem"; import { EditImagesDialog } from "./EditImagesDialog"; import { DeleteImagesDialog } from "./DeleteImagesDialog"; import "flexbin/flexbin.css"; @@ -56,9 +57,12 @@ const ImageWall: React.FC = ({ images, handleImageOpen }) => { images.forEach((image, index) => { let imageData = { - src: image.paths.thumbnail!, - width: image.files[0].width, - height: image.files[0].height, + src: + image.paths.preview != "" + ? image.paths.preview! + : image.paths.thumbnail!, + width: image.visual_files[0].width, + height: image.visual_files[0].height, tabIndex: index, key: image.id, loading: "lazy", @@ -86,6 +90,7 @@ const ImageWall: React.FC = ({ images, handleImageOpen }) => { {photos.length ? ( = ( + props: IImageWallProps +) => { + type style = Record; + var imgStyle: style = { + margin: props.margin, + display: "block", + }; + + if (props.direction === "column") { + imgStyle.position = "absolute"; + imgStyle.left = props.left; + imgStyle.top = props.top; + } + + var handleClick = function handleClick( + event: React.MouseEvent + ) { + if (props.onClick) { + props.onClick(event, { index: props.index }); + } + }; + + const video = props.photo.src.includes("preview"); + const ImagePreview = video ? "video" : "img"; + + return ( + + ); +}; diff --git a/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx b/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx index b91f73f8b..d8cc0f67c 100644 --- a/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsLibraryPanel.tsx @@ -130,6 +130,14 @@ export const SettingsLibraryPanel: React.FC = () => { onChange={(v) => saveGeneral({ writeImageThumbnails: v })} /> + saveGeneral({ createImageClipsFromVideos: v })} + /> + = ({ headingID="dialogs.scene_gen.interactive_heatmap_speed" onChange={(v) => setOptions({ interactiveHeatmapsSpeeds: v })} /> + setOptions({ clipPreviews: v })} + /> = ({ scanGenerateSprites, scanGeneratePhashes, scanGenerateThumbnails, + scanGenerateClipPreviews, } = options; function setOptions(input: Partial) { @@ -68,6 +69,12 @@ export const ScanOptions: React.FC = ({ headingID="config.tasks.generate_thumbnails_during_scan" onChange={(v) => setOptions({ scanGenerateThumbnails: v })} /> + setOptions({ scanGenerateClipPreviews: v })} + /> ); }; diff --git a/ui/v2.5/src/core/createClient.ts b/ui/v2.5/src/core/createClient.ts index c48fa480b..b6601a6cc 100644 --- a/ui/v2.5/src/core/createClient.ts +++ b/ui/v2.5/src/core/createClient.ts @@ -88,6 +88,10 @@ const typePolicies: TypePolicies = { }, }; +const possibleTypes = { + VisualFile: ["VideoFile", "ImageFile"], +}; + export const baseURL = document.querySelector("base")?.getAttribute("href") ?? "/"; @@ -156,7 +160,10 @@ export const createClient = () => { const link = from([errorLink, splitLink]); - const cache = new InMemoryCache({ typePolicies }); + const cache = new InMemoryCache({ + typePolicies, + possibleTypes: possibleTypes, + }); const client = new ApolloClient({ link, cache, diff --git a/ui/v2.5/src/docs/en/Manual/Galleries.md b/ui/v2.5/src/docs/en/Manual/Galleries.md deleted file mode 100644 index c31e2b1c4..000000000 --- a/ui/v2.5/src/docs/en/Manual/Galleries.md +++ /dev/null @@ -1,12 +0,0 @@ -# Galleries - -**Note:** images are now included during the scan process and are loaded independently of galleries. It is _no longer necessary_ to have images in zip files to be scanned into your library. - -Galleries are automatically created from zip files found during scanning that contain images. It is also possible to automatically create galleries from folders containing images, by selecting the "Create galleries from folders containing images" checkbox in the Configuration page. It is also possible to manually create galleries. - -For best results, images in zip file should be stored without compression (copy, store or no compression options depending on the software you use. Eg on linux: `zip -0 -r gallery.zip foldertozip/`). This impacts **heavily** on the zip read performance. - -If a filename of an image in the gallery zip file ends with `cover.jpg`, it will be treated like a cover and presented first in the gallery view page and as a gallery cover in the gallery list view. If more than one images match the name the first one found in natural sort order is selected. - -Images can be added to a gallery by navigating to the gallery's page, selecting the "Add" tab, querying for and selecting the images to add, then selecting "Add to Gallery" from the `...` menu button. Likewise, images may be removed from a gallery by selecting the "Images" tab, selecting the images to remove and selecting "Remove from Gallery" from the `...` menu button. - diff --git a/ui/v2.5/src/docs/en/Manual/Images.md b/ui/v2.5/src/docs/en/Manual/Images.md new file mode 100644 index 000000000..7b384596b --- /dev/null +++ b/ui/v2.5/src/docs/en/Manual/Images.md @@ -0,0 +1,27 @@ +# Images and Galleries + +Images are the parts which make up galleries, but you can also have them be scanned independently. To declare an image part of a gallery, there are four ways: + +1. Group them in a folder together and activate the **Create galleries from folders containing images** option in the library section of your settings. The gallery will get the name of the folder. +2. Group them in a folder together and create a file in the folder called .forcegallery. The gallery will get the name of the folder. +3. Group them into a zip archive together. The gallery will get the name of the archive. +4. You can simply create a gallery in stash itself by clicking on **New** in the Galleries tab. + +You can add images to every gallery manually in the gallery detail page. Deleting can be done by selecting the according images in the same view and clicking on the minus next to the edit button. + +For best results, images in zip file should be stored without compression (copy, store or no compression options depending on the software you use. Eg on linux: `zip -0 -r gallery.zip foldertozip/`). This impacts **heavily** on the zip read performance. + +If a filename of an image in the gallery zip file ends with `cover.jpg`, it will be treated like a cover and presented first in the gallery view page and as a gallery cover in the gallery list view. If more than one images match the name the first one found in natural sort order is selected. + +## Image clips/gifs + +Images can also be clips/gifs. These are meant to be short video loops. Right now they are not possible in zipfiles. To declare video files to be images, there are two ways: + +1. Deactivate video scanning for all libraries that contain clips/gifs, but keep image scanning active. Set the **Scan Video Extensions as Image Clip** option in the library section of your settings. +2. Make sure none of the file endings used by your clips/gifs are present in the **Video Extensions** and add them to the **Image Extensions** in the library section of your settings. + +A clip/gif will be a stillframe in the wall and grid view by default. To view the loop, you can go into the Lightbox Carousel (e.g. by clicking on an image in the wall view) or the image detail page. + +If you want the loop to be used as a preview on the wall and grid view, you will have to generate them. +You can do this as you scan for the new clip file by activating **Generate previews for image clips** on the scan settings, or do it after by going to the **Generated Content** section in the task section of your settings, activating **Image Clip Previews** and clicking generate. This takes a while, as the files are transcoded. + diff --git a/ui/v2.5/src/docs/en/Manual/Tasks.md b/ui/v2.5/src/docs/en/Manual/Tasks.md index f7df798f9..2856306ff 100644 --- a/ui/v2.5/src/docs/en/Manual/Tasks.md +++ b/ui/v2.5/src/docs/en/Manual/Tasks.md @@ -20,6 +20,7 @@ The scan task accepts the following options: | Generate scrubber sprites | Generates sprites for the scene scrubber. | | Generate perceptual hashes | Generates perceptual hashes for scene deduplication and identification. | | Generate thumbnails for images | Generates thumbnails for image files. | +| Generate previews for image clips | Generates a gif/looping video as thumbnail for image clips/gifs. | # Auto Tagging See the [Auto Tagging](/help/AutoTagging.md) page. @@ -51,6 +52,7 @@ The generate task accepts the following options: | Transcodes | MP4 conversions of unsupported video formats. Allows direct streaming instead of live transcoding. | | Perceptual hashes (for deduplication) | Generates perceptual hashes for scene deduplication and identification. | | Generate heatmaps and speeds for interactive scenes | Generates heatmaps and speeds for interactive scenes. | +| Image Clip Previews | Generates a gif/looping video as thumbnail for image clips/gifs. | | Overwrite existing generated files | By default, where a generated file exists, it is not regenerated. When this flag is enabled, then the generated files are regenerated. | ## Transcodes diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index bae92ab0c..8cadd2d54 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -425,20 +425,25 @@ export const LightboxComponent: React.FC = ({ } } - const navItems = images.map((image, i) => ( - + React.createElement(image.paths.preview != "" ? "video" : "img", { + loop: image.paths.preview != "", + autoPlay: image.paths.preview != "", + src: + image.paths.preview != "" + ? image.paths.preview ?? "" + : image.paths.thumbnail ?? "", + alt: "", + className: cx(CLASSNAME_NAVIMAGE, { [CLASSNAME_NAVSELECTED]: i === index, - })} - onClick={(e: React.MouseEvent) => selectIndex(e, i)} - role="presentation" - loading="lazy" - key={image.paths.thumbnail} - onLoad={imageLoaded} - /> - )); + }), + onClick: (e: React.MouseEvent) => selectIndex(e, i), + role: "presentation", + loading: "lazy", + key: image.paths.thumbnail, + onLoad: imageLoaded, + }) + ); const onDelayChange = (e: React.ChangeEvent) => { let numberValue = Number.parseInt(e.currentTarget.value, 10); @@ -845,6 +850,7 @@ export const LightboxComponent: React.FC = ({ scrollAttemptsBeforeChange={scrollAttemptsBeforeChange} setZoom={(v) => setZoom(v)} resetPosition={resetPosition} + isVideo={image.visual_files?.[0]?.__typename == "VideoFile"} /> ) : undefined}
diff --git a/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx b/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx index dcddcbe5d..425a3aacd 100644 --- a/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx +++ b/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx @@ -59,6 +59,7 @@ interface IProps { setZoom: (v: number) => void; onLeft: () => void; onRight: () => void; + isVideo: boolean; } export const LightboxImage: React.FC = ({ @@ -74,6 +75,7 @@ export const LightboxImage: React.FC = ({ current, setZoom, resetPosition, + isVideo, }) => { const [defaultZoom, setDefaultZoom] = useState(1); const [moving, setMoving] = useState(false); @@ -89,7 +91,7 @@ export const LightboxImage: React.FC = ({ const container = React.createRef(); const startPoints = useRef([0, 0]); - const pointerCache = useRef[]>([]); + const pointerCache = useRef([]); const prevDiff = useRef(); const scrollAttempts = useRef(0); @@ -100,6 +102,24 @@ export const LightboxImage: React.FC = ({ setBoxWidth(box.offsetWidth); setBoxHeight(box.offsetHeight); } + + function toggleVideoPlay() { + if (container.current) { + let openVideo = container.current.getElementsByTagName("video"); + if (openVideo.length > 0) { + let rect = openVideo[0].getBoundingClientRect(); + if (Math.abs(rect.x) < document.body.clientWidth / 2) { + openVideo[0].play(); + } else { + openVideo[0].pause(); + } + } + } + } + + setTimeout(() => { + toggleVideoPlay(); + }, 250); }, [container]); useEffect(() => { @@ -233,7 +253,12 @@ export const LightboxImage: React.FC = ({ calculateInitialPosition, ]); - function getScrollMode(ev: React.WheelEvent) { + function getScrollMode( + ev: + | React.WheelEvent + | React.WheelEvent + | React.WheelEvent + ) { if (ev.shiftKey) { switch (scrollMode) { case GQL.ImageLightboxScrollMode.Zoom: @@ -246,14 +271,24 @@ export const LightboxImage: React.FC = ({ return scrollMode; } - function onContainerScroll(ev: React.WheelEvent) { + function onContainerScroll( + ev: + | React.WheelEvent + | React.WheelEvent + | React.WheelEvent + ) { // don't zoom if mouse isn't over image if (getScrollMode(ev) === GQL.ImageLightboxScrollMode.PanY) { onImageScroll(ev); } } - function onImageScrollPanY(ev: React.WheelEvent) { + function onImageScrollPanY( + ev: + | React.WheelEvent + | React.WheelEvent + | React.WheelEvent + ) { if (current) { const [minY, maxY] = minMaxY(zoom * defaultZoom); @@ -298,7 +333,12 @@ export const LightboxImage: React.FC = ({ } } - function onImageScroll(ev: React.WheelEvent) { + function onImageScroll( + ev: + | React.WheelEvent + | React.WheelEvent + | React.WheelEvent + ) { const percent = ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP; switch (getScrollMode(ev)) { @@ -311,7 +351,11 @@ export const LightboxImage: React.FC = ({ } } - function onImageMouseOver(ev: React.MouseEvent) { + function onImageMouseOver( + ev: + | React.MouseEvent + | React.MouseEvent + ) { if (!moving) return; if (!ev.buttons) { @@ -327,14 +371,22 @@ export const LightboxImage: React.FC = ({ setPositionY(positionY + posY); } - function onImageMouseDown(ev: React.MouseEvent) { + function onImageMouseDown( + ev: + | React.MouseEvent + | React.MouseEvent + ) { startPoints.current = [ev.pageX, ev.pageY]; setMoving(true); mouseDownEvent.current = ev.nativeEvent; } - function onImageMouseUp(ev: React.MouseEvent) { + function onImageMouseUp( + ev: + | React.MouseEvent + | React.MouseEvent + ) { if (ev.button !== 0) return; if ( @@ -360,7 +412,12 @@ export const LightboxImage: React.FC = ({ } } - function onTouchStart(ev: React.TouchEvent) { + function onTouchStart( + ev: + | React.TouchEvent + | React.TouchEvent + | React.TouchEvent + ) { ev.preventDefault(); if (ev.touches.length === 1) { startPoints.current = [ev.touches[0].pageX, ev.touches[0].pageY]; @@ -368,7 +425,12 @@ export const LightboxImage: React.FC = ({ } } - function onTouchMove(ev: React.TouchEvent) { + function onTouchMove( + ev: + | React.TouchEvent + | React.TouchEvent + | React.TouchEvent + ) { if (!moving) return; if (ev.touches.length === 1) { @@ -381,7 +443,12 @@ export const LightboxImage: React.FC = ({ } } - function onPointerDown(ev: React.PointerEvent) { + function onPointerDown( + ev: + | React.PointerEvent + | React.PointerEvent + | React.PointerEvent + ) { // replace pointer event with the same id, if applicable pointerCache.current = pointerCache.current.filter( (e) => e.pointerId !== ev.pointerId @@ -391,7 +458,12 @@ export const LightboxImage: React.FC = ({ prevDiff.current = undefined; } - function onPointerUp(ev: React.PointerEvent) { + function onPointerUp( + ev: + | React.PointerEvent + | React.PointerEvent + | React.PointerEvent + ) { for (let i = 0; i < pointerCache.current.length; i++) { if (pointerCache.current[i].pointerId === ev.pointerId) { pointerCache.current.splice(i, 1); @@ -400,7 +472,12 @@ export const LightboxImage: React.FC = ({ } } - function onPointerMove(ev: React.PointerEvent) { + function onPointerMove( + ev: + | React.PointerEvent + | React.PointerEvent + | React.PointerEvent + ) { // find the event in the cache const cachedIndex = pointerCache.current.findIndex( (c) => c.pointerId === ev.pointerId @@ -432,6 +509,17 @@ export const LightboxImage: React.FC = ({ } } + const ImageView = isVideo ? "video" : "img"; + const customStyle = isVideo + ? { + touchAction: "none", + display: "flex", + margin: "auto", + width: "100%", + "max-height": "90vh", + } + : { touchAction: "none" }; + return (
= ({ > {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} - onImageScroll(e) : undefined} onMouseDown={(e) => onImageMouseDown(e)} onMouseUp={(e) => onImageMouseUp(e)} diff --git a/ui/v2.5/src/hooks/Lightbox/types.ts b/ui/v2.5/src/hooks/Lightbox/types.ts index 6b60422fd..f955a060a 100644 --- a/ui/v2.5/src/hooks/Lightbox/types.ts +++ b/ui/v2.5/src/hooks/Lightbox/types.ts @@ -3,6 +3,13 @@ import * as GQL from "src/core/generated-graphql"; interface IImagePaths { image?: GQL.Maybe; thumbnail?: GQL.Maybe; + preview?: GQL.Maybe; +} + +interface IFiles { + __typename?: string; + width: number; + height: number; } export interface ILightboxImage { @@ -11,6 +18,7 @@ export interface ILightboxImage { rating100?: GQL.Maybe; o_counter?: GQL.Maybe; paths: IImagePaths; + visual_files?: GQL.Maybe[]; } export interface IChapter { diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 7226bd4ba..8827d38bc 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -422,6 +422,7 @@ "generating_from_paths": "Generating for scenes from the following paths", "generating_scenes": "Generating for {num} {scene}" }, + "generate_clip_previews_during_scan": "Generate previews for image clips", "generate_desc": "Generate supporting image, sprite, video, vtt and other files.", "generate_phashes_during_scan": "Generate perceptual hashes", "generate_phashes_during_scan_tooltip": "For deduplication and scene identification.", @@ -592,6 +593,10 @@ "write_image_thumbnails": { "description": "Write image thumbnails to disk when generated on-the-fly", "heading": "Write image thumbnails" + }, + "create_image_clips_from_videos": { + "description": "When a library has Videos disabled, Video Files (files ending with Video Extension) will be scanned as Image Clip", + "heading": "Scan Video Extensions as Image Clip" } } }, @@ -799,6 +804,7 @@ "destination": "Reassign to" }, "scene_gen": { + "clip_previews": "Image Clip Previews", "covers": "Scene covers", "force_transcodes": "Force Transcode generation", "force_transcodes_tooltip": "By default, transcodes are only generated when the video file is not supported in the browser. When enabled, transcodes will be generated even when the video file appears to be supported in the browser.", From 11344c51b7b04d9955a1bdf672fe9f9ca5f9173c Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Wed, 17 May 2023 01:33:35 +0200 Subject: [PATCH 48/81] Fix missing tag images (#3736) --- internal/api/resolver_model_tag.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/resolver_model_tag.go b/internal/api/resolver_model_tag.go index f2c677b87..6f74c8d1b 100644 --- a/internal/api/resolver_model_tag.go +++ b/internal/api/resolver_model_tag.go @@ -114,7 +114,7 @@ func (r *tagResolver) ImagePath(ctx context.Context, obj *models.Tag) (*string, var hasImage bool if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error - hasImage, err = r.repository.Performer.HasImage(ctx, obj.ID) + hasImage, err = r.repository.Tag.HasImage(ctx, obj.ID) return err }); err != nil { return nil, err From 9a41841bd207e9721f404b602ea304bbb02e223b Mon Sep 17 00:00:00 2001 From: stash-translation-bot <94573628+stash-translation-bot@users.noreply.github.com> Date: Tue, 16 May 2023 21:32:00 -0700 Subject: [PATCH 49/81] Translations update from Stash (#3665) * Translated using Weblate (Portuguese (Brazil)) Currently translated at 86.9% (832 of 957 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/pt_BR/ * Translated using Weblate (Spanish) Currently translated at 84.3% (807 of 957 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/es/ * Translated using Weblate (Spanish) Currently translated at 89.1% (853 of 957 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/es/ * Translated using Weblate (Spanish) Currently translated at 90.8% (869 of 957 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/es/ * Translated using Weblate (Swedish) Currently translated at 100.0% (957 of 957 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/sv/ * Translated using Weblate (French) Currently translated at 100.0% (957 of 957 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/ * Translated using Weblate (Estonian) Currently translated at 100.0% (957 of 957 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/et/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 95.1% (912 of 958 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hans/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 95.1% (912 of 958 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hans/ * Translated using Weblate (French) Currently translated at 100.0% (958 of 958 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (958 of 958 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hans/ * Translated using Weblate (German) Currently translated at 100.0% (958 of 958 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/de/ * Translated using Weblate (Swedish) Currently translated at 100.0% (958 of 958 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/sv/ * Translated using Weblate (Estonian) Currently translated at 100.0% (958 of 958 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/et/ * Translated using Weblate (Danish) Currently translated at 87.7% (841 of 958 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/da/ * Update translation files Updated by "Remove blank strings" hook in Weblate. Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/ * Translated using Weblate (French) Currently translated at 100.0% (964 of 964 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/ * Translated using Weblate (Estonian) Currently translated at 100.0% (964 of 964 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/et/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 93.1% (898 of 964 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hant/ * Translated using Weblate (Swedish) Currently translated at 100.0% (964 of 964 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/sv/ * Translated using Weblate (French) Currently translated at 100.0% (964 of 964 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 99.3% (958 of 964 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hans/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (964 of 964 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hans/ * Translated using Weblate (Estonian) Currently translated at 100.0% (964 of 964 strings) Translation: Stash/Stash Desktop Client Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/et/ --------- Co-authored-by: Eduardo Souza Co-authored-by: Gabriel Velez Co-authored-by: Weblate Co-authored-by: Alpaca Serious Co-authored-by: MrOV3RDOSE Co-authored-by: Lauri Co-authored-by: JueLuo Co-authored-by: Yeluo Co-authored-by: Dee.H.Y Co-authored-by: Phasetime Co-authored-by: Christoph Holmes Co-authored-by: brestu Co-authored-by: Yesmola Co-authored-by: MoeHero <562416714@qq.com> --- ui/v2.5/src/locales/da-DK.json | 42 ++++- ui/v2.5/src/locales/de-DE.json | 5 +- ui/v2.5/src/locales/es-ES.json | 114 ++++++++++++- ui/v2.5/src/locales/et-EE.json | 19 ++- ui/v2.5/src/locales/fr-FR.json | 19 ++- ui/v2.5/src/locales/pt-BR.json | 21 +++ ui/v2.5/src/locales/sv-SE.json | 19 ++- ui/v2.5/src/locales/zh-CN.json | 286 ++++++++++++++++++++++----------- ui/v2.5/src/locales/zh-TW.json | 6 +- 9 files changed, 401 insertions(+), 130 deletions(-) diff --git a/ui/v2.5/src/locales/da-DK.json b/ui/v2.5/src/locales/da-DK.json index d9698b74b..b83b8b480 100644 --- a/ui/v2.5/src/locales/da-DK.json +++ b/ui/v2.5/src/locales/da-DK.json @@ -6,6 +6,7 @@ "add_to_entity": "Tilføj til {entityType}", "allow": "Tillad", "allow_temporarily": "Tillad midlertidigt", + "anonymise": "Anonymisér", "apply": "Anvend", "auto_tag": "Auto Tagge", "backup": "Backup", @@ -20,6 +21,7 @@ "confirm": "Bekræft", "continue": "Forsæt", "create": "Lav", + "create_chapters": "Skab Kapitel", "create_entity": "Lav {entityType}", "create_marker": "Lav Mærke", "created_entity": "Lavet {entity_type}: {entity_name}", @@ -32,6 +34,7 @@ "delete_stashid": "Slet StashID", "disallow": "Tillad ikke", "download": "Download", + "download_anonymised": "Hent anonymiseret", "download_backup": "Download Backup", "edit": "Ændre", "edit_entity": "Ændre {entityType}", @@ -58,6 +61,8 @@ "merge": "Fusioner", "merge_from": "Fusioner fra", "merge_into": "Fusioner til", + "migrate_blobs": "Migrér Blobs", + "migrate_scene_screenshots": "Migrér Scene-skærmbilleder", "next_action": "Næste", "not_running": "kører ikke", "open_in_external_player": "Åben i ekstern afspiller", @@ -122,14 +127,20 @@ "aliases": "Aliaser", "all": "alt", "also_known_as": "Også kendt som", + "appears_with": "Optræder Med", "ascending": "Stigende", "average_resolution": "Gennemsnitlig Opløsning", "between_and": "og", "birth_year": "Fødselsår", "birthdate": "Fødselsdato", "bitrate": "Bithastighed", + "blobs_storage_type": { + "database": "Database", + "filesystem": "Filsystem" + }, "captions": "Undertekster", "career_length": "Karrierer Længde", + "chapters": "Kapitler", "component_tagger": { "config": { "active_instance": "Aktiv stash-box instans:", @@ -183,6 +194,7 @@ "latest_version": "Seneste Version", "latest_version_build_hash": "Seneste version Byg Hash:", "new_version_notice": "[NY]", + "release_date": "Udgivelsesdato:", "stash_discord": "Tilmeld dig vores {url} kanal", "stash_home": "Stash hjem på {url}", "stash_open_collective": "Støt os gennem {url}", @@ -253,7 +265,15 @@ "description": "Mappelokation for SQLite database backup filer", "heading": "Backup mappesti" }, - "cache_location": "Directory placering af cachen", + "blobs_path": { + "description": "Hvori filsystemet binær data skal lagres. Anvendes kun når blobs lagres i Filsystemet. ADVARSEL: Ændres dette, kræves manuel flytning af eksisterende data.", + "heading": "Binær data filsystem-sti" + }, + "blobs_storage": { + "description": "Hvor binær data, som scene-forsider, performere, studie eller tag-billeder opbevares. Efter denne værdi ændres, skal den eksisterende data migreres med Migrér Blobs-opgaverne. Se Opgaver siden for migrering.", + "heading": "Binær data lagringstype" + }, + "cache_location": "Mappe-placering af cachen. Påkrævet, hvis der streames via HLS (som på Apple-enheder) eller DASH.", "cache_path_head": "Cache Sti", "calculate_md5_and_ohash_desc": "Beregn MD5 kontrolsum ud over oshash. Aktivering vil medføre, at indledende scanninger bliver langsommere. Filnavnehash skal indstilles til oshash for at deaktivere MD5-beregning.", "calculate_md5_and_ohash_label": "Beregn MD5 for videoer", @@ -263,19 +283,31 @@ "chrome_cdp_path_desc": "Filsti til den eksekverbare Chrome-fil eller en ekstern adresse (startende med http:// eller https://, for eksempel http://localhost:9222/json/version) til en Chrome-instans.", "create_galleries_from_folders_desc": "Hvis sandt, opretter gallerier fra mapper, der indeholder billeder.", "create_galleries_from_folders_label": "Opret gallerier fra mapper, der indeholder billeder", + "database": "Database", "db_path_head": "Databasesti", "directory_locations_to_your_content": "Adresser til dit indhold i mappen", "excluded_image_gallery_patterns_desc": "Regexps af billed- og gallerifiler/stier, der skal udelukkes fra Scan og tilføje til Clean", "excluded_image_gallery_patterns_head": "Udelukkede billed-/gallerimønstre", "excluded_video_patterns_desc": "Regexps af videofiler/stier, der skal udelukkes fra Scan og tilføje til Clean", "excluded_video_patterns_head": "Udelukkede videomønstre", + "ffmpeg": { + "hardware_acceleration": { + "desc": "Anvender tilgængelig hardware til live-omkodning af video.", + "heading": "FFmpeg hardware-indkodning" + }, + "live_transcode": { + "input_args": { + "desc": "Avanceret: Yderligere argumenter for videreførsel til ffmpeg forinden inputsfeltet, når live video omkodes.", + "heading": "FFmpeg Live Omkodning Input Argumenter" + } + } + }, "gallery_ext_desc": "Kommasepareret liste over filtypenavne, der vil blive identificeret som galleri-zip-filer.", "gallery_ext_head": "Galleri zip-udvidelser", "generated_file_naming_hash_desc": "Brug MD5 eller oshash til genereret filnavngivning. Ændring af dette kræver, at alle scener har den relevante MD5/oshash-værdi udfyldt. Efter at have ændret denne værdi, skal eksisterende genererede filer migreres eller regenereres. Se siden Opgaver for migrering.", "generated_file_naming_hash_head": "Genereret hash til filnavngivning", "generated_files_location": "Katalogplacering for de genererede filer (scenemarkører, sceneforhåndsvisninger, sprites osv.)", "generated_path_head": "Genereret sti", - "hashing": "Hashing", "image_ext_desc": "Kommasepareret liste over filtypenavne, der vil blive identificeret som billeder.", "image_ext_head": "Billedudvidelser", "include_audio_desc": "Inkluderer lydstream ved generering af forhåndsvisninger.", @@ -769,7 +801,7 @@ "gender": "Køn", "gender_types": { "FEMALE": "Kvinde", - "INTERSEX": "Intersex", + "INTERSEX": "Interkønnet", "MALE": "Mand", "NON_BINARY": "Ikke-binær", "TRANSGENDER_FEMALE": "Transkønnet kvinde", @@ -895,7 +927,7 @@ "resolution": "Opløsning", "scene": "Scene", "sceneTagger": "Scenetagger", - "sceneTags": "Scene Tags", + "sceneTags": "Scene-etiketter", "scene_count": "Scene antal", "scene_id": "Scene-id", "scenes": "Scener", @@ -1009,7 +1041,7 @@ "sub_tag_of": "Under-tag til {parent}", "sub_tags": "Under-tags", "subsidiary_studios": "Underliggende Studier", - "synopsis": "Synopsis", + "synopsis": "Synopse", "tag": "Tag", "tag_count": "Tag Antal", "tags": "Tags", diff --git a/ui/v2.5/src/locales/de-DE.json b/ui/v2.5/src/locales/de-DE.json index b4dc9c864..ea3b4289e 100644 --- a/ui/v2.5/src/locales/de-DE.json +++ b/ui/v2.5/src/locales/de-DE.json @@ -127,6 +127,7 @@ "aliases": "Aliase", "all": "Alle", "also_known_as": "Auch bekannt unter", + "appears_with": "Tritt auf mit", "ascending": "Aufsteigend", "average_resolution": "Durchschnittliche Auflösung", "between_and": "und", @@ -306,8 +307,8 @@ }, "transcode": { "input_args": { - "desc": "Erweitert: Zusätzliche Parameter für die Live-Transkodierung mit ffmpeg, welche vor dem Eingabefeld übergeben werden können.", - "heading": "FFmpeg Live-Transkodierung Eingangsparameter" + "desc": "Erweitert: Zusätzliche Parameter für die Video-Generierung mit ffmpeg, welche vor dem Eingabefeld übergeben werden können.", + "heading": "FFmpeg Transkodierung Eingangsparameter" }, "output_args": { "desc": "Erweitert: Zusätzliche Parameter für die Videogenerierung mit ffmpeg, welche vor dem Ausgabefeld übergeben werden können.", diff --git a/ui/v2.5/src/locales/es-ES.json b/ui/v2.5/src/locales/es-ES.json index 2f115be11..50f88ceeb 100644 --- a/ui/v2.5/src/locales/es-ES.json +++ b/ui/v2.5/src/locales/es-ES.json @@ -6,6 +6,7 @@ "add_to_entity": "Añadir a {entityType}", "allow": "Permitir", "allow_temporarily": "Permitir temporalmente", + "anonymise": "Anonimizar", "apply": "Aplicar", "auto_tag": "Etiquetado automático", "backup": "Copia de seguridad", @@ -20,6 +21,7 @@ "confirm": "Confirmar", "continue": "Continuar", "create": "Crear", + "create_chapters": "Crear capítulo", "create_entity": "Crear {entityType}", "create_marker": "Crear marcador", "created_entity": "{entity_type} creado: {entity_name}", @@ -32,6 +34,7 @@ "delete_stashid": "Eliminar StashID", "disallow": "No permitir/Denegar", "download": "Descargar", + "download_anonymised": "Descargar anonimizado", "download_backup": "Descargar copia de seguridad", "edit": "Editar", "edit_entity": "Editar {entityType}", @@ -54,9 +57,12 @@ "import": "Importar…", "import_from_file": "Importar desde archivo", "logout": "Cerrar sesión", + "make_primary": "Establecer como primario", "merge": "Unir", "merge_from": "Fusionar desde (origen)", "merge_into": "Fusionar en (destino)", + "migrate_blobs": "Migrar blobs", + "migrate_scene_screenshots": "Migrar capturas de pantalla", "next_action": "Próximo", "not_running": "Apagado", "open_in_external_player": "Abrir en reproductor externo", @@ -127,8 +133,13 @@ "birth_year": "Año de nacimiento", "birthdate": "Cumpleaños", "bitrate": "Tasa de bits", + "blobs_storage_type": { + "database": "Base de datos", + "filesystem": "Sistema de ficheros" + }, "captions": "Subtítulos", "career_length": "Años en activo", + "chapters": "Capítulos", "component_tagger": { "config": { "active_instance": "Instancia activa de stash-box:", @@ -159,7 +170,7 @@ "duration_unknown": "Duración desconocida", "fp_found": "{fpCount, plural, =0 {No se han encontrado nuevas huellas dactilares} other {# resultados de huellas dactilares encontrados}}", "fp_matches": "La duración coincide", - "fp_matches_multi": "La duración coincide {matchCount}/{durationsLength} huella(s) dactilar(es)", + "fp_matches_multi": "{matchCount}/{durationsLength} cincidencias de fingerprint(s)", "hash_matches": "{hash_type} coincide", "match_failed_already_tagged": "Escena ya etiquetada", "match_failed_no_result": "No se han encontrado resultados", @@ -182,6 +193,7 @@ "latest_version": "Última Versión", "latest_version_build_hash": "Hash de la última versión:", "new_version_notice": "[NUEVA]", + "release_date": "Fecha de publicación:", "stash_discord": "Únete a nuestro canal {url}", "stash_home": "Página principal del proyecto en {url}", "stash_open_collective": "Donaciones al proyecto a través de {url}", @@ -252,7 +264,15 @@ "description": "Ubicación del directorio para copias de seguridad de archivos de bases de datos SQLite", "heading": "Ruta del directorio de la copia de seguridad" }, - "cache_location": "Ruta relativa del directorio donde se almacenarán los ficheros de la caché", + "blobs_path": { + "description": "Donde almacenar ficheros binarios. Solo aplicable cuando el sistema de archivos es del tipo \"blob\". AVISO: Cambiar este parámetro requiere mover manualmente los ficheros.", + "heading": "Ruta de sistema de archivos binario" + }, + "blobs_storage": { + "description": "Donde almacenar información binaria como por ejemplo imágenes de escenas, actores, estudios y etiquetas. Tras cambiar este valor, los datos existentes tienen que ser migrados usando la tarea \"Migrar blobs\". Ver la página \"Tareas\" para la migración.", + "heading": "Almacenamiento de datos binario" + }, + "cache_location": "Ruta de la caché. Requerido para utilizar streaming mediante HLS (por ejemplo dispositivos Apple) o DASH.", "cache_path_head": "Ruta relative para la caché", "calculate_md5_and_ohash_desc": "Calcular comprobación MD5 en adición a oshash. Habilitar esta opción puede provocar que los escaneos iniciales resulten más lentos. El cálculo de hash del nombre del fichero debe establecerse en oshash para deshabilitar el cálculo MD5.", "calculate_md5_and_ohash_label": "Calcular MD5 para los vídeos", @@ -262,12 +282,43 @@ "chrome_cdp_path_desc": "Ruta del archivo del ejecutable Chrome o una dirección remota (comenzando por http:// o https://, por ejemplo, http://localhost:9222/json/version) para una instancia Chrome.", "create_galleries_from_folders_desc": "Si esta opción está marcada se crearán automáticamente galerías de aquellos directorios que contienen imágenes.", "create_galleries_from_folders_label": "Crear galerías desde directorios con imágenes", + "database": "Base de datos", "db_path_head": "Ruta de la base de datos", "directory_locations_to_your_content": "Ruta relativa de los directorios que almacenan el contenido", "excluded_image_gallery_patterns_desc": "Expresiones regulares de archivos/rutas de imágenes que serán excluidos del escaneo y que serán añadidos a la tarea de depuración/limpieza", "excluded_image_gallery_patterns_head": "Patrones de imágenes/galerías excluidos", "excluded_video_patterns_desc": "Expresiones regulares de archivos/rutas de vídeos que serán excluidos del escaneo y que serán añadidos a la tarea de depuración/limpieza", "excluded_video_patterns_head": "Patrones de vídeo excluidos", + "ffmpeg": { + "hardware_acceleration": { + "desc": "Utiliza el hardware disponible para encodificar el video en tiempo real.", + "heading": "Encodificación hardware FFmpeg" + }, + "live_transcode": { + "input_args": { + "desc": "Avanzado: Argumentos adicionales para ffmpeg antes del campo de entrada al transcodificar vídeos en tiempo real.", + "heading": "Argumentos entrada transcodificación FFmpeg en tiempo real" + }, + "output_args": { + "desc": "Avanzado: Argumentos adicionales para ffmpeg antes del campo de salida al transcodificar vídeos en tiempo real.", + "heading": "Argumentos salida transcodificación FFmpeg en tiempo real" + } + }, + "transcode": { + "input_args": { + "desc": "Avanzado: Argumentos adicionales para ffmpeg antes del campo de entrada al generar vídeos.", + "heading": "Argumentos entrada transcodificación FFmpeg" + }, + "output_args": { + "desc": "Avanzado: Argumentos adicionales para ffmpeg antes del campo de salida al generar vídeos.", + "heading": "Argumentos salida transcodificación FFmpeg" + } + } + }, + "funscript_heatmap_draw_range": "Inluir rango en los mapas de calor generados", + "funscript_heatmap_draw_range_desc": "Dibujar rango de movimiento en el eje \"y\" del mapa de calor generado. Los mapas de calor existentes tendrán que ser generados de nuevo tras el cambio.", + "gallery_cover_regex_desc": "Expresión regular utilizada para identificar una imagen como carátula de una galería", + "gallery_cover_regex_label": "Patrón carátula galería", "gallery_ext_desc": "Lista delimitada por comas de extensiones de archivo que serán identificados como archivos de galería en formato zip.", "gallery_ext_head": "Extensiones de galería zip", "generated_file_naming_hash_desc": "Usar MD5 o oshash para la los nombres de archivo generados. Cambiar esta opción requiere que todas las escenas tengan relleno el correspondiente valor MD5/oshash. Después de cambiar este valor los ficheros generados existentes tendrán que ser migrados o regenerados. Ver la página de tareas para llevar a cabo la migración.", @@ -275,6 +326,7 @@ "generated_files_location": "Ruta relativa del directorio para los ficheros generados (marcadores de escena, vistas previas de escena, conjuntos de imágenes o “sprites”, etc)", "generated_path_head": "Ruta relativa para el directorio de arvhivos generados", "hashing": "Hashing", + "heatmap_generation": "Generación mapa de calor Funscript", "image_ext_desc": "Lista delimitada por comas de las extensiones de archivo que serán identificadas como imágenes.", "image_ext_head": "Extensiones de imagen", "include_audio_desc": "Incluye flujo de audio cuando se generen vistas previas.", @@ -303,7 +355,7 @@ "heading": "Ruta de Rastreadores" }, "scraping": "Rastreo", - "sqlite_location": "Ruta relativa para la base de datos SQLite (requiere reinicio)", + "sqlite_location": "Ruta relativa para la base de datos SQLite (requiere reinicio). AVISO: Almacenar la base de datos en un sistema distinto al servidor Stash (por ejemplo a través de la red) no está soportado!", "video_ext_desc": "Lista delimitada por comas de las extensiones de archivo que serán identificadas como vídeos.", "video_ext_head": "Extensiones de vídeo", "video_head": "Vídeo" @@ -345,6 +397,9 @@ }, "tasks": { "added_job_to_queue": "Añadido/a {operation_name} a la cola de trabajo", + "anonymise_and_download": "Realiza una copia aninimizada de la base de datos y descarga el fichero resultante.", + "anonymise_database": "Hace una copia de la base de datos al directorio de copias de seguridad anonimizando toda la información sensible. Esta copia se puede proveer a terceros para solucionar y depurar problemas. La base de datos original no es modificada. La base de datos anonimizada se almacena con el formato {filename_format}.", + "anonymising_database": "Aninimizando base de datos", "auto_tag": { "auto_tagging_all_paths": "Etiquetar automáticamente todas las rutas", "auto_tagging_paths": "Etiquetar automáticamente las siguientes rutas" @@ -353,7 +408,7 @@ "auto_tagging": "Auto-Etiquetado", "backing_up_database": "Guardando respaldo de la base de datos", "backup_and_download": "Lleva a cabo una copia de seguridad de la base de datos y la guarda en un fichero de respaldo.", - "backup_database": "Lleva a cabo una copia de seguridad de la base de datos en el mismo directorio en que se encuentre ésta. El formato de nombre del fichero generado es {filename_format}", + "backup_database": "Realiza una copia de seguridad de la base de datos en el directorio de copias de seguridad. La copia se almacena con el formato {filename_format}.", "cleanup_desc": "Buscar ficheros eliminados del sistema de archivos y eliminarlos de la base de datos. PRECAUCIÓN: esta es una acción destructiva.", "data_management": "Gestión de datos", "defaults_set": "Las opciones por defecto se han guardado y serán usadas cada vez que pulses el botoón de {action} en la página de Tareas.", @@ -371,6 +426,7 @@ "generate_previews_during_scan_tooltip": "Generar vistas previas animadas WebP, solo requerido si Tipo de Vista Previa es Imagen Animada.", "generate_sprites_during_scan": "Generar conjunto de imágenes o “sprites” de depuración", "generate_thumbnails_during_scan": "Generar miniaturas de las imágenes", + "generate_video_covers_during_scan": "Generar carátulas de escenas", "generate_video_previews_during_scan": "Generar vistas previas", "generate_video_previews_during_scan_tooltip": "Generar vistas previas en vídeo que se reproducen al pasar el ratón por encima de una escena", "generated_content": "Contenido generado", @@ -398,7 +454,16 @@ "incremental_import": "Importación gradual o progresiva desde un archivo zip de exportación aportado por el usuario.", "job_queue": "Cola de tareas", "maintenance": "Mantenimiento", + "migrate_blobs": { + "delete_old": "Borrar datos antiguos", + "description": "Migrar blobs al sistema de almacenamiento de blobs actual. Esta migración debería ejecutarse tras cambiar el sistema de almacenamiento de blobs. Opcionalmente se pueden borrar los datos antiguos tras la migración." + }, "migrate_hash_files": "Se ejecutará tras realizar un cambio de tipo de hash para renombrar los ficheros generados al nuevo formato hash.", + "migrate_scene_screenshots": { + "delete_files": "Borrar ficheros de capturas de pantalla", + "description": "Migrar capturas de pantalla de escenas al nuevo sistema de almacenamiento blob. Esta migración debería ejecutarse migrar un sistema existente a la versión 0.20. Opcionalmente se pueden borrar las capturas de pantalla antiguas tras la migración.", + "overwrite_existing": "Sobreescribir blobs existentes con datos de capturas de pantalla" + }, "migrations": "Migraciones", "only_dry_run": "Marca esta casilla para ejecutar en MODO DE SIMULACIÓN. No se eliminará información alguna, solo se mostrarán en el registro las acciones a realizar", "plugin_tasks": "Tareas de plugins", @@ -435,15 +500,20 @@ }, "basic_settings": "Ajustes básicos", "custom_css": { - "description": "La página debe ser recargada para que se lleven a cabo los cambios realizados.", + "description": "La página debe ser recargada para que se lleven a cabo los cambios realizados. Futuras versiones de Stash pueden no ser compatibles con CSS personalizados.", "heading": "CSS personalizado", "option_label": "Habilitar CSS personalizado" }, "custom_javascript": { - "description": "La página debe ser refrescada para que los cambios tomen efecto.", + "description": "La página debe ser refrescada para que los cambios tomen efecto. Futuras versiones de Stash pueden no ser compatibles con Javascripts personalizados.", "heading": "JavaScript personalizada", "option_label": "JavaScript personalizada activada" }, + "custom_locales": { + "description": "Sobreescribir traducciones individuales. El listado original se puede encontrar aquí: https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json. La página debe ser refrescada para reflejar los cambios realizados.", + "heading": "Traducciones personalizadas", + "option_label": "Traducción personalizada activada" + }, "delete_options": { "description": "Opciones por defecto al borrar escenas, imágenes y galerías.", "heading": "Opciones de eliminación", @@ -465,8 +535,21 @@ "heading": "Deshabilitar creación en menú desplegable" }, "heading": "Edición", + "max_options_shown": { + "label": "Número máximo de elementos a mostrar en menús desplegables" + }, "rating_system": { + "star_precision": { + "label": "Precisión estrellas valoraciones", + "options": { + "full": "Lleno", + "half": "Medias", + "quarter": "Cuartos", + "tenth": "Décimas" + } + }, "type": { + "label": "Sistema de valoración", "options": { "decimal": "Decimal", "stars": "Estrellas" @@ -480,6 +563,12 @@ }, "handy_connection": { "connect": "Conectar", + "server_offset": { + "heading": "Tiempo compensación servidor" + }, + "status": { + "heading": "Estado conexión móvil" + }, "sync": "Sincronizar" }, "handy_connection_key": { @@ -489,6 +578,11 @@ "image_lightbox": { "heading": "Lightbox para imágenes" }, + "image_wall": { + "direction": "Dirección", + "heading": "Imagen de fondo", + "margin": "Margen (píxeles)" + }, "images": { "heading": "Imágenes", "options": { @@ -510,6 +604,10 @@ "description": "Mostrar u ocultar los diferentes tipos de contenido del menú de navegación", "heading": "Elementos del menú" }, + "minimum_play_percent": { + "description": "Porcentaje de tiempo a reproducir una escena antes de incrementar su contador de visionados.", + "heading": "Porcentaje mínimo de reproducción" + }, "performers": { "options": { "image_location": { @@ -536,6 +634,7 @@ "scene_player": { "heading": "Reproductor de vídeo", "options": { + "always_start_from_beginning": "Siempre iniciar vídeo desde el inicio", "auto_start_video": "Iniciar vídeo automáticamente", "auto_start_video_on_play_selected": { "description": "Comenzar automáticamente la reproducción del vídeo de la escena cuando \"reproducir\" esté seleccionado o se seleccione una escena aleatoria desde la página de escenas", @@ -545,7 +644,8 @@ "description": "Reproducir la siguiente escena cuando el fichero en reproducción finalice", "heading": "(Por defecto) Continuar lista de reproducción" }, - "show_scrubber": "Mostrar depurador" + "show_scrubber": "Mostrar depurador", + "track_activity": "Registrar actividad" } }, "scene_wall": { diff --git a/ui/v2.5/src/locales/et-EE.json b/ui/v2.5/src/locales/et-EE.json index c3a010350..948d8df3f 100644 --- a/ui/v2.5/src/locales/et-EE.json +++ b/ui/v2.5/src/locales/et-EE.json @@ -127,6 +127,7 @@ "aliases": "Varjunimed", "all": "kõik", "also_known_as": "Tuntud ka kui", + "appears_with": "Esineb Koos", "ascending": "Kasvav", "average_resolution": "Keskmine Resolutsioon", "between_and": "ja", @@ -233,7 +234,9 @@ "server_display_name": "Serveri Nimi", "server_display_name_desc": "DLNA server nimi. Vaikimisi {server_name}, kui midagi pole sisestatud.", "successfully_cancelled_temporary_behaviour": "Edukalt tühistatud ajutine käitumine", - "until_restart": "restardini" + "until_restart": "restardini", + "video_sort_order": "Videote Sorteerimise Vaikeväärtus", + "video_sort_order_desc": "Viis, kuidas vaikimisi videoid sorteerida." }, "general": { "auth": { @@ -280,7 +283,7 @@ "check_for_insecure_certificates_desc": "Mõned lehed kasutavad ebaturvalisi ssl sertifikaate. Kui märkimata, kraapija jätab sertifikaadi kontrollimise vahele ning võimaldab nendelt lehtedelt andmeid kraapida. Kui kraapimise ajal esineb sertifikaadivigu, eemalda linnuke.", "chrome_cdp_path": "Chrome CDP tee", "chrome_cdp_path_desc": "Failitee Chrome käivitajani, või kaugaadress (algab http:// või https:// -iga, näiteks http://localhost:9222/json/version) Chrome'i eksemplarini.", - "create_galleries_from_folders_desc": "Kui lubatud, loob galeriisid pilte sisaldavatest kaustadest.", + "create_galleries_from_folders_desc": "Kui lubatud, loob vaikeväärtusena galeriisid pilte sisaldavatest kaustadest. Loo kasutas fail nimega .forcegallery või .nogallery, et seda sundida või sellest hoiduda.", "create_galleries_from_folders_label": "Loo galeriisid kaustadest, mis sisaldavad pilte", "database": "Andmebaas", "db_path_head": "Andmebaasi Failitee", @@ -306,8 +309,8 @@ }, "transcode": { "input_args": { - "desc": "Edasijõudnutele: Lisaargumendid mida edastada ffmpegi sisendväljale video reaalajas transkodeerimisel.", - "heading": "FFmpeg Reaalajas Transkodeerimise Sisendargumendid" + "desc": "Edasijõudnutele: Lisaargumendid mida edastada ffmpegi sisendväljale video genereerimisel.", + "heading": "FFmpeg Transkodeerimise Sisendargumendid" }, "output_args": { "desc": "Edasijõudnutele: Lisaargumendid mida edastada ffmpegi väljundväljale video genereerimisel.", @@ -810,7 +813,7 @@ "markers_tooltip": "20-sekundilised videod, mis algavad etteantud ajakoodiga.", "override_preview_generation_options": "Eelvaate Genereerimise Valikute Ülekirjutamine", "override_preview_generation_options_desc": "Eelvaate Genereerimise Sätete üle kirjutamine selle operatsiooni jaoks. Vaikeseaded määratakse jaotises Süsteem -> Eelvaate Genereerimine.", - "overwrite": "Kirjuta üle olemasolevad genereeritud failid", + "overwrite": "Kirjuta üle olemasolevad failid", "phash": "Nähtavad hashid (duplikaatide eemaldamiseks)", "preview_exclude_end_time_desc": "Välista stseeni eelvaadetest viimased x sekundid. See võib olla väärtus sekundites või protsent (nt 2%) stseeni kogukestusest.", "preview_exclude_end_time_head": "Välista lõpuaeg", @@ -852,6 +855,11 @@ "donate": "Anneta", "dupe_check": { "description": "Täpsetest madalamate tasemete arvutamine võib võtta kauem aega. Valepositiivsed tulemused võidakse tagastada ka madalamal täpsustasemel.", + "duration_diff": "Maksimaalse Pikkuse Vahe", + "duration_options": { + "any": "Kõik", + "equal": "Võrdne" + }, "found_sets": "{setCount, plural, one{# duplikaat leitud.} other {# duplikaati leitud.}}", "options": { "exact": "Täpselt", @@ -1074,6 +1082,7 @@ "saved_filters": "Salvestatud filtrid", "update_filter": "Uuenda Filtrit" }, + "second": "Sekund", "seconds": "Sekundit", "settings": "Sätted", "setup": { diff --git a/ui/v2.5/src/locales/fr-FR.json b/ui/v2.5/src/locales/fr-FR.json index d1bff5c6f..93d25f24f 100644 --- a/ui/v2.5/src/locales/fr-FR.json +++ b/ui/v2.5/src/locales/fr-FR.json @@ -127,6 +127,7 @@ "aliases": "Alias", "all": "tout", "also_known_as": "Également connu comme", + "appears_with": "Apparaît avec", "ascending": "Ascendant", "average_resolution": "Résolution moyenne", "between_and": "et", @@ -233,7 +234,9 @@ "server_display_name": "Nom d'affichage du serveur", "server_display_name_desc": "Nom d'affichage du serveur DLNA. Par défaut {server_name} si vide.", "successfully_cancelled_temporary_behaviour": "Le comportement temporaire a été annulé avec succès", - "until_restart": "jusqu'au redémarrage" + "until_restart": "jusqu'au redémarrage", + "video_sort_order": "Ordre de tri vidéo par défaut", + "video_sort_order_desc": "Commande pour trier les vidéos par défaut." }, "general": { "auth": { @@ -280,7 +283,7 @@ "check_for_insecure_certificates_desc": "Certains sites utilisent des certificats SSL non sécurisés. Lorsque cette option est décochée, l'extracteur de contenu ignore la vérification des certificats non sécurisés et autorise l'extraction de ces sites. Si vous obtenez une erreur de certificat lors de l'extraction, décochez cette option.", "chrome_cdp_path": "Chemin Chrome CDP (Chrome Debugging Protocol)", "chrome_cdp_path_desc": "Chemin de l'exécutable Chrome, ou adresse distante (commençant par http:// ou https://, par exemple http://localhost:9222/json/version) d'une instance de Chrome.", - "create_galleries_from_folders_desc": "Coché, crée des galeries à partir de dossiers contenant des images.", + "create_galleries_from_folders_desc": "Si vrai, crée par défaut des galeries à partir des répertoires contenant des images. Créer un fichier appelé .forcegallery ou .nogallery dans un répertoire pour forcer/empêcher cela.", "create_galleries_from_folders_label": "Créer des galeries à partir de dossiers contenant des images", "database": "Base de données", "db_path_head": "Chemin de la base de données", @@ -306,8 +309,8 @@ }, "transcode": { "input_args": { - "desc": "Avancé : Arguments supplémentaires à passer à FFmpeg avant le champ d'entrée lors du transcodage vidéo en temps réel.", - "heading": "Arguments d'entrée pour le transcodage FFmpeg en temps réel" + "desc": "Avancé : Arguments additionnels à transmettre à FFmpeg avant le champ d'entrée lors de la génération de la vidéo.", + "heading": "Arguments d'entrée de FFmpeg transcode" }, "output_args": { "desc": "Avancé : Arguments supplémentaires à passer à FFmpeg avant le champ de sortie lors de la génération de la vidéo.", @@ -810,7 +813,7 @@ "markers_tooltip": "Vidéos de 20 secondes qui débutent au repère temporel donné.", "override_preview_generation_options": "Remplacer les options de génération d'aperçu", "override_preview_generation_options_desc": "Remplacer les options de génération d'aperçu pour cette opération. Les valeurs par défaut sont définies dans Système -> Génération d'aperçus.", - "overwrite": "Remplacer les fichiers générés existants", + "overwrite": "Remplacer les fichiers existants", "phash": "Empreintes perceptuelles (pour la déduplication)", "preview_exclude_end_time_desc": "Exclure les x dernières secondes des aperçus de la scène. Cela peut être une valeur en secondes, ou un pourcentage (par exemple 2%) de la durée totale de la scène.", "preview_exclude_end_time_head": "Exclure le temps de fin", @@ -852,6 +855,11 @@ "donate": "Faire un don", "dupe_check": { "description": "Les niveaux en-deça de \"Exacte\" peuvent prendre plus de temps à calculer. Des faux positifs peuvent également être retournés à de faibles précisions.", + "duration_diff": "Écart maximum de temps", + "duration_options": { + "any": "Tous", + "equal": "Égal" + }, "found_sets": "{setCount, plural, one{# ensemble de doublons trouvé.} other {# ensembles de doublons trouvés.}}", "options": { "exact": "Exacte", @@ -1074,6 +1082,7 @@ "saved_filters": "Filtres sauvegardés", "update_filter": "Filtre actualisé" }, + "second": "Deuxième", "seconds": "Secondes", "settings": "Paramètres", "setup": { diff --git a/ui/v2.5/src/locales/pt-BR.json b/ui/v2.5/src/locales/pt-BR.json index 17946e51b..be494d6ee 100644 --- a/ui/v2.5/src/locales/pt-BR.json +++ b/ui/v2.5/src/locales/pt-BR.json @@ -6,6 +6,7 @@ "add_to_entity": "Adicionar em {entityType}", "allow": "Permitir", "allow_temporarily": "Permitir temporariamente", + "anonymise": "Anonimizar", "apply": "Aplicar", "auto_tag": "Etiquetamento automático", "backup": "Backup", @@ -20,6 +21,7 @@ "confirm": "Confirmar", "continue": "Continuar", "create": "Criar", + "create_chapters": "Criar Capítulo", "create_entity": "Criar {entityType}", "create_marker": "Criar marcador", "created_entity": "Criar {entity_type}: {entity_name}", @@ -32,6 +34,7 @@ "delete_stashid": "Deletar StashID", "disallow": "Não permitir", "download": "Download", + "download_anonymised": "Download Anonimizado", "download_backup": "Download backup", "edit": "Editar", "edit_entity": "Editar {entityType}", @@ -54,9 +57,12 @@ "import": "Importar…", "import_from_file": "Importar de arquivo", "logout": "Sair", + "make_primary": "Tornar Primário", "merge": "Unir", "merge_from": "Unir do", "merge_into": "Unir em", + "migrate_blobs": "Migrar Blobs", + "migrate_scene_screenshots": "Migrar Print da Cena", "next_action": "Próximo", "not_running": "não realizado", "open_in_external_player": "Abrir em um reprodutor externo", @@ -66,6 +72,7 @@ "play_selected": "Tocar selecionado", "preview": "Previsualizar", "previous_action": "Voltar", + "reassign": "Reatribuir", "refresh": "Atualizar", "reload_plugins": "Recarregar plugins", "reload_scrapers": "Recarregar scrapers", @@ -98,10 +105,12 @@ "show": "Mostrar", "show_configuration": "Exibir Configuração", "skip": "Pular", + "split": "Dividir", "stop": "Parar", "submit": "Enviar", "submit_stash_box": "Enviar para o Stash-Box", "submit_update": "Enviar atualização", + "swap": "Trocar", "tasks": { "clean_confirm_message": "Tem certeza de que quer limpar? Isto irá apagar as informações do banco de dados e conteúdos gerados de todas as cenas e galerias que não são mais encontradas no sistema.", "dry_mode_selected": "Modo não destrutivo. Nenhum arquivo será apagado, apenas registrado.", @@ -120,11 +129,17 @@ "also_known_as": "Também conhecido(a) como", "ascending": "Ascendente", "average_resolution": "Resolução média", + "between_and": "e", "birth_year": "Ano de nascimento", "birthdate": "Data de nascimento", "bitrate": "Taxa de bits", + "blobs_storage_type": { + "database": "Banco de Dados", + "filesystem": "Arquivo de Sistema" + }, "captions": "Legendas", "career_length": "Duração da carreira", + "chapters": "Capítulos", "component_tagger": { "config": { "active_instance": "Instância do stash-box ativa:", @@ -178,6 +193,7 @@ "latest_version": "Última versão", "latest_version_build_hash": "Hash do executável da última versão:", "new_version_notice": "[NOVA]", + "release_date": "Data de Lançamento:", "stash_discord": "Junte-se ao nosso servidor no {url}", "stash_home": "Stash home no {url}", "stash_open_collective": "Apoie-nos através de {url}", @@ -244,6 +260,10 @@ "username": "Usuário", "username_desc": "Username para acessar o Stash. Deixe em branco para desativar a autenticação do usuário" }, + "backup_directory_path": { + "description": "Ditetório para aquivos de backup do banco de dados SQLite", + "heading": "Diretório de Backup" + }, "cache_location": "Localização do diretório do cache", "cache_path_head": "Caminho do cache", "calculate_md5_and_ohash_desc": "Calcular MD5 checksum além do oshash. A ativação fará com que as escaneamentos iniciais sejam mais lentos. Nomeação de arquivo Hash deve ser definido para oshash para desabilitar o cálculo MD5.", @@ -254,6 +274,7 @@ "chrome_cdp_path_desc": "Caminho do arquivo para o executavel do Chrome, ou um endereço remoto (começando com http:// ou https://, por exemplo http://localhost:9222/json/version) para uma instância do Chrome.", "create_galleries_from_folders_desc": "Se marcado, cria galerias de pastas contendo imagens.", "create_galleries_from_folders_label": "Crie galerias de pastas contendo imagens", + "database": "Banco de Dados", "db_path_head": "Caminho do banco de dados", "directory_locations_to_your_content": "Locais de diretório para o seu conteúdo", "excluded_image_gallery_patterns_desc": "Regexps de imagem e galeria de arquivos/caminhos para excluir do escaneamento e adicionar para limpar", diff --git a/ui/v2.5/src/locales/sv-SE.json b/ui/v2.5/src/locales/sv-SE.json index c88229303..c508102a8 100644 --- a/ui/v2.5/src/locales/sv-SE.json +++ b/ui/v2.5/src/locales/sv-SE.json @@ -127,6 +127,7 @@ "aliases": "Alias", "all": "Allt", "also_known_as": "Även känd som", + "appears_with": "Uppträder Med", "ascending": "Stigande", "average_resolution": "Genomsnittlig upplösning", "between_and": "och", @@ -233,7 +234,9 @@ "server_display_name": "Servers visningsnamn", "server_display_name_desc": "Visningsnamnet för DLNA-servern. Återgår till standard {server_name} om tom.", "successfully_cancelled_temporary_behaviour": "Lyckad avbrytning av temporärt beteende", - "until_restart": "tills omstart" + "until_restart": "tills omstart", + "video_sort_order": "Standard Scen Sorteringsordning", + "video_sort_order_desc": "Ordningen som scener sorteras i som standard." }, "general": { "auth": { @@ -280,7 +283,7 @@ "check_for_insecure_certificates_desc": "Vissa webbplatser använder osäkra SSl-certifikat. När detta är avstängt kommer skraparen att kunna skrapa webbplatser med osäkra certifikat. Stäng av detta om du får certifikatfel vid skrapning.", "chrome_cdp_path": "Chrome CDP-sökväg", "chrome_cdp_path_desc": "Sökväg till Chrome-programfilen, eller en fjärradress (börjar med http:// eller https://, till exempel http://localhost:9222/json/version) till en Chrome-instans.", - "create_galleries_from_folders_desc": "Om sant, skapar gallerier från mappar som innehåller bilder.", + "create_galleries_from_folders_desc": "Om sant, skapar gallerier från mappar som innehåller bilder. Skapa en fil med namn .forcegalllery i en mapp för att aktivera/förhindra detta.", "create_galleries_from_folders_label": "Skapa gallerier från mappar som innehåller bilder", "database": "Databas", "db_path_head": "Databassökväg", @@ -306,8 +309,8 @@ }, "transcode": { "input_args": { - "desc": "Avancerat: Ytterligare argument att skicka till ffmpeg innan input-fältet vid liveomkodning av video.", - "heading": "FFmpeg Liveomkodning Input Argument" + "desc": "Avancerat: Ytterligare argument att skicka till ffmpeg innan input-fältet vid videogeneration.", + "heading": "FFmpeg Omkodning Input Argument" }, "output_args": { "desc": "Avancerat: Ytterligare argument att skicka till ffmpeg innan output-fältet vid videogenerering.", @@ -810,7 +813,7 @@ "markers_tooltip": "20-sekunders videor som börjar vid angiven tidsstämpel.", "override_preview_generation_options": "Åsidosätt Inställningar för Förhandsvisningsgeneration", "override_preview_generation_options_desc": "Åsidosätt inställningar för Förhandsvisningsgeneration. Standarder ställs in i System -> Förhandsvisningsgeneration.", - "overwrite": "Ersätt existerande genererade filer", + "overwrite": "Ersätt existerande filer", "phash": "Perceptuella hashar (för de-duplikation)", "preview_exclude_end_time_desc": "Exkludera de sista x sekunderna från videoförhandsvisning. Detta kan vara ett värde i sekunder, eller en procent (ex. 2%) av scenes totala speltid.", "preview_exclude_end_time_head": "Exludera sluttid", @@ -852,6 +855,11 @@ "donate": "Donera", "dupe_check": { "description": "Nivåer under 'Exakt' kan ta längre tid att beräkna. Falskt positiva svar riskeras också genom att välja en lägre nivå.", + "duration_diff": "Maximal Speltidsskillnad", + "duration_options": { + "any": "Allt", + "equal": "Lika" + }, "found_sets": "{setCount, plural, one{# kopia hittades.} other {# antal kopior hittades.}}", "options": { "exact": "Exakt", @@ -1074,6 +1082,7 @@ "saved_filters": "Sparade filter", "update_filter": "Uppdatera filter" }, + "second": "Sekund", "seconds": "Sekunder", "settings": "Inställningar", "setup": { diff --git a/ui/v2.5/src/locales/zh-CN.json b/ui/v2.5/src/locales/zh-CN.json index eb11b1ac5..30a8491ed 100644 --- a/ui/v2.5/src/locales/zh-CN.json +++ b/ui/v2.5/src/locales/zh-CN.json @@ -1,14 +1,14 @@ { "actions": { - "add": "添加", - "add_directory": "添加目录", - "add_entity": "添加 {entityType}", - "add_to_entity": "添加到 {entityType}", + "add": "新增", + "add_directory": "新增目录", + "add_entity": "新增{entityType}", + "add_to_entity": "新增至{entityType}", "allow": "允许", "allow_temporarily": "暂时允许", "anonymise": "匿名化", "apply": "应用", - "auto_tag": "自动添加标签", + "auto_tag": "自动标签", "backup": "备份", "browse_for_image": "浏览图片…", "cancel": "取消", @@ -21,28 +21,29 @@ "confirm": "确认", "continue": "继续", "create": "创建", - "create_entity": "创建 {entityType}", + "create_chapters": "创建章节", + "create_entity": "创建{entityType}", "create_marker": "创建标记", - "created_entity": "已经创建 {entity_type}: {entity_name}", + "created_entity": "已创建{entity_type}: {entity_name}", "customise": "自定义", "delete": "删除", - "delete_entity": "删除 {entityType}", + "delete_entity": "删除{entityType}", "delete_file": "删除文件", - "delete_file_and_funscript": "删除文件(和funscript)", - "delete_generated_supporting_files": "删除已经生成的支持文件", + "delete_file_and_funscript": "删除文件 (和 funscript)", + "delete_generated_supporting_files": "删除已生成的附加文件", "delete_stashid": "删除 StashID", "disallow": "不允许", "download": "下载", "download_anonymised": "匿名下载", "download_backup": "下载备份", "edit": "编辑", - "edit_entity": "编辑 {entityType}", + "edit_entity": "编辑{entityType}", "export": "导出", "export_all": "导出所有…", "find": "搜索", - "finish": "结束", - "from_file": "从文件…", - "from_url": "从网址…", + "finish": "完成", + "from_file": "来自文件…", + "from_url": "来自网址…", "full_export": "完整导出", "full_import": "完整导入", "generate": "生成", @@ -51,31 +52,33 @@ "hash_migration": "识别码迁移", "hide": "隐藏", "hide_configuration": "隐藏设定", - "identify": "鉴定", - "ignore": "略过", + "identify": "刮削", + "ignore": "忽略", "import": "导入…", "import_from_file": "从文件导入", - "logout": "注销", + "logout": "登出", "make_primary": "作为主要文件", "merge": "合并", - "merge_from": "合并源", - "merge_into": "合并入", - "next_action": "下一个", + "merge_from": "与其他项目合并", + "merge_into": "合并至其他项目", + "migrate_blobs": "迁移数据", + "migrate_scene_screenshots": "迁移截图", + "next_action": "下一步", "not_running": "未运行", "open_in_external_player": "由外部播放器打开", "open_random": "开启随机", "overwrite": "覆盖", "play_random": "随机播放", - "play_selected": "播放已选择的", + "play_selected": "播放所选", "preview": "预览", - "previous_action": "回去", + "previous_action": "上一步", "reassign": "重新分配", "refresh": "刷新", - "reload_plugins": "重新加载插件", - "reload_scrapers": "重新加载网页挖掘器", + "reload_plugins": "重载插件", + "reload_scrapers": "重载刮削器", "remove": "移除", "remove_from_gallery": "从图库中删除", - "rename_gen_files": "重命名生成的文件", + "rename_gen_files": "重命名已生成的文件", "rescan": "重新扫描", "reshuffle": "重新排列", "running": "运行中", @@ -83,23 +86,23 @@ "save_delete_settings": "在删除时使用以下默认选项", "save_filter": "保存过滤条件", "scan": "扫描", - "scrape": "挖掘", - "scrape_query": "挖掘 查询指令", - "scrape_scene_fragment": "以部分名字挖掘", - "scrape_with": "使用挖掘器…", + "scrape": "刮削", + "scrape_query": "刮削查询关键字", + "scrape_scene_fragment": "以部分名称刮削", + "scrape_with": "使用刮削器…", "search": "搜索", "select_all": "选择所有", - "select_entity": "选择 {entityType}", + "select_entity": "选择{entityType}", "select_folders": "选择目录", "select_none": "清除选择", - "selective_auto_tag": "选择性自动生成标签", + "selective_auto_tag": "选择性自动标签", "selective_clean": "选择性清理", "selective_scan": "选择性扫描", "set_as_default": "设置为默认", "set_back_image": "设置背面图…", "set_front_image": "设置正面图…", "set_image": "设置图片…", - "show": "展示", + "show": "显示", "show_configuration": "显示设定", "skip": "跳过", "split": "分割", @@ -124,14 +127,20 @@ "aliases": "别名", "all": "所有", "also_known_as": "又称作", + "appears_with": "显示方式", "ascending": "升序", "average_resolution": "平均分辨率", "between_and": "以及", "birth_year": "出生年份", "birthdate": "出生日期", "bitrate": "比特率", + "blobs_storage_type": { + "database": "数据库", + "filesystem": "文件系统" + }, "captions": "字幕", "career_length": "工龄", + "chapters": "章节", "component_tagger": { "config": { "active_instance": "目前使用的 Stash-box:", @@ -185,6 +194,7 @@ "latest_version": "最新版本", "latest_version_build_hash": "最新版本识别码:", "new_version_notice": "[新版本]", + "release_date": "发布日期:", "stash_discord": "加入我们的 {url} 频道", "stash_home": "Stash 主页在 {url}", "stash_open_collective": "通过 {url} 支持我们", @@ -224,7 +234,9 @@ "server_display_name": "服务器名称", "server_display_name_desc": "DLAN服务器的名称。如果为空,则默认为 {server_name}。", "successfully_cancelled_temporary_behaviour": "成功取消暂时的服务行为", - "until_restart": "直到服务重启" + "until_restart": "直到服务重启", + "video_sort_order": "默认视频排序", + "video_sort_order_desc": "默认情况下对视频进行排序。" }, "general": { "auth": { @@ -255,7 +267,15 @@ "description": "备份SQLite 数据库文件的目录路径", "heading": "备份用的路径" }, - "cache_location": "缓存目录的位置", + "blobs_path": { + "description": "在文件系统中存储二进制数据的位置。仅在使用 Filesystem blob 存储类型时适用。警告:更改此项需要手动移动现有数据。", + "heading": "二进制数据文件储存路径" + }, + "blobs_storage": { + "description": "存储二进制数据(如场景封面、表演者、工作室和标签图像)的地方。在改变这个值之后,必须使用迁移数据任务来迁移现有数据。参见迁移数据任务页面。", + "heading": "二进制数据存储类型" + }, + "cache_location": "缓存的目录位置。如果使用 HLS(例如在 Apple 设备上)或 DASH 进行流传输,则需要该位置。", "cache_path_head": "缓存路径", "calculate_md5_and_ohash_desc": "除了快搜码外还计算 MD5 值。如果开启,初次扫描时速度会较慢。如果关闭 MD5 值计算,则必须将文件名识别码算法设置为快搜码.", "calculate_md5_and_ohash_label": "计算影片MD5值", @@ -263,8 +283,9 @@ "check_for_insecure_certificates_desc": "某些网站所使用的证书可能有安全问题。取消勾选之后挖掘器会跳过证书安全性检查,如果你遇到了证书错误的问题,可以取消此选项。", "chrome_cdp_path": "谷歌浏览器 CDP 路径", "chrome_cdp_path_desc": "谷歌浏览器 可执行文件的路径, 或者远端地址 (以 http:// 或 https:// 开头, 比如 http://localhost:9222/json/version)。", - "create_galleries_from_folders_desc": "如果勾选,则会从包含图片的文件夹建立图库。", + "create_galleries_from_folders_desc": "如果勾选,则默认从包含图片的文件夹中创建画廊。在文件夹中创建一个名为 .forcegallery 或 .nogallery 的文件来强制/防止这种情况。", "create_galleries_from_folders_label": "从包含图片的文件夹建立图库", + "database": "数据库", "db_path_head": "数据库所在路径", "directory_locations_to_your_content": "你的影片等收藏的路径", "excluded_image_gallery_patterns_desc": "要从扫描中排除并会被[清除]功能所移除的图片及图库文件/路径的正则表达式", @@ -272,6 +293,10 @@ "excluded_video_patterns_desc": "要从扫描中排除并会被[清除]功能所移除的视频文件/路径的正则表达式", "excluded_video_patterns_head": "视频排除规则", "ffmpeg": { + "hardware_acceleration": { + "desc": "使用可用的硬件对视频进行编码来用于实时转码。", + "heading": "FFmpeg 硬件编码" + }, "live_transcode": { "input_args": { "desc": "高级:当直播转码的视频时,在输入参数前要传给ffmpeg用的附加参数。", @@ -293,6 +318,10 @@ } } }, + "funscript_heatmap_draw_range": "在生成的热图中包括范围", + "funscript_heatmap_draw_range_desc": "在生成的热图的y轴上绘制运动范围。更改后需要重新生成现有热图。", + "gallery_cover_regex_desc": "正则表达式用于将图像识别为图库封面", + "gallery_cover_regex_label": "图库封面模式", "gallery_ext_desc": "逗号(半角)分隔的文件扩展名列表,将被标识为图库或图包。", "gallery_ext_head": "图库压缩包扩展名", "generated_file_naming_hash_desc": "使用 MD5 或快搜码为生成的文件命名。 更改此设置要求所有短片都有对应的 MD5/快搜码 值。 更改此值后,之前生成的数据需要迁移或重新生成。 请参阅 [迁移] 页面。", @@ -300,6 +329,7 @@ "generated_files_location": "生成数据的存储目录(短片标记,短片预览,预览图等)", "generated_path_head": "生成数据的存储路径", "hashing": "识别码设置", + "heatmap_generation": "Funscript 热图生成", "image_ext_desc": "逗号(半角)分隔的文件扩展名列表,将被标识为图片。", "image_ext_head": "图片扩展名", "include_audio_desc": "生成预览时包括音频流.", @@ -321,21 +351,21 @@ "description": "Python 执行程序的位置。给网页挖掘器和插件的源文件使用。如果没有,python会从环境变量找到", "heading": "Python 路径" }, - "scraper_user_agent": "挖掘器用户代理(User Agent)", - "scraper_user_agent_desc": "挖掘器运行时的用户代理名(User Agent)", + "scraper_user_agent": "刮削器用户代理 (User Agent)", + "scraper_user_agent_desc": "刮削器运行时的用户代理 (User Agent)", "scrapers_path": { - "description": "含有网路挖掘器配置文件的路径", - "heading": "网页挖掘器路径" + "description": "含有刮削器配置文件的路径", + "heading": "刮削器路径" }, - "scraping": "挖掘器设置", - "sqlite_location": "Sqlite 数据库的位置(需要重启)", + "scraping": "刮削器设置", + "sqlite_location": "SQLite 数据库的位置(需要重启)。警告:不支持将数据库放在与 Stash 服务器以外的系统上(即通过网络)!", "video_ext_desc": "逗号(半角)分隔的文件扩展名列表,将被标识为视频。", "video_ext_head": "视频扩展名", "video_head": "视频设置" }, "library": { "exclusions": "不包括", - "gallery_and_image_options": "图库和照片的选项", + "gallery_and_image_options": "图库和图片的选项", "media_content_extensions": "媒体的文件扩展名" }, "logs": { @@ -343,21 +373,21 @@ }, "plugins": { "hooks": "回调", - "triggers_on": "触发" + "triggers_on": "触发于" }, "scraping": { - "entity_metadata": "{entityType} 元数据", - "entity_scrapers": "{entityType} 挖掘器", - "excluded_tag_patterns_desc": "从抓取结果中排除的标签名称的正则表达式", + "entity_metadata": "{entityType}元数据", + "entity_scrapers": "{entityType}刮削器", + "excluded_tag_patterns_desc": "从刮削结果中排除的标签名称的正则表达式", "excluded_tag_patterns_head": "排除标签的正则表达式", - "scraper": "挖掘器", - "scrapers": "挖掘器", + "scraper": "刮削器", + "scrapers": "刮削器", "search_by_name": "按名称搜索", "supported_types": "支持类型", "supported_urls": "支持链接" }, "stashbox": { - "add_instance": "新增 stash-box 入口", + "add_instance": "新增 Stash-Box 入口", "api_key": "API 密钥", "description": "Stash-box 根据指纹和文件名自动标记短片和演员。\n入口和 API 密钥可以在您的帐户页面上的 stash-box 实例中找到。 添加多个实例时必须设置名称。", "endpoint": "入口", @@ -399,6 +429,7 @@ "generate_previews_during_scan_tooltip": "生成WebP动画预览,仅适用于预览类型设为动图的情况.", "generate_sprites_during_scan": "生成时间轴预览小图", "generate_thumbnails_during_scan": "生成图片的缩略图", + "generate_video_covers_during_scan": "生成短片封面", "generate_video_previews_during_scan": "生成预览", "generate_video_previews_during_scan_tooltip": "产生视频预览,用以鼠标移到短片上时播放", "generated_content": "生成的内容", @@ -426,7 +457,16 @@ "incremental_import": "从导出的 zip 文件增量导入。", "job_queue": "任务队列", "maintenance": "维护", + "migrate_blobs": { + "delete_old": "删除旧数据", + "description": "将 blob 迁移到当前 blob 存储系统。应在更改 blob 存储系统之后运行此迁移。可以选择在迁移后删除旧数据。" + }, "migrate_hash_files": "使用更改之后的识别码算法重新命名已经存在的文件到新的识别码格式。", + "migrate_scene_screenshots": { + "delete_files": "删除截图文件", + "description": "将短片屏幕截图迁移到新的 blob 存储系统中。应在将现有系统迁移到 0.20 之后运行此迁移。可以选择在迁移后删除旧的屏幕截图。", + "overwrite_existing": "用屏幕截图数据覆盖现有 Blob" + }, "migrations": "迁移", "only_dry_run": "仅模拟运行,不要删除任何东西", "plugin_tasks": "插件任务", @@ -498,13 +538,17 @@ "heading": "禁止下拉菜单建立" }, "heading": "编辑", + "max_options_shown": { + "label": "在选择下拉列表中显示的最大项数" + }, "rating_system": { "star_precision": { "label": "评分星的精度", "options": { "full": "完整", "half": "一半", - "quarter": "四分之一" + "quarter": "四分之一", + "tenth": "十分之一" } }, "type": { @@ -537,6 +581,11 @@ "image_lightbox": { "heading": "图片灯箱" }, + "image_wall": { + "direction": "方向", + "heading": "图片墙", + "margin": "边距(像素)" + }, "images": { "heading": "图片", "options": { @@ -634,8 +683,8 @@ "heading": "标签显示", "options": { "show_child_tagged_content": { - "description": "在标签显示里,显示副标签的内容", - "heading": "显示副标签的内容" + "description": "在标签页面中,同时显示子标签的内容", + "heading": "显示子标签内容" } } }, @@ -656,7 +705,7 @@ }, "country": "国家", "cover_image": "封面图片", - "created_at": "创建时间", + "created_at": "创建于", "criterion": { "greater_than": "大于", "less_than": "小于", @@ -666,7 +715,7 @@ "between": "介于…之间", "equals": "是", "excludes": "不包含", - "format_string": "{criterion} {modifierString} {valueString}", + "format_string": "{criterion}{modifierString}{valueString}", "greater_than": "大于", "includes": "包含", "includes_all": "包含所有", @@ -680,6 +729,8 @@ }, "custom": "自定义", "date": "日期", + "date_format": "YYYY-MM-DD", + "datetime_format": "YYYY-MM-DD HH:MM", "death_date": "去世日期", "death_year": "去世年份", "descending": "降序", @@ -688,13 +739,13 @@ "details": "简介", "developmentVersion": "开发版本", "dialogs": { - "create_new_entity": "创建新的 {entity}", - "delete_alert": "以下 {count, plural, one {{singularEntity}} other {{pluralEntity}}} 会被永久删除:", - "delete_confirm": "确定要删除 {entityName} 吗?", - "delete_entity_desc": "{count, plural, one {确定要删除{singularEntity}吗? 除非同时删除文件, 否则下次扫描时{singularEntity}会重新被添加到数据库中。} other {确定要删除{pluralEntity}吗? 除非同时删除文件, 否则下次扫描时{pluralEntity}会重新被添加到数据库中。}}", - "delete_entity_simple_desc": "{count, plural, one {你确定要删除这个 {singularEntity}?} other {你确定要删除这些 {pluralEntity}?}}", - "delete_entity_title": "{count, plural, one {删除 {singularEntity}} other {删除 {pluralEntity}}}", - "delete_galleries_extra": "...以及任何没有加入其它图库的图片.", + "create_new_entity": "创建新{entity}", + "delete_alert": "以下{count, plural, one {{singularEntity}} other {{pluralEntity}}}将被永久删除:", + "delete_confirm": "确定要删除{entityName}吗?", + "delete_entity_desc": "{count, plural, one {确定要删除{singularEntity}吗?除非同时删除文件?否则下次扫描时{singularEntity}会重新被添加到数据库中。} other {确定要删除{pluralEntity}吗?除非同时删除文件?否则下次扫描时{pluralEntity}会重新被添加到数据库中。}}", + "delete_entity_simple_desc": "{count, plural, one {你确定要删除这个{singularEntity}?} other {你确定要删除这些{pluralEntity}?}}", + "delete_entity_title": "{count, plural, one {删除{singularEntity}} other {删除{pluralEntity}}}", + "delete_galleries_extra": "...以及任何没有加入其它图库的图片。", "delete_gallery_files": "删除图库的文件夹/压缩包和任何没有加入其它图库的图片.", "delete_object_desc": "确定要删除{count, plural, one {这个{singularEntity}} other {这些{pluralEntity}}}?", "delete_object_overflow": "…以及 {count} 个其他 {count, plural, one {{singularEntity}} other {{pluralEntity}}}.", @@ -703,6 +754,14 @@ "edit_entity_title": "编辑 {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "export_include_related_objects": "导出时包含相关的数据", "export_title": "导出", + "imagewall": { + "direction": { + "column": "列", + "description": "基于列或行的布局。", + "row": "行" + }, + "margin_desc": "每个完整图像周围的边距像素数。" + }, "lightbox": { "delay": "延迟(秒)", "display_mode": { @@ -712,6 +771,7 @@ "original": "原图" }, "options": "选项", + "page_header": "第 {page} 页,共 {total} 页", "reset_zoom_on_nav": "图片改动时重设缩放度", "scale_up": { "description": "放大小图到整屏", @@ -739,6 +799,7 @@ "destination": "重新指定至" }, "scene_gen": { + "covers": "短片封面", "force_transcodes": "强制生成转码文件", "force_transcodes_tooltip": "默认情况下,转码文件只会在浏览器不支持的情况下生成。如果开启此选项,即使浏览器支持该视频,也会生成转码文件。", "image_previews": "动图预览", @@ -752,7 +813,7 @@ "markers_tooltip": "从给出的时间码开始20秒的视频.", "override_preview_generation_options": "凌驾预览生成选项", "override_preview_generation_options_desc": "在本次操作中凌驾于预览生成设定的选项。默认的选项在 系统 -> 预览生成。", - "overwrite": "覆盖已经生成的文件", + "overwrite": "覆盖现有文件", "phash": "感知识别码PHash(用于检测短片是否重复)", "preview_exclude_end_time_desc": "从短片预览中排除最后 x 秒。可以是一个以秒为单位的值,也可以是百分比(比如2%)。", "preview_exclude_end_time_head": "排除结束时间", @@ -794,6 +855,11 @@ "donate": "赞助", "dupe_check": { "description": "低于“精确”的准确度需要更长的时间来计算,但使用较低的准确度可能会产生误报。", + "duration_diff": "最大持续时间差异", + "duration_options": { + "any": "任意", + "equal": "相等" + }, "found_sets": "{setCount, plural, one{# 个发现的重复数据。} other {# 个发现的重复数据。}}", "options": { "exact": "精确", @@ -828,11 +894,16 @@ "warmth": "色温" }, "empty_server": "增加一些短片到服务器以看到本页面的推荐。", + "errors": { + "image_index_greater_than_zero": "图像索引必须大于 0", + "lazy_component_error_help": "如果您最近升级了 Stash,请重新加载页面或清除浏览器缓存。", + "something_went_wrong": "出了些问题。" + }, "ethnicity": "人种", "existing_value": "现值", "eye_color": "瞳孔颜色", "fake_tits": "假奶", - "false": "假", + "false": "否", "favourite": "收藏", "file": "文件", "file_count": "文件数量", @@ -875,6 +946,7 @@ "syncing": "正在和服务器同步", "uploading": "上传脚本中" }, + "hasChapters": "已有章节", "hasMarkers": "含有章节标记", "height": "身高", "height_cm": "高(cm)", @@ -882,6 +954,7 @@ "ignore_auto_tag": "忽略自动标签", "image": "图片", "image_count": "图片数量", + "image_index": "图像 #", "images": "图片", "include_parent_tags": "包含母标签", "include_sub_studios": "包含子工作室", @@ -1004,17 +1077,23 @@ "scenes": "短片", "scenes_updated_at": "短片更新时间", "search_filter": { + "edit_filter": "编辑筛选器", "name": "过滤", "saved_filters": "保存过滤器", "update_filter": "更新过滤器" }, + "second": "第二", "seconds": "秒", "settings": "设置", "setup": { "confirm": { "almost_ready": "设置就快完成,请确认以下设定。你可以点“回去”去改变任何不正确的东西,如果一切看来都好,请按“确定”去建立你的系统。", + "blobs_directory": "二进制文件目录", + "cache_directory": "缓存目录", "configuration_file_location": "配置文件的路径:", "database_file_path": "数据库文件的路径", + "default_blobs_location": "<用户数据库>", + "default_cache_location": "<包含配置文件的路径>/cache", "default_db_location": "<含有设置文件的路径>/stash-go.sqlite", "default_generated_content_location": "<含有设置文件的路径>/generated", "generated_directory": "生成资料的路径", @@ -1050,12 +1129,20 @@ }, "paths": { "database_filename_empty_for_default": "数据库文件名 (留空则用默认名)", - "description": "接下来,我们需要决定哪里找到你的收藏,哪里存放stash数据库和产生资料文件。如果需要,这些设定可在以后再修改。", + "description": "接下来,我们需要决定哪里找到你的收藏,哪里存放 stash 数据库和产生资料文件。如果需要,这些设定可在以后再修改。", + "path_to_blobs_directory_empty_for_database": "Blob 目录的路径(为空以使用数据库)", + "path_to_cache_directory_empty_for_default": "缓存目录的路径(默认为空)", "path_to_generated_directory_empty_for_default": "生成资料的文件夹路径 (留空则使用默认路径)", "set_up_your_paths": "设立你的路径", "stash_alert": "没有选择任何影像库的路径。Stash将不会扫描到任何媒体文件。你确认吗?", + "where_can_stash_store_blobs": "Stash 在哪里可以存储数据库二进制数据?", + "where_can_stash_store_blobs_description": "Stash 可以在数据库或文件系统中存储二进制数据,如场景封面、表演者、工作室和标签图像。默认情况下,它会将这些数据存储在子目录 blobs 中的文件系统中。如果要更改此路径,请输入绝对或相对(相对于当前工作目录)路径。如果该目录不存在,Stash将创建该目录。", + "where_can_stash_store_blobs_description_addendum": "或者,如果要将此数据存储在数据库中,可以将此字段留空注意:这将增加数据库文件的大小,并增加数据库迁移时间。", + "where_can_stash_store_cache_files": "Stash可以在哪里存储缓存文件?", + "where_can_stash_store_cache_files_description": "为了使 HLS/DASH 实时转码等功能发挥作用,Stash 需要一个临时文件的缓存目录。默认情况下,Stash将在包含配置文件的目录中创建一个缓存目录。如果要更改此路径,请输入绝对或相对(相对于当前工作目录)路径。如果该目录不存在,Stash 将创建该目录。", "where_can_stash_store_its_database": "在哪里可以储存Stash的数据库?", - "where_can_stash_store_its_database_description": "Stash使用一个sqlite数据库来存放你的收藏的元数据。默认情况下,会建立stash-go.sqlite在包含有你配置文件的目录里。如果你想改动,请输入一个绝对,或者相对(对于当前目录)的文件名。", + "where_can_stash_store_its_database_description": "Stash 使用 sqlite 数据库来存放你的收藏的元数据。默认情况下,会建立stash-go.sqlite在包含有你配置文件的目录里。如果你想改动,请输入一个绝对,或者相对(对于当前目录)的文件名。", + "where_can_stash_store_its_database_warning": "警告:不支持将数据库存储在运行 Stash 的不同系统上(例如,在另一台计算机上运行 Stash 服务器时将数据库存储到 NAS 上)!SQLite 不适合在网络上使用,尝试这样做很容易导致整个数据库损坏。", "where_can_stash_store_its_generated_content": "哪里可以存放Stash产生的资料?", "where_can_stash_store_its_generated_content_description": "为了可以提供缩图,预览和浏览图,Stash生成图片和视频。同时也包括将不支持的文件转码后的视频。默认情况下,Stash会建立一个generated文件夹在含有你配置文件的目录中。如果你要修改生成媒体的地方,请输入一个绝对,或者相对(对于当前工作目录)的路径。如果此目录不存在,Stash会自动建立它。", "where_is_your_porn_located": "你的收藏在哪里?", @@ -1090,14 +1177,14 @@ }, "welcome_to_stash": "欢迎使用Stash" }, - "stash_id": "Stash 号", - "stash_id_endpoint": "Stash 号的终端", - "stash_ids": "Stash号", + "stash_id": "Stash ID", + "stash_id_endpoint": "Stash ID 端点", + "stash_ids": "Stash IDs", "stashbox": { - "go_review_draft": "去 {endpoint_name} 检阅草稿。", - "selected_stash_box": "选择的 Stash-Box 终端", + "go_review_draft": "到 {endpoint_name} 预览草稿。", + "selected_stash_box": "已选择的 Stash-Box 端点", "submission_failed": "提交失败", - "submission_successful": "成功提交", + "submission_successful": "提交成功", "submit_update": "已存在于 {endpoint_name}" }, "statistics": "统计", @@ -1108,49 +1195,52 @@ }, "status": "状态:{statusText}", "studio": "工作室", - "studio_depth": "深度(为空时显示所有)", + "studio_depth": "深度 (为空时显示全部)", "studios": "工作室", - "sub_tag_count": "副标签 数量", - "sub_tag_of": "{parent}的副标签", - "sub_tags": "副标签", - "subsidiary_studios": "旗下的工作室", - "synopsis": "影片概要", + "sub_tag_count": "子标签数量", + "sub_tag_of": "{parent}的子标签", + "sub_tags": "子标签", + "subsidiary_studios": "子工作室", + "synopsis": "概要", "tag": "标签", "tag_count": "标签数量", "tags": "标签", "tattoos": "纹身", "title": "标题", "toast": { - "added_entity": "已添加 {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "added_entity": "已添加{count, plural, one {{singularEntity}} other {{pluralEntity}}}", "added_generation_job_to_queue": "已添加生成工作至队列", - "created_entity": "已经创建{entity}", + "created_entity": "已创建{entity}", "default_filter_set": "默认过滤器", - "delete_past_tense": "已经删除 {count, plural, one {{singularEntity}} other {{pluralEntity}}}", - "generating_screenshot": "正在生成截图…", - "merged_scenes": "拼合的短片", - "merged_tags": "已经合并标签", - "reassign_past_tense": "文件重新指定了", - "removed_entity": "已移除 {count, plural, one {{singularEntity}} other {{pluralEntity}}}", - "rescanning_entity": "正在重新扫描 {count, plural, one {{singularEntity}} other {{pluralEntity}}}…", - "saved_entity": "已经保存 {entity}", - "started_auto_tagging": "已经开始自动生成标签", - "started_generating": "开始产生资料", - "started_importing": "开始导入中", - "updated_entity": "已经更新 {entity}" + "delete_past_tense": "已删除{count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "generating_screenshot": "生成截图中…", + "image_index_too_large": "错误:图像索引大于库中的图像数", + "merged_scenes": "合并的短片", + "merged_tags": "已合并的标签", + "reassign_past_tense": "已重新指定文件", + "removed_entity": "已删除{count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "rescanning_entity": "重新扫描{count, plural, one {{singularEntity}} other {{pluralEntity}}}中…", + "saved_entity": "已保存{entity}", + "started_auto_tagging": "自动生成标签中", + "started_generating": "生成资料中", + "started_importing": "导入中", + "updated_entity": "已更新{entity}" }, - "total": "总共", - "true": "真", - "twitter": "推特", - "type": "类别", - "updated_at": "更新时间", + "total": "总计", + "true": "是", + "twitter": "Twitter", + "type": "种类", + "updated_at": "更新于", "url": "链接", "validation": { - "aliases_must_be_unique": "别名必须是唯一的" + "aliases_must_be_unique": "别名必须是唯一的", + "date_invalid_form": "${path} 的格式必须为 YYYY-MM-DD", + "required": "${path} 是必填字段" }, "videos": "视频", "view_all": "查看全部", "weight": "体重", - "weight_kg": "重量(kg)", + "weight_kg": "体重 (kg)", "years_old": "岁", "zip_file_count": "压缩文件数量" } diff --git a/ui/v2.5/src/locales/zh-TW.json b/ui/v2.5/src/locales/zh-TW.json index 432b4dcf3..e8685a94f 100644 --- a/ui/v2.5/src/locales/zh-TW.json +++ b/ui/v2.5/src/locales/zh-TW.json @@ -193,7 +193,7 @@ "latest_version": "最新版本", "latest_version_build_hash": "最新版本的雜湊值:", "new_version_notice": "[新版本]", - "release_date": "上映日期:", + "release_date": "發布日期:", "stash_discord": "加入我們的 {url} 頻道", "stash_home": "Stash 的 {url} 專案", "stash_open_collective": "透過 {url} 來支持本計畫的開發", @@ -486,8 +486,8 @@ }, "custom_javascript": { "description": "必須重新整理頁面才能使更改生效。", - "heading": "自定義 JavaScript", - "option_label": "已啟用自定義 JavaScript" + "heading": "自訂 JavaScript", + "option_label": "已啟用自訂 JavaScript" }, "custom_locales": { "description": "強制使用特定翻譯字串。主列表請參閱 https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json。必須重新整理頁面才能使更改生效。", From 06e924d01094a816ab73d59fd15037a1f028e067 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 19 May 2023 12:33:53 +1000 Subject: [PATCH 50/81] Change modifier selector to pills (#3598) * Use pills for modifier selector * Fix caption default modifier * Increase clickable area for criterion remove If the area becomes too large, we can use half margin half padding. Reduces the amount of pixel hunting required to click. * Use pill-styled buttons --- .../src/components/List/CriterionEditor.tsx | 28 +++++++++-------- ui/v2.5/src/components/List/styles.scss | 30 ++++++++++++++++--- ui/v2.5/src/index.scss | 3 +- .../models/list-filter/criteria/captions.ts | 1 + 4 files changed, 44 insertions(+), 18 deletions(-) diff --git a/ui/v2.5/src/components/List/CriterionEditor.tsx b/ui/v2.5/src/components/List/CriterionEditor.tsx index aac0c00f8..763d7c4f0 100644 --- a/ui/v2.5/src/components/List/CriterionEditor.tsx +++ b/ui/v2.5/src/components/List/CriterionEditor.tsx @@ -1,6 +1,6 @@ import cloneDeep from "lodash-es/cloneDeep"; import React, { useCallback, useMemo } from "react"; -import { Form } from "react-bootstrap"; +import { Button, Form } from "react-bootstrap"; import { CriterionModifier } from "src/core/generated-graphql"; import { DurationCriterion, @@ -40,6 +40,7 @@ import { OptionsListFilter } from "./Filters/OptionsListFilter"; import { PathFilter } from "./Filters/PathFilter"; import { PhashCriterion } from "src/models/list-filter/criteria/phash"; import { PhashFilter } from "./Filters/PhashFilter"; +import cx from "classnames"; interface IGenericCriterionEditor { criterion: Criterion; @@ -55,9 +56,9 @@ const GenericCriterionEditor: React.FC = ({ const { options, modifierOptions } = criterion.criterionOption; const onChangedModifierSelect = useCallback( - (event: React.ChangeEvent) => { + (m: CriterionModifier) => { const newCriterion = cloneDeep(criterion); - newCriterion.modifier = event.target.value as CriterionModifier; + newCriterion.modifier = m; setCriterion(newCriterion); }, [criterion, setCriterion] @@ -69,18 +70,21 @@ const GenericCriterionEditor: React.FC = ({ } return ( - + {modifierOptions.map((c) => ( - + ); }, [modifierOptions, onChangedModifierSelect, criterion.modifier, intl]); diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 40a9ead91..38b61606c 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -183,11 +183,33 @@ input[type="range"].zoom-slider { } } -.modifier-selector { - margin-bottom: 1rem; +.modifier-options { + display: flex; + flex-wrap: wrap; + justify-content: center; +} - // to accommodate for caret - padding-right: 2rem; +.modifier-options .modifier-option { + background-color: $secondary; + border: none; + border-radius: 10rem; + cursor: pointer; + display: inline-block; + font-size: 100%; + font-weight: 700; + line-height: 1; + margin-bottom: 0.5rem; + margin-right: 0.25rem; + padding: 0.25em 0.6em; + text-align: center; + transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, + border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + vertical-align: baseline; + white-space: nowrap; + + &.selected { + background-color: $primary; + } } .filter-tags .clear-all-button { diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 5e4053684..2aa3a0c65 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -375,9 +375,8 @@ div.dropdown-menu { color: $dark-text; font-size: 12px; line-height: 1rem; - margin-left: 0.5rem; opacity: 0.5; - padding: 0; + padding: 0 0 0 0.5rem; position: relative; &:active, diff --git a/ui/v2.5/src/models/list-filter/criteria/captions.ts b/ui/v2.5/src/models/list-filter/criteria/captions.ts index 0ba4fc8f4..13c72dc77 100644 --- a/ui/v2.5/src/models/list-filter/criteria/captions.ts +++ b/ui/v2.5/src/models/list-filter/criteria/captions.ts @@ -17,6 +17,7 @@ class CaptionsCriterionOptionType extends CriterionOption { CriterionModifier.IsNull, CriterionModifier.NotNull, ], + defaultModifier: CriterionModifier.Includes, options: languageStrings, }); } From 0a143941132cb130b51de1123003b0b715e85901 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 19 May 2023 12:36:28 +1000 Subject: [PATCH 51/81] Allow filter header to be tabbable (#3739) --- .../src/components/List/EditFilterDialog.tsx | 2 +- ui/v2.5/src/components/List/styles.scss | 19 ++++++++++++++++--- ui/v2.5/src/styles/_theme.scss | 1 + 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/ui/v2.5/src/components/List/EditFilterDialog.tsx b/ui/v2.5/src/components/List/EditFilterDialog.tsx index 8a41b4a2d..4443ad805 100644 --- a/ui/v2.5/src/components/List/EditFilterDialog.tsx +++ b/ui/v2.5/src/components/List/EditFilterDialog.tsx @@ -130,7 +130,7 @@ const CriterionOptionList: React.FC = ({ function renderCard(c: CriterionOption, isPin: boolean) { return ( - + Date: Fri, 19 May 2023 12:36:53 +1000 Subject: [PATCH 52/81] Filter query (#3740) * Add search field to filter dialog * Add / shortcut to focus query * Fix f keybind typing f into query field * Document keyboard shortcut --- .../src/components/List/EditFilterDialog.tsx | 51 ++++++++++++++++--- ui/v2.5/src/components/List/ItemList.tsx | 8 ++- ui/v2.5/src/components/List/styles.scss | 9 ++++ .../src/docs/en/Manual/KeyboardShortcuts.md | 2 +- ui/v2.5/src/utils/focus.ts | 17 ++++++- 5 files changed, 78 insertions(+), 9 deletions(-) diff --git a/ui/v2.5/src/components/List/EditFilterDialog.tsx b/ui/v2.5/src/components/List/EditFilterDialog.tsx index 4443ad805..581fd31fb 100644 --- a/ui/v2.5/src/components/List/EditFilterDialog.tsx +++ b/ui/v2.5/src/components/List/EditFilterDialog.tsx @@ -7,7 +7,7 @@ import React, { useRef, useState, } from "react"; -import { Accordion, Button, Card, Modal } from "react-bootstrap"; +import { Accordion, Button, Card, Form, Modal } from "react-bootstrap"; import cx from "classnames"; import { CriterionValue, @@ -34,6 +34,8 @@ import { useToast } from "src/hooks/Toast"; import { useConfigureUI } from "src/core/StashService"; import { IUIConfig } from "src/core/config"; import { FilterMode } from "src/core/generated-graphql"; +import { useFocusOnce } from "src/utils/focus"; +import Mousetrap from "mousetrap"; interface ICriterionList { criteria: string[]; @@ -222,11 +224,14 @@ export const EditFilterDialog: React.FC = ({ const { configuration } = useContext(ConfigurationContext); + const [searchValue, setSearchValue] = useState(""); const [currentFilter, setCurrentFilter] = useState( cloneDeep(filter) ); const [criterion, setCriterion] = useState>(); + const [searchRef, setSearchFocus] = useFocusOnce(); + const { criteria } = currentFilter; const criteriaList = useMemo(() => { @@ -275,17 +280,31 @@ export const EditFilterDialog: React.FC = ({ const ui = (configuration?.ui ?? {}) as IUIConfig; const [saveUI] = useConfigureUI(); + const filteredOptions = useMemo(() => { + const trimmedSearch = searchValue.trim().toLowerCase(); + if (!trimmedSearch) { + return criterionOptions; + } + + return criterionOptions.filter((c) => { + return intl + .formatMessage({ id: c.messageID }) + .toLowerCase() + .includes(trimmedSearch); + }); + }, [intl, searchValue, criterionOptions]); + const pinnedFilters = useMemo( () => ui.pinnedFilters?.[filterModeToConfigKey(currentFilter.mode)] ?? [], [currentFilter.mode, ui.pinnedFilters] ); const pinnedElements = useMemo( - () => criterionOptions.filter((c) => pinnedFilters.includes(c.messageID)), - [pinnedFilters, criterionOptions] + () => filteredOptions.filter((c) => pinnedFilters.includes(c.messageID)), + [pinnedFilters, filteredOptions] ); const unpinnedElements = useMemo( - () => criterionOptions.filter((c) => !pinnedFilters.includes(c.messageID)), - [pinnedFilters, criterionOptions] + () => filteredOptions.filter((c) => !pinnedFilters.includes(c.messageID)), + [pinnedFilters, filteredOptions] ); const editingCriterionChanged = useCompare(editingCriterion); @@ -304,6 +323,17 @@ export const EditFilterDialog: React.FC = ({ editingCriterionChanged, ]); + useEffect(() => { + Mousetrap.bind("/", (e) => { + setSearchFocus(); + e.preventDefault(); + }); + + return () => { + Mousetrap.unbind("/"); + }; + }); + async function updatePinnedFilters(filters: string[]) { const configKey = filterModeToConfigKey(currentFilter.mode); try { @@ -403,7 +433,16 @@ export const EditFilterDialog: React.FC = ({ <> onCancel()} className="edit-filter-dialog"> - +
+ +
+ setSearchValue(e.target.value)} + value={searchValue} + placeholder={`${intl.formatMessage({ id: "actions.search" })}…`} + ref={searchRef} + />
({ // set up hotkeys useEffect(() => { - Mousetrap.bind("f", () => setShowEditFilter(true)); + Mousetrap.bind("f", (e) => { + setShowEditFilter(true); + // prevent default behavior of typing f in a text field + // otherwise the filter dialog closes, the query field is focused and + // f is typed. + e.preventDefault(); + }); return () => { Mousetrap.unbind("f"); diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index cec9da69e..8b4c67827 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -118,6 +118,15 @@ input[type="range"].zoom-slider { } .edit-filter-dialog { + .modal-header { + align-items: center; + padding: 0.5rem 1rem; + + .search-input { + width: auto; + } + } + .modal-body { padding-left: 0; padding-right: 0; diff --git a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md index 7eee67ef4..98c13cad0 100644 --- a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md +++ b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md @@ -24,7 +24,7 @@ | Keyboard sequence | Action | |-------------------|--------| -| `/` | Focus search field | +| `/` | Focus search field / focus query field in filter dialog | | `f` | Show Add Filter dialog | | `r` | Reshuffle if sorted by random | | `v g` | Set view to grid | diff --git a/ui/v2.5/src/utils/focus.ts b/ui/v2.5/src/utils/focus.ts index 0ab1e3b68..189920752 100644 --- a/ui/v2.5/src/utils/focus.ts +++ b/ui/v2.5/src/utils/focus.ts @@ -1,4 +1,4 @@ -import { useRef } from "react"; +import { useRef, useEffect } from "react"; const useFocus = () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -14,4 +14,19 @@ const useFocus = () => { return [htmlElRef, setFocus] as const; }; +// focuses on the element only once on mount +export const useFocusOnce = () => { + const [htmlElRef, setFocus] = useFocus(); + const focused = useRef(false); + + useEffect(() => { + if (!focused.current) { + setFocus(); + focused.current = true; + } + }, [setFocus]); + + return [htmlElRef, setFocus] as const; +}; + export default useFocus; From 124adb3f5bf0b4f41f8bdcd2f09e3d9c04f2ab7b Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Tue, 23 May 2023 03:40:27 +0200 Subject: [PATCH 53/81] Fix bulk performer update plugin hook (#3754) --- internal/api/resolver_mutation_performer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go index 88aab07d0..5b9304ba3 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -418,7 +418,7 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe // execute post hooks outside of txn var newRet []*models.Performer for _, performer := range ret { - r.hookExecutor.ExecutePostHooks(ctx, performer.ID, plugin.ImageUpdatePost, input, translator.getFields()) + r.hookExecutor.ExecutePostHooks(ctx, performer.ID, plugin.PerformerUpdatePost, input, translator.getFields()) performer, err = r.getPerformer(ctx, performer.ID) if err != nil { From 58a6c2207240f1479568cea0b95d0e72d4730bb2 Mon Sep 17 00:00:00 2001 From: CJ <72030708+Teda1@users.noreply.github.com> Date: Tue, 23 May 2023 00:07:06 -0500 Subject: [PATCH 54/81] honor dlna sort order to content exceeding the first page (#3747) --- internal/dlna/cds.go | 18 +++++++++++++----- internal/dlna/paging.go | 10 +++++----- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/internal/dlna/cds.go b/internal/dlna/cds.go index cf5deaa7c..22cc17718 100644 --- a/internal/dlna/cds.go +++ b/internal/dlna/cds.go @@ -440,15 +440,21 @@ func getRootObjects() []interface{} { return objs } +func getSortDirection(sceneFilter *models.SceneFilterType, sort string) models.SortDirectionEnum { + direction := models.SortDirectionEnumDesc + if sort == "title" { + direction = models.SortDirectionEnumAsc + } + + return direction +} + func (me *contentDirectoryService) getVideos(sceneFilter *models.SceneFilterType, parentID string, host string) []interface{} { var objs []interface{} if err := txn.WithReadTxn(context.TODO(), me.txnManager, func(ctx context.Context) error { sort := me.VideoSortOrder - direction := models.SortDirectionEnumDesc - if sort == "title" { - direction = models.SortDirectionEnumAsc - } + direction := getSortDirection(sceneFilter, sort) findFilter := &models.FindFilterType{ PerPage: &pageSize, Sort: &sort, @@ -497,8 +503,10 @@ func (me *contentDirectoryService) getPageVideos(sceneFilter *models.SceneFilter parentID: parentID, } + sort := me.VideoSortOrder + direction := getSortDirection(sceneFilter, sort) var err error - objs, err = pager.getPageVideos(ctx, me.repository.SceneFinder, me.repository.FileFinder, page, host) + objs, err = pager.getPageVideos(ctx, me.repository.SceneFinder, me.repository.FileFinder, page, host, sort, direction) if err != nil { return err } diff --git a/internal/dlna/paging.go b/internal/dlna/paging.go index d5643da88..bd1b00283 100644 --- a/internal/dlna/paging.go +++ b/internal/dlna/paging.go @@ -60,14 +60,14 @@ func (p *scenePager) getPages(ctx context.Context, r scene.Queryer, total int) ( return objs, nil } -func (p *scenePager) getPageVideos(ctx context.Context, r SceneFinder, f file.Finder, page int, host string) ([]interface{}, error) { +func (p *scenePager) getPageVideos(ctx context.Context, r SceneFinder, f file.Finder, page int, host string, sort string, direction models.SortDirectionEnum) ([]interface{}, error) { var objs []interface{} - sort := "title" findFilter := &models.FindFilterType{ - PerPage: &pageSize, - Page: &page, - Sort: &sort, + PerPage: &pageSize, + Page: &page, + Sort: &sort, + Direction: &direction, } scenes, err := scene.Query(ctx, r, p.sceneFilter, findFilter) From 776c7e6c35ec96f47ab24ba5f5019f0d3abf8f1b Mon Sep 17 00:00:00 2001 From: departure18 <92104199+departure18@users.noreply.github.com> Date: Wed, 24 May 2023 04:19:35 +0100 Subject: [PATCH 55/81] Add penis length and circumcision stats to performers. (#3627) * Add penis length stat to performers. * Modified the UI to display and edit the stat. * Added the ability to filter floats to allow filtering by penis length. * Add circumcision stat to performer. * Refactor enum filtering * Change boolean filter to radio buttons * Return null for empty enum values --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/documents/data/performer-slim.graphql | 2 + graphql/documents/data/performer.graphql | 2 + graphql/documents/data/scrapers.graphql | 4 + graphql/schema/types/filters.graphql | 15 ++ graphql/schema/types/performer.graphql | 13 ++ .../schema/types/scraped-performer.graphql | 4 + internal/api/images.go | 9 +- internal/api/resolver_mutation_performer.go | 28 +++- internal/identify/performer.go | 13 +- internal/identify/performer_test.go | 6 +- internal/manager/task_stash_box_tag.go | 6 +- pkg/models/filter.go | 14 ++ pkg/models/jsonschema/performer.go | 2 + pkg/models/model_performer.go | 44 ++--- pkg/models/model_scraped_item.go | 2 + pkg/models/performer.go | 50 ++++++ pkg/performer/export.go | 13 +- pkg/performer/export_test.go | 20 ++- pkg/performer/import.go | 15 +- pkg/scraper/autotag.go | 2 +- pkg/scraper/performer.go | 2 + pkg/scraper/stash.go | 2 + pkg/scraper/stashbox/stash_box.go | 2 +- pkg/sqlite/database.go | 2 +- pkg/sqlite/filter.go | 32 ++++ pkg/sqlite/migrations/46_penis_stats.up.sql | 2 + pkg/sqlite/performer.go | 31 +++- pkg/sqlite/performer_test.go | 150 ++++++++++++++++-- pkg/sqlite/record.go | 11 +- pkg/sqlite/setup_test.go | 25 +++ pkg/sqlite/sql.go | 52 ++++-- pkg/sqlite/values.go | 9 ++ pkg/utils/strings.go | 10 ++ .../src/components/List/CriterionEditor.tsx | 23 ++- .../components/List/Filters/BooleanFilter.tsx | 4 +- .../components/List/Filters/OptionFilter.tsx | 85 ++++++++++ .../components/List/Filters/OptionsFilter.tsx | 45 ------ .../List/Filters/OptionsListFilter.tsx | 45 ------ .../Performers/EditPerformersDialog.tsx | 54 +++++++ .../PerformerDetailsPanel.tsx | 56 ++++++- .../PerformerDetails/PerformerEditPanel.tsx | 64 ++++++++ .../PerformerScrapeDialog.tsx | 101 ++++++++++++ ui/v2.5/src/components/Performers/styles.scss | 28 ++-- ui/v2.5/src/core/StashService.ts | 5 + ui/v2.5/src/locales/en-GB.json | 8 + .../list-filter/criteria/circumcised.ts | 36 +++++ .../models/list-filter/criteria/criterion.ts | 19 +++ .../models/list-filter/criteria/factory.ts | 5 + ui/v2.5/src/models/list-filter/performers.ts | 4 + ui/v2.5/src/models/list-filter/types.ts | 2 + ui/v2.5/src/utils/circumcised.ts | 51 ++++++ ui/v2.5/src/utils/units.ts | 6 + 52 files changed, 1051 insertions(+), 184 deletions(-) create mode 100644 pkg/sqlite/migrations/46_penis_stats.up.sql create mode 100644 ui/v2.5/src/components/List/Filters/OptionFilter.tsx delete mode 100644 ui/v2.5/src/components/List/Filters/OptionsFilter.tsx delete mode 100644 ui/v2.5/src/components/List/Filters/OptionsListFilter.tsx create mode 100644 ui/v2.5/src/models/list-filter/criteria/circumcised.ts create mode 100644 ui/v2.5/src/utils/circumcised.ts diff --git a/graphql/documents/data/performer-slim.graphql b/graphql/documents/data/performer-slim.graphql index 4bac5d90b..65019b98b 100644 --- a/graphql/documents/data/performer-slim.graphql +++ b/graphql/documents/data/performer-slim.graphql @@ -16,6 +16,8 @@ fragment SlimPerformerData on Performer { eye_color height_cm fake_tits + penis_length + circumcised career_length tattoos piercings diff --git a/graphql/documents/data/performer.graphql b/graphql/documents/data/performer.graphql index ed469f01e..c89ce1e13 100644 --- a/graphql/documents/data/performer.graphql +++ b/graphql/documents/data/performer.graphql @@ -14,6 +14,8 @@ fragment PerformerData on Performer { height_cm measurements fake_tits + penis_length + circumcised career_length tattoos piercings diff --git a/graphql/documents/data/scrapers.graphql b/graphql/documents/data/scrapers.graphql index 8d02b3362..1d4553a97 100644 --- a/graphql/documents/data/scrapers.graphql +++ b/graphql/documents/data/scrapers.graphql @@ -13,6 +13,8 @@ fragment ScrapedPerformerData on ScrapedPerformer { height measurements fake_tits + penis_length + circumcised career_length tattoos piercings @@ -43,6 +45,8 @@ fragment ScrapedScenePerformerData on ScrapedPerformer { height measurements fake_tits + penis_length + circumcised career_length tattoos piercings diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index a635eaf51..0b18cbfee 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -76,6 +76,10 @@ input PerformerFilterType { measurements: StringCriterionInput """Filter by fake tits value""" fake_tits: StringCriterionInput + """Filter by penis length value""" + penis_length: FloatCriterionInput + """Filter by ciricumcision""" + circumcised: CircumcisionCriterionInput """Filter by career length""" career_length: StringCriterionInput """Filter by tattoos""" @@ -505,6 +509,12 @@ input IntCriterionInput { modifier: CriterionModifier! } +input FloatCriterionInput { + value: Float! + value2: Float + modifier: CriterionModifier! +} + input MultiCriterionInput { value: [ID!] modifier: CriterionModifier! @@ -514,6 +524,11 @@ input GenderCriterionInput { value: GenderEnum modifier: CriterionModifier! } + +input CircumcisionCriterionInput { + value: [CircumisedEnum!] + modifier: CriterionModifier! +} input HierarchicalMultiCriterionInput { value: [ID!] diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index 401f3b7c6..6cbe6ed32 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -6,6 +6,11 @@ enum GenderEnum { INTERSEX NON_BINARY } + +enum CircumisedEnum { + CUT + UNCUT +} type Performer { id: ID! @@ -24,6 +29,8 @@ type Performer { height_cm: Int measurements: String fake_tits: String + penis_length: Float + circumcised: CircumisedEnum career_length: String tattoos: String piercings: String @@ -69,6 +76,8 @@ input PerformerCreateInput { height_cm: Int measurements: String fake_tits: String + penis_length: Float + circumcised: CircumisedEnum career_length: String tattoos: String piercings: String @@ -107,6 +116,8 @@ input PerformerUpdateInput { height_cm: Int measurements: String fake_tits: String + penis_length: Float + circumcised: CircumisedEnum career_length: String tattoos: String piercings: String @@ -150,6 +161,8 @@ input BulkPerformerUpdateInput { height_cm: Int measurements: String fake_tits: String + penis_length: Float + circumcised: CircumisedEnum career_length: String tattoos: String piercings: String diff --git a/graphql/schema/types/scraped-performer.graphql b/graphql/schema/types/scraped-performer.graphql index 518e5abca..a23b04fed 100644 --- a/graphql/schema/types/scraped-performer.graphql +++ b/graphql/schema/types/scraped-performer.graphql @@ -15,6 +15,8 @@ type ScrapedPerformer { height: String measurements: String fake_tits: String + penis_length: String + circumcised: String career_length: String tattoos: String piercings: String @@ -48,6 +50,8 @@ input ScrapedPerformerInput { height: String measurements: String fake_tits: String + penis_length: String + circumcised: String career_length: String tattoos: String piercings: String diff --git a/internal/api/images.go b/internal/api/images.go index ddcaee629..7ddbbfc10 100644 --- a/internal/api/images.go +++ b/internal/api/images.go @@ -87,7 +87,7 @@ func initialiseCustomImages() { } } -func getRandomPerformerImageUsingName(name string, gender models.GenderEnum, customPath string) ([]byte, error) { +func getRandomPerformerImageUsingName(name string, gender *models.GenderEnum, customPath string) ([]byte, error) { var box *imageBox // If we have a custom path, we should return a new box in the given path. @@ -95,8 +95,13 @@ func getRandomPerformerImageUsingName(name string, gender models.GenderEnum, cus box = performerBoxCustom } + var g models.GenderEnum + if gender != nil { + g = *gender + } + if box == nil { - switch gender { + switch g { case models.GenderEnumFemale: box = performerBox case models.GenderEnumMale: diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go index 5b9304ba3..2f3e9e01b 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -67,7 +67,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerC newPerformer.URL = *input.URL } if input.Gender != nil { - newPerformer.Gender = *input.Gender + newPerformer.Gender = input.Gender } if input.Birthdate != nil { d := models.NewDate(*input.Birthdate) @@ -98,6 +98,12 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerC if input.FakeTits != nil { newPerformer.FakeTits = *input.FakeTits } + if input.PenisLength != nil { + newPerformer.PenisLength = input.PenisLength + } + if input.Circumcised != nil { + newPerformer.Circumcised = input.Circumcised + } if input.CareerLength != nil { newPerformer.CareerLength = *input.CareerLength } @@ -222,6 +228,16 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input PerformerU updatedPerformer.Ethnicity = translator.optionalString(input.Ethnicity, "ethnicity") updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits") + updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length") + + if translator.hasField("circumcised") { + if input.Circumcised != nil { + updatedPerformer.Circumcised = models.NewOptionalString(input.Circumcised.String()) + } else { + updatedPerformer.Circumcised = models.NewOptionalStringPtr(nil) + } + } + updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") @@ -339,6 +355,16 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe updatedPerformer.Measurements = translator.optionalString(input.Measurements, "measurements") updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits") + updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length") + + if translator.hasField("circumcised") { + if input.Circumcised != nil { + updatedPerformer.Circumcised = models.NewOptionalString(input.Circumcised.String()) + } else { + updatedPerformer.Circumcised = models.NewOptionalStringPtr(nil) + } + } + updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") diff --git a/internal/identify/performer.go b/internal/identify/performer.go index a78a0ce6c..cb16f2a83 100644 --- a/internal/identify/performer.go +++ b/internal/identify/performer.go @@ -65,7 +65,8 @@ func scrapedToPerformerInput(performer *models.ScrapedPerformer) models.Performe ret.DeathDate = &d } if performer.Gender != nil { - ret.Gender = models.GenderEnum(*performer.Gender) + v := models.GenderEnum(*performer.Gender) + ret.Gender = &v } if performer.Ethnicity != nil { ret.Ethnicity = *performer.Ethnicity @@ -97,6 +98,16 @@ func scrapedToPerformerInput(performer *models.ScrapedPerformer) models.Performe if performer.FakeTits != nil { ret.FakeTits = *performer.FakeTits } + if performer.PenisLength != nil { + h, err := strconv.ParseFloat(*performer.PenisLength, 64) + if err == nil { + ret.PenisLength = &h + } + } + if performer.Circumcised != nil { + v := models.CircumisedEnum(*performer.Circumcised) + ret.Circumcised = &v + } if performer.CareerLength != nil { ret.CareerLength = *performer.CareerLength } diff --git a/internal/identify/performer_test.go b/internal/identify/performer_test.go index 0a78ea173..9ba1018c7 100644 --- a/internal/identify/performer_test.go +++ b/internal/identify/performer_test.go @@ -228,6 +228,10 @@ func Test_scrapedToPerformerInput(t *testing.T) { return &d } + genderPtr := func(g models.GenderEnum) *models.GenderEnum { + return &g + } + tests := []struct { name string performer *models.ScrapedPerformer @@ -259,7 +263,7 @@ func Test_scrapedToPerformerInput(t *testing.T) { Name: name, Birthdate: dateToDatePtr(models.NewDate(*nextVal())), DeathDate: dateToDatePtr(models.NewDate(*nextVal())), - Gender: models.GenderEnum(*nextVal()), + Gender: genderPtr(models.GenderEnum(*nextVal())), Ethnicity: *nextVal(), Country: *nextVal(), EyeColor: *nextVal(), diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index e927a0335..dd31b4899 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -131,7 +131,6 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { EyeColor: getString(performer.EyeColor), HairColor: getString(performer.HairColor), FakeTits: getString(performer.FakeTits), - Gender: models.GenderEnum(getString(performer.Gender)), Height: getIntPtr(performer.Height), Weight: getIntPtr(performer.Weight), Instagram: getString(performer.Instagram), @@ -150,6 +149,11 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { UpdatedAt: currentTime, } + if performer.Gender != nil { + v := models.GenderEnum(getString(performer.Gender)) + newPerformer.Gender = &v + } + err := txn.WithTxn(ctx, instance.Repository, func(ctx context.Context) error { r := instance.Repository err := r.Performer.Create(ctx, &newPerformer) diff --git a/pkg/models/filter.go b/pkg/models/filter.go index 47e93f237..42cff1118 100644 --- a/pkg/models/filter.go +++ b/pkg/models/filter.go @@ -109,6 +109,20 @@ func (i IntCriterionInput) ValidModifier() bool { return false } +type FloatCriterionInput struct { + Value float64 `json:"value"` + Value2 *float64 `json:"value2"` + Modifier CriterionModifier `json:"modifier"` +} + +func (i FloatCriterionInput) ValidModifier() bool { + switch i.Modifier { + case CriterionModifierEquals, CriterionModifierNotEquals, CriterionModifierGreaterThan, CriterionModifierLessThan, CriterionModifierIsNull, CriterionModifierNotNull, CriterionModifierBetween, CriterionModifierNotBetween: + return true + } + return false +} + type ResolutionCriterionInput struct { Value ResolutionEnum `json:"value"` Modifier CriterionModifier `json:"modifier"` diff --git a/pkg/models/jsonschema/performer.go b/pkg/models/jsonschema/performer.go index c0996a1a5..248cf9557 100644 --- a/pkg/models/jsonschema/performer.go +++ b/pkg/models/jsonschema/performer.go @@ -48,6 +48,8 @@ type Performer struct { Height string `json:"height,omitempty"` Measurements string `json:"measurements,omitempty"` FakeTits string `json:"fake_tits,omitempty"` + PenisLength float64 `json:"penis_length,omitempty"` + Circumcised string `json:"circumcised,omitempty"` CareerLength string `json:"career_length,omitempty"` Tattoos string `json:"tattoos,omitempty"` Piercings string `json:"piercings,omitempty"` diff --git a/pkg/models/model_performer.go b/pkg/models/model_performer.go index fd52a7674..134d46783 100644 --- a/pkg/models/model_performer.go +++ b/pkg/models/model_performer.go @@ -6,26 +6,28 @@ import ( ) type Performer struct { - ID int `json:"id"` - Name string `json:"name"` - Disambiguation string `json:"disambiguation"` - Gender GenderEnum `json:"gender"` - URL string `json:"url"` - Twitter string `json:"twitter"` - Instagram string `json:"instagram"` - Birthdate *Date `json:"birthdate"` - Ethnicity string `json:"ethnicity"` - Country string `json:"country"` - EyeColor string `json:"eye_color"` - Height *int `json:"height"` - Measurements string `json:"measurements"` - FakeTits string `json:"fake_tits"` - CareerLength string `json:"career_length"` - Tattoos string `json:"tattoos"` - Piercings string `json:"piercings"` - Favorite bool `json:"favorite"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int `json:"id"` + Name string `json:"name"` + Disambiguation string `json:"disambiguation"` + Gender *GenderEnum `json:"gender"` + URL string `json:"url"` + Twitter string `json:"twitter"` + Instagram string `json:"instagram"` + Birthdate *Date `json:"birthdate"` + Ethnicity string `json:"ethnicity"` + Country string `json:"country"` + EyeColor string `json:"eye_color"` + Height *int `json:"height"` + Measurements string `json:"measurements"` + FakeTits string `json:"fake_tits"` + PenisLength *float64 `json:"penis_length"` + Circumcised *CircumisedEnum `json:"circumcised"` + CareerLength string `json:"career_length"` + Tattoos string `json:"tattoos"` + Piercings string `json:"piercings"` + Favorite bool `json:"favorite"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // Rating expressed in 1-100 scale Rating *int `json:"rating"` Details string `json:"details"` @@ -90,6 +92,8 @@ type PerformerPartial struct { Height OptionalInt Measurements OptionalString FakeTits OptionalString + PenisLength OptionalFloat64 + Circumcised OptionalString CareerLength OptionalString Tattoos OptionalString Piercings OptionalString diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index fa25bcb7e..9d497b043 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -32,6 +32,8 @@ type ScrapedPerformer struct { Height *string `json:"height"` Measurements *string `json:"measurements"` FakeTits *string `json:"fake_tits"` + PenisLength *string `json:"penis_length"` + Circumcised *string `json:"circumcised"` CareerLength *string `json:"career_length"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` diff --git a/pkg/models/performer.go b/pkg/models/performer.go index aa6ea3af6..23b70b0da 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -61,6 +61,52 @@ type GenderCriterionInput struct { Modifier CriterionModifier `json:"modifier"` } +type CircumisedEnum string + +const ( + CircumisedEnumCut CircumisedEnum = "CUT" + CircumisedEnumUncut CircumisedEnum = "UNCUT" +) + +var AllCircumcisionEnum = []CircumisedEnum{ + CircumisedEnumCut, + CircumisedEnumUncut, +} + +func (e CircumisedEnum) IsValid() bool { + switch e { + case CircumisedEnumCut, CircumisedEnumUncut: + return true + } + return false +} + +func (e CircumisedEnum) String() string { + return string(e) +} + +func (e *CircumisedEnum) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = CircumisedEnum(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid CircumisedEnum", str) + } + return nil +} + +func (e CircumisedEnum) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +type CircumcisionCriterionInput struct { + Value []CircumisedEnum `json:"value"` + Modifier CriterionModifier `json:"modifier"` +} + type PerformerFilterType struct { And *PerformerFilterType `json:"AND"` Or *PerformerFilterType `json:"OR"` @@ -88,6 +134,10 @@ type PerformerFilterType struct { Measurements *StringCriterionInput `json:"measurements"` // Filter by fake tits value FakeTits *StringCriterionInput `json:"fake_tits"` + // Filter by penis length value + PenisLength *FloatCriterionInput `json:"penis_length"` + // Filter by circumcision + Circumcised *CircumcisionCriterionInput `json:"circumcised"` // Filter by career length CareerLength *StringCriterionInput `json:"career_length"` // Filter by tattoos diff --git a/pkg/performer/export.go b/pkg/performer/export.go index 4b46fd901..9aec8b34e 100644 --- a/pkg/performer/export.go +++ b/pkg/performer/export.go @@ -23,7 +23,6 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode newPerformerJSON := jsonschema.Performer{ Name: performer.Name, Disambiguation: performer.Disambiguation, - Gender: performer.Gender.String(), URL: performer.URL, Ethnicity: performer.Ethnicity, Country: performer.Country, @@ -43,6 +42,14 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode UpdatedAt: json.JSONTime{Time: performer.UpdatedAt}, } + if performer.Gender != nil { + newPerformerJSON.Gender = performer.Gender.String() + } + + if performer.Circumcised != nil { + newPerformerJSON.Circumcised = performer.Circumcised.String() + } + if performer.Birthdate != nil { newPerformerJSON.Birthdate = performer.Birthdate.String() } @@ -61,6 +68,10 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode newPerformerJSON.Weight = *performer.Weight } + if performer.PenisLength != nil { + newPerformerJSON.PenisLength = *performer.PenisLength + } + if err := performer.LoadAliases(ctx, reader); err != nil { return nil, fmt.Errorf("loading performer aliases: %w", err) } diff --git a/pkg/performer/export_test.go b/pkg/performer/export_test.go index f65693e3f..c5965404a 100644 --- a/pkg/performer/export_test.go +++ b/pkg/performer/export_test.go @@ -29,7 +29,6 @@ const ( ethnicity = "ethnicity" eyeColor = "eyeColor" fakeTits = "fakeTits" - gender = "gender" instagram = "instagram" measurements = "measurements" piercings = "piercings" @@ -42,10 +41,15 @@ const ( ) var ( - aliases = []string{"alias1", "alias2"} - rating = 5 - height = 123 - weight = 60 + genderEnum = models.GenderEnumFemale + gender = genderEnum.String() + aliases = []string{"alias1", "alias2"} + rating = 5 + height = 123 + weight = 60 + penisLength = 1.23 + circumcisedEnum = models.CircumisedEnumCut + circumcised = circumcisedEnum.String() ) var imageBytes = []byte("imageBytes") @@ -81,8 +85,10 @@ func createFullPerformer(id int, name string) *models.Performer { Ethnicity: ethnicity, EyeColor: eyeColor, FakeTits: fakeTits, + PenisLength: &penisLength, + Circumcised: &circumcisedEnum, Favorite: true, - Gender: gender, + Gender: &genderEnum, Height: &height, Instagram: instagram, Measurements: measurements, @@ -125,6 +131,8 @@ func createFullJSONPerformer(name string, image string) *jsonschema.Performer { Ethnicity: ethnicity, EyeColor: eyeColor, FakeTits: fakeTits, + PenisLength: penisLength, + Circumcised: circumcised, Favorite: true, Gender: gender, Height: strconv.Itoa(height), diff --git a/pkg/performer/import.go b/pkg/performer/import.go index beebab35d..4ca27ce55 100644 --- a/pkg/performer/import.go +++ b/pkg/performer/import.go @@ -189,7 +189,6 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform newPerformer := models.Performer{ Name: performerJSON.Name, Disambiguation: performerJSON.Disambiguation, - Gender: models.GenderEnum(performerJSON.Gender), URL: performerJSON.URL, Ethnicity: performerJSON.Ethnicity, Country: performerJSON.Country, @@ -213,6 +212,16 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform StashIDs: models.NewRelatedStashIDs(performerJSON.StashIDs), } + if performerJSON.Gender != "" { + v := models.GenderEnum(performerJSON.Gender) + newPerformer.Gender = &v + } + + if performerJSON.Circumcised != "" { + v := models.CircumisedEnum(performerJSON.Circumcised) + newPerformer.Circumcised = &v + } + if performerJSON.Birthdate != "" { d, err := utils.ParseDateStringAsTime(performerJSON.Birthdate) if err == nil { @@ -237,6 +246,10 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform newPerformer.Weight = &performerJSON.Weight } + if performerJSON.PenisLength != 0 { + newPerformer.PenisLength = &performerJSON.PenisLength + } + if performerJSON.Height != "" { h, err := strconv.Atoi(performerJSON.Height) if err == nil { diff --git a/pkg/scraper/autotag.go b/pkg/scraper/autotag.go index 53aedc749..786cd024d 100644 --- a/pkg/scraper/autotag.go +++ b/pkg/scraper/autotag.go @@ -41,7 +41,7 @@ func autotagMatchPerformers(ctx context.Context, path string, performerReader ma Name: &pp.Name, StoredID: &id, } - if pp.Gender.IsValid() { + if pp.Gender != nil && pp.Gender.IsValid() { v := pp.Gender.String() sp.Gender = &v } diff --git a/pkg/scraper/performer.go b/pkg/scraper/performer.go index 48f6ce318..269368823 100644 --- a/pkg/scraper/performer.go +++ b/pkg/scraper/performer.go @@ -16,6 +16,8 @@ type ScrapedPerformerInput struct { Height *string `json:"height"` Measurements *string `json:"measurements"` FakeTits *string `json:"fake_tits"` + PenisLength *string `json:"penis_length"` + Circumcised *string `json:"circumcised"` CareerLength *string `json:"career_length"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` diff --git a/pkg/scraper/stash.go b/pkg/scraper/stash.go index 9267bad0c..652a9de0a 100644 --- a/pkg/scraper/stash.go +++ b/pkg/scraper/stash.go @@ -69,6 +69,8 @@ type scrapedPerformerStash struct { Height *string `graphql:"height" json:"height"` Measurements *string `graphql:"measurements" json:"measurements"` FakeTits *string `graphql:"fake_tits" json:"fake_tits"` + PenisLength *string `graphql:"penis_length" json:"penis_length"` + Circumcised *string `graphql:"circumcised" json:"circumcised"` CareerLength *string `graphql:"career_length" json:"career_length"` Tattoos *string `graphql:"tattoos" json:"tattoos"` Piercings *string `graphql:"piercings" json:"piercings"` diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index b8eadfd1b..713265e7c 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -1009,7 +1009,7 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf if performer.FakeTits != "" { draft.BreastType = &performer.FakeTits } - if performer.Gender.IsValid() { + if performer.Gender != nil && performer.Gender.IsValid() { v := performer.Gender.String() draft.Gender = &v } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index d8e8b5e0d..c18b323ee 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -32,7 +32,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 45 +var appSchemaVersion uint = 46 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index 057fec179..d0c74772d 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -426,6 +426,29 @@ func stringCriterionHandler(c *models.StringCriterionInput, column string) crite } } +func enumCriterionHandler(modifier models.CriterionModifier, values []string, column string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if modifier.IsValid() { + switch modifier { + case models.CriterionModifierIncludes, models.CriterionModifierEquals: + if len(values) > 0 { + f.whereClauses = append(f.whereClauses, getEnumSearchClause(column, values, false)) + } + case models.CriterionModifierExcludes, models.CriterionModifierNotEquals: + if len(values) > 0 { + f.whereClauses = append(f.whereClauses, getEnumSearchClause(column, values, true)) + } + case models.CriterionModifierIsNull: + f.addWhere("(" + column + " IS NULL OR TRIM(" + column + ") = '')") + case models.CriterionModifierNotNull: + f.addWhere("(" + column + " IS NOT NULL AND TRIM(" + column + ") != '')") + default: + panic("unsupported string filter modifier") + } + } + } +} + func pathCriterionHandler(c *models.StringCriterionInput, pathColumn string, basenameColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { @@ -525,6 +548,15 @@ func intCriterionHandler(c *models.IntCriterionInput, column string, addJoinFn f } } +func floatCriterionHandler(c *models.FloatCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + clause, args := getFloatCriterionWhereClause(column, *c) + f.addWhere(clause, args...) + } + } +} + func boolCriterionHandler(c *bool, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if c != nil { diff --git a/pkg/sqlite/migrations/46_penis_stats.up.sql b/pkg/sqlite/migrations/46_penis_stats.up.sql new file mode 100644 index 000000000..2e9e31654 --- /dev/null +++ b/pkg/sqlite/migrations/46_penis_stats.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE `performers` ADD COLUMN `penis_length` float; +ALTER TABLE `performers` ADD COLUMN `circumcised` varchar[10]; \ No newline at end of file diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index a197b2ce5..7468db8be 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -42,6 +42,8 @@ type performerRow struct { Height null.Int `db:"height"` Measurements zero.String `db:"measurements"` FakeTits zero.String `db:"fake_tits"` + PenisLength null.Float `db:"penis_length"` + Circumcised zero.String `db:"circumcised"` CareerLength zero.String `db:"career_length"` Tattoos zero.String `db:"tattoos"` Piercings zero.String `db:"piercings"` @@ -64,7 +66,7 @@ func (r *performerRow) fromPerformer(o models.Performer) { r.ID = o.ID r.Name = o.Name r.Disambigation = zero.StringFrom(o.Disambiguation) - if o.Gender.IsValid() { + if o.Gender != nil && o.Gender.IsValid() { r.Gender = zero.StringFrom(o.Gender.String()) } r.URL = zero.StringFrom(o.URL) @@ -79,6 +81,10 @@ func (r *performerRow) fromPerformer(o models.Performer) { r.Height = intFromPtr(o.Height) r.Measurements = zero.StringFrom(o.Measurements) r.FakeTits = zero.StringFrom(o.FakeTits) + r.PenisLength = null.FloatFromPtr(o.PenisLength) + if o.Circumcised != nil && o.Circumcised.IsValid() { + r.Circumcised = zero.StringFrom(o.Circumcised.String()) + } r.CareerLength = zero.StringFrom(o.CareerLength) r.Tattoos = zero.StringFrom(o.Tattoos) r.Piercings = zero.StringFrom(o.Piercings) @@ -100,7 +106,6 @@ func (r *performerRow) resolve() *models.Performer { ID: r.ID, Name: r.Name, Disambiguation: r.Disambigation.String, - Gender: models.GenderEnum(r.Gender.String), URL: r.URL.String, Twitter: r.Twitter.String, Instagram: r.Instagram.String, @@ -111,6 +116,7 @@ func (r *performerRow) resolve() *models.Performer { Height: nullIntPtr(r.Height), Measurements: r.Measurements.String, FakeTits: r.FakeTits.String, + PenisLength: nullFloatPtr(r.PenisLength), CareerLength: r.CareerLength.String, Tattoos: r.Tattoos.String, Piercings: r.Piercings.String, @@ -126,6 +132,16 @@ func (r *performerRow) resolve() *models.Performer { IgnoreAutoTag: r.IgnoreAutoTag, } + if r.Gender.ValueOrZero() != "" { + v := models.GenderEnum(r.Gender.String) + ret.Gender = &v + } + + if r.Circumcised.ValueOrZero() != "" { + v := models.CircumisedEnum(r.Circumcised.String) + ret.Circumcised = &v + } + return ret } @@ -147,6 +163,8 @@ func (r *performerRowRecord) fromPartial(o models.PerformerPartial) { r.setNullInt("height", o.Height) r.setNullString("measurements", o.Measurements) r.setNullString("fake_tits", o.FakeTits) + r.setNullFloat64("penis_length", o.PenisLength) + r.setNullString("circumcised", o.Circumcised) r.setNullString("career_length", o.CareerLength) r.setNullString("tattoos", o.Tattoos) r.setNullString("piercings", o.Piercings) @@ -597,6 +615,15 @@ func (qb *PerformerStore) makeFilter(ctx context.Context, filter *models.Perform query.handleCriterion(ctx, stringCriterionHandler(filter.Measurements, tableName+".measurements")) query.handleCriterion(ctx, stringCriterionHandler(filter.FakeTits, tableName+".fake_tits")) + query.handleCriterion(ctx, floatCriterionHandler(filter.PenisLength, tableName+".penis_length", nil)) + + query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if circumcised := filter.Circumcised; circumcised != nil { + v := utils.StringerSliceToStringSlice(circumcised.Value) + enumCriterionHandler(circumcised.Modifier, v, tableName+".circumcised")(ctx, f) + } + })) + query.handleCriterion(ctx, stringCriterionHandler(filter.CareerLength, tableName+".career_length")) query.handleCriterion(ctx, stringCriterionHandler(filter.Tattoos, tableName+".tattoos")) query.handleCriterion(ctx, stringCriterionHandler(filter.Piercings, tableName+".piercings")) diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index 2b24d6455..a874f3967 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -52,6 +52,8 @@ func Test_PerformerStore_Create(t *testing.T) { height = 134 measurements = "measurements" fakeTits = "fakeTits" + penisLength = 1.23 + circumcised = models.CircumisedEnumCut careerLength = "careerLength" tattoos = "tattoos" piercings = "piercings" @@ -81,7 +83,7 @@ func Test_PerformerStore_Create(t *testing.T) { models.Performer{ Name: name, Disambiguation: disambiguation, - Gender: gender, + Gender: &gender, URL: url, Twitter: twitter, Instagram: instagram, @@ -92,6 +94,8 @@ func Test_PerformerStore_Create(t *testing.T) { Height: &height, Measurements: measurements, FakeTits: fakeTits, + PenisLength: &penisLength, + Circumcised: &circumcised, CareerLength: careerLength, Tattoos: tattoos, Piercings: piercings, @@ -196,6 +200,8 @@ func Test_PerformerStore_Update(t *testing.T) { height = 134 measurements = "measurements" fakeTits = "fakeTits" + penisLength = 1.23 + circumcised = models.CircumisedEnumCut careerLength = "careerLength" tattoos = "tattoos" piercings = "piercings" @@ -226,7 +232,7 @@ func Test_PerformerStore_Update(t *testing.T) { ID: performerIDs[performerIdxWithGallery], Name: name, Disambiguation: disambiguation, - Gender: gender, + Gender: &gender, URL: url, Twitter: twitter, Instagram: instagram, @@ -237,6 +243,8 @@ func Test_PerformerStore_Update(t *testing.T) { Height: &height, Measurements: measurements, FakeTits: fakeTits, + PenisLength: &penisLength, + Circumcised: &circumcised, CareerLength: careerLength, Tattoos: tattoos, Piercings: piercings, @@ -327,6 +335,7 @@ func clearPerformerPartial() models.PerformerPartial { nullString := models.OptionalString{Set: true, Null: true} nullDate := models.OptionalDate{Set: true, Null: true} nullInt := models.OptionalInt{Set: true, Null: true} + nullFloat := models.OptionalFloat64{Set: true, Null: true} // leave mandatory fields return models.PerformerPartial{ @@ -342,6 +351,8 @@ func clearPerformerPartial() models.PerformerPartial { Height: nullInt, Measurements: nullString, FakeTits: nullString, + PenisLength: nullFloat, + Circumcised: nullString, CareerLength: nullString, Tattoos: nullString, Piercings: nullString, @@ -372,6 +383,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { height = 143 measurements = "measurements" fakeTits = "fakeTits" + penisLength = 1.23 + circumcised = models.CircumisedEnumCut careerLength = "careerLength" tattoos = "tattoos" piercings = "piercings" @@ -415,6 +428,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { Height: models.NewOptionalInt(height), Measurements: models.NewOptionalString(measurements), FakeTits: models.NewOptionalString(fakeTits), + PenisLength: models.NewOptionalFloat64(penisLength), + Circumcised: models.NewOptionalString(circumcised.String()), CareerLength: models.NewOptionalString(careerLength), Tattoos: models.NewOptionalString(tattoos), Piercings: models.NewOptionalString(piercings), @@ -453,7 +468,7 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { ID: performerIDs[performerIdxWithDupName], Name: name, Disambiguation: disambiguation, - Gender: gender, + Gender: &gender, URL: url, Twitter: twitter, Instagram: instagram, @@ -464,6 +479,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { Height: &height, Measurements: measurements, FakeTits: fakeTits, + PenisLength: &penisLength, + Circumcised: &circumcised, CareerLength: careerLength, Tattoos: tattoos, Piercings: piercings, @@ -957,16 +974,30 @@ func TestPerformerQuery(t *testing.T) { false, }, { - "alias", + "circumcised (cut)", nil, &models.PerformerFilterType{ - Aliases: &models.StringCriterionInput{ - Value: getPerformerStringValue(performerIdxWithGallery, "alias"), - Modifier: models.CriterionModifierEquals, + Circumcised: &models.CircumcisionCriterionInput{ + Value: []models.CircumisedEnum{models.CircumisedEnumCut}, + Modifier: models.CriterionModifierIncludes, }, }, - []int{performerIdxWithGallery}, - []int{performerIdxWithScene}, + []int{performerIdx1WithScene}, + []int{performerIdxWithScene, performerIdx2WithScene}, + false, + }, + { + "circumcised (excludes cut)", + nil, + &models.PerformerFilterType{ + Circumcised: &models.CircumcisionCriterionInput{ + Value: []models.CircumisedEnum{models.CircumisedEnumCut}, + Modifier: models.CriterionModifierExcludes, + }, + }, + []int{performerIdx2WithScene}, + // performerIdxWithScene has null value + []int{performerIdx1WithScene, performerIdxWithScene}, false, }, } @@ -995,6 +1026,107 @@ func TestPerformerQuery(t *testing.T) { } } +func TestPerformerQueryPenisLength(t *testing.T) { + var upper = 4.0 + + tests := []struct { + name string + modifier models.CriterionModifier + value float64 + value2 *float64 + }{ + { + "equals", + models.CriterionModifierEquals, + 1, + nil, + }, + { + "not equals", + models.CriterionModifierNotEquals, + 1, + nil, + }, + { + "greater than", + models.CriterionModifierGreaterThan, + 1, + nil, + }, + { + "between", + models.CriterionModifierBetween, + 2, + &upper, + }, + { + "greater than", + models.CriterionModifierNotBetween, + 2, + &upper, + }, + { + "null", + models.CriterionModifierIsNull, + 0, + nil, + }, + { + "not null", + models.CriterionModifierNotNull, + 0, + nil, + }, + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + filter := &models.PerformerFilterType{ + PenisLength: &models.FloatCriterionInput{ + Modifier: tt.modifier, + Value: tt.value, + Value2: tt.value2, + }, + } + + performers, _, err := db.Performer.Query(ctx, filter, nil) + if err != nil { + t.Errorf("PerformerStore.Query() error = %v", err) + return + } + + for _, p := range performers { + verifyFloat(t, p.PenisLength, *filter.PenisLength) + } + }) + } +} + +func verifyFloat(t *testing.T, value *float64, criterion models.FloatCriterionInput) bool { + t.Helper() + assert := assert.New(t) + switch criterion.Modifier { + case models.CriterionModifierEquals: + return assert.NotNil(value) && assert.Equal(criterion.Value, *value) + case models.CriterionModifierNotEquals: + return assert.NotNil(value) && assert.NotEqual(criterion.Value, *value) + case models.CriterionModifierGreaterThan: + return assert.NotNil(value) && assert.Greater(*value, criterion.Value) + case models.CriterionModifierLessThan: + return assert.NotNil(value) && assert.Less(*value, criterion.Value) + case models.CriterionModifierBetween: + return assert.NotNil(value) && assert.GreaterOrEqual(*value, criterion.Value) && assert.LessOrEqual(*value, *criterion.Value2) + case models.CriterionModifierNotBetween: + return assert.NotNil(value) && assert.True(*value < criterion.Value || *value > *criterion.Value2) + case models.CriterionModifierIsNull: + return assert.Nil(value) + case models.CriterionModifierNotNull: + return assert.NotNil(value) + } + + return false +} + func TestPerformerQueryForAutoTag(t *testing.T) { withTxn(func(ctx context.Context) error { tqb := db.Performer diff --git a/pkg/sqlite/record.go b/pkg/sqlite/record.go index fbee73e86..5f4d31b55 100644 --- a/pkg/sqlite/record.go +++ b/pkg/sqlite/record.go @@ -3,6 +3,7 @@ package sqlite import ( "github.com/doug-martin/goqu/v9/exp" "github.com/stashapp/stash/pkg/models" + "gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4/zero" ) @@ -77,11 +78,11 @@ func (r *updateRecord) setFloat64(destField string, v models.OptionalFloat64) { } } -// func (r *updateRecord) setNullFloat64(destField string, v models.OptionalFloat64) { -// if v.Set { -// r.set(destField, null.FloatFromPtr(v.Ptr())) -// } -// } +func (r *updateRecord) setNullFloat64(destField string, v models.OptionalFloat64) { + if v.Set { + r.set(destField, null.FloatFromPtr(v.Ptr())) + } +} func (r *updateRecord) setSQLiteTimestamp(destField string, v models.OptionalTime) { if v.Set { diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index affe3cd72..94c92035b 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1331,6 +1331,29 @@ func getPerformerCareerLength(index int) *string { return &ret } +func getPerformerPenisLength(index int) *float64 { + if index%5 == 0 { + return nil + } + + ret := float64(index) + return &ret +} + +func getPerformerCircumcised(index int) *models.CircumisedEnum { + var ret models.CircumisedEnum + switch { + case index%3 == 0: + return nil + case index%3 == 1: + ret = models.CircumisedEnumCut + default: + ret = models.CircumisedEnumUncut + } + + return &ret +} + func getIgnoreAutoTag(index int) bool { return index%5 == 0 } @@ -1372,6 +1395,8 @@ func createPerformers(ctx context.Context, n int, o int) error { DeathDate: getPerformerDeathDate(i), Details: getPerformerStringValue(i, "Details"), Ethnicity: getPerformerStringValue(i, "Ethnicity"), + PenisLength: getPerformerPenisLength(i), + Circumcised: getPerformerCircumcised(i), Rating: getIntPtr(getRating(i)), IgnoreAutoTag: getIgnoreAutoTag(i), TagIDs: models.NewRelatedIDs(tids), diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index a410bac28..90b922520 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -159,6 +159,22 @@ func getStringSearchClause(columns []string, q string, not bool) sqlClause { return makeClause("("+likes+")", args...) } +func getEnumSearchClause(column string, enumVals []string, not bool) sqlClause { + var args []interface{} + + notStr := "" + if not { + notStr = " NOT" + } + + clause := fmt.Sprintf("(%s%s IN %s)", column, notStr, getInBinding(len(enumVals))) + for _, enumVal := range enumVals { + args = append(args, enumVal) + } + + return makeClause(clause, args...) +} + func getInBinding(length int) string { bindings := strings.Repeat("?, ", length) bindings = strings.TrimRight(bindings, ", ") @@ -175,8 +191,26 @@ func getIntWhereClause(column string, modifier models.CriterionModifier, value i upper = &u } - args := []interface{}{value} - betweenArgs := []interface{}{value, *upper} + args := []interface{}{value, *upper} + return getNumericWhereClause(column, modifier, args) +} + +func getFloatCriterionWhereClause(column string, input models.FloatCriterionInput) (string, []interface{}) { + return getFloatWhereClause(column, input.Modifier, input.Value, input.Value2) +} + +func getFloatWhereClause(column string, modifier models.CriterionModifier, value float64, upper *float64) (string, []interface{}) { + if upper == nil { + u := 0.0 + upper = &u + } + + args := []interface{}{value, *upper} + return getNumericWhereClause(column, modifier, args) +} + +func getNumericWhereClause(column string, modifier models.CriterionModifier, args []interface{}) (string, []interface{}) { + singleArgs := args[0:1] switch modifier { case models.CriterionModifierIsNull: @@ -184,20 +218,20 @@ func getIntWhereClause(column string, modifier models.CriterionModifier, value i case models.CriterionModifierNotNull: return fmt.Sprintf("%s IS NOT NULL", column), nil case models.CriterionModifierEquals: - return fmt.Sprintf("%s = ?", column), args + return fmt.Sprintf("%s = ?", column), singleArgs case models.CriterionModifierNotEquals: - return fmt.Sprintf("%s != ?", column), args + return fmt.Sprintf("%s != ?", column), singleArgs case models.CriterionModifierBetween: - return fmt.Sprintf("%s BETWEEN ? AND ?", column), betweenArgs + return fmt.Sprintf("%s BETWEEN ? AND ?", column), args case models.CriterionModifierNotBetween: - return fmt.Sprintf("%s NOT BETWEEN ? AND ?", column), betweenArgs + return fmt.Sprintf("%s NOT BETWEEN ? AND ?", column), args case models.CriterionModifierLessThan: - return fmt.Sprintf("%s < ?", column), args + return fmt.Sprintf("%s < ?", column), singleArgs case models.CriterionModifierGreaterThan: - return fmt.Sprintf("%s > ?", column), args + return fmt.Sprintf("%s > ?", column), singleArgs } - panic("unsupported int modifier type " + modifier) + panic("unsupported numeric modifier type " + modifier) } func getDateCriterionWhereClause(column string, input models.DateCriterionInput) (string, []interface{}) { diff --git a/pkg/sqlite/values.go b/pkg/sqlite/values.go index eafb8e462..be812275f 100644 --- a/pkg/sqlite/values.go +++ b/pkg/sqlite/values.go @@ -24,6 +24,15 @@ func nullIntPtr(i null.Int) *int { return &v } +func nullFloatPtr(i null.Float) *float64 { + if !i.Valid { + return nil + } + + v := float64(i.Float64) + return &v +} + func nullIntFolderIDPtr(i null.Int) *file.FolderID { if !i.Valid { return nil diff --git a/pkg/utils/strings.go b/pkg/utils/strings.go index 02e1fe67b..0b57f5f6e 100644 --- a/pkg/utils/strings.go +++ b/pkg/utils/strings.go @@ -31,3 +31,13 @@ func StrFormat(format string, m StrFormatMap) string { return strings.NewReplacer(args...).Replace(format) } + +// StringerSliceToStringSlice converts a slice of fmt.Stringers to a slice of strings. +func StringerSliceToStringSlice[V fmt.Stringer](v []V) []string { + ret := make([]string, len(v)) + for i, vv := range v { + ret[i] = vv.String() + } + + return ret +} diff --git a/ui/v2.5/src/components/List/CriterionEditor.tsx b/ui/v2.5/src/components/List/CriterionEditor.tsx index 763d7c4f0..dd099cacd 100644 --- a/ui/v2.5/src/components/List/CriterionEditor.tsx +++ b/ui/v2.5/src/components/List/CriterionEditor.tsx @@ -36,7 +36,7 @@ import { StashIDFilter } from "./Filters/StashIDFilter"; import { RatingCriterion } from "../../models/list-filter/criteria/rating"; import { RatingFilter } from "./Filters/RatingFilter"; import { BooleanFilter } from "./Filters/BooleanFilter"; -import { OptionsListFilter } from "./Filters/OptionsListFilter"; +import { OptionFilter, OptionListFilter } from "./Filters/OptionFilter"; import { PathFilter } from "./Filters/PathFilter"; import { PhashCriterion } from "src/models/list-filter/criteria/phash"; import { PhashFilter } from "./Filters/PhashFilter"; @@ -132,18 +132,17 @@ const GenericCriterionEditor: React.FC = ({ !criterionIsNumberValue(criterion.value) && !criterionIsStashIDValue(criterion.value) && !criterionIsDateValue(criterion.value) && - !criterionIsTimestampValue(criterion.value) && - !Array.isArray(criterion.value) + !criterionIsTimestampValue(criterion.value) ) { - // if (!modifierOptions || modifierOptions.length === 0) { - return ( - - ); - // } - - // return ( - // - // ); + if (!Array.isArray(criterion.value)) { + return ( + + ); + } else { + return ( + + ); + } } if (criterion.criterionOption instanceof PathCriterionOption) { return ( diff --git a/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx b/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx index 0a04a4fc6..e9e2da084 100644 --- a/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/BooleanFilter.tsx @@ -30,14 +30,14 @@ export const BooleanFilter: React.FC = ({ id={`${criterion.getId()}-true`} onChange={() => onSelect(true)} checked={criterion.value === "true"} - type="checkbox" + type="radio" label={} /> onSelect(false)} checked={criterion.value === "false"} - type="checkbox" + type="radio" label={} />
diff --git a/ui/v2.5/src/components/List/Filters/OptionFilter.tsx b/ui/v2.5/src/components/List/Filters/OptionFilter.tsx new file mode 100644 index 000000000..dad0e38cc --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/OptionFilter.tsx @@ -0,0 +1,85 @@ +import cloneDeep from "lodash-es/cloneDeep"; +import React from "react"; +import { Form } from "react-bootstrap"; +import { + CriterionValue, + Criterion, +} from "src/models/list-filter/criteria/criterion"; + +interface IOptionsFilter { + criterion: Criterion; + setCriterion: (c: Criterion) => void; +} + +export const OptionFilter: React.FC = ({ + criterion, + setCriterion, +}) => { + function onSelect(v: string) { + const c = cloneDeep(criterion); + if (c.value === v) { + c.value = ""; + } else { + c.value = v; + } + + setCriterion(c); + } + + const { options } = criterion.criterionOption; + + return ( +
+ {options?.map((o) => ( + onSelect(o.toString())} + checked={criterion.value === o.toString()} + type="radio" + label={o.toString()} + /> + ))} +
+ ); +}; + +interface IOptionsListFilter { + criterion: Criterion; + setCriterion: (c: Criterion) => void; +} + +export const OptionListFilter: React.FC = ({ + criterion, + setCriterion, +}) => { + function onSelect(v: string) { + const c = cloneDeep(criterion); + const cv = c.value as string[]; + if (cv.includes(v)) { + c.value = cv.filter((x) => x !== v); + } else { + c.value = [...cv, v]; + } + + setCriterion(c); + } + + const { options } = criterion.criterionOption; + const value = criterion.value as string[]; + + return ( +
+ {options?.map((o) => ( + onSelect(o.toString())} + checked={value.includes(o.toString())} + type="checkbox" + label={o.toString()} + /> + ))} +
+ ); +}; diff --git a/ui/v2.5/src/components/List/Filters/OptionsFilter.tsx b/ui/v2.5/src/components/List/Filters/OptionsFilter.tsx deleted file mode 100644 index 2f6f40bdc..000000000 --- a/ui/v2.5/src/components/List/Filters/OptionsFilter.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React, { useMemo } from "react"; -import { Form } from "react-bootstrap"; -import { - Criterion, - CriterionValue, -} from "../../../models/list-filter/criteria/criterion"; - -interface IOptionsFilterProps { - criterion: Criterion; - onValueChanged: (value: CriterionValue) => void; -} - -export const OptionsFilter: React.FC = ({ - criterion, - onValueChanged, -}) => { - function onChanged(event: React.ChangeEvent) { - onValueChanged(event.target.value); - } - - const options = useMemo(() => { - const ret = criterion.criterionOption.options?.slice() ?? []; - - ret.unshift(""); - - return ret; - }, [criterion.criterionOption.options]); - - return ( - - - {options.map((c) => ( - - ))} - - - ); -}; diff --git a/ui/v2.5/src/components/List/Filters/OptionsListFilter.tsx b/ui/v2.5/src/components/List/Filters/OptionsListFilter.tsx deleted file mode 100644 index b84cf8bd1..000000000 --- a/ui/v2.5/src/components/List/Filters/OptionsListFilter.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import cloneDeep from "lodash-es/cloneDeep"; -import React from "react"; -import { Form } from "react-bootstrap"; -import { - CriterionValue, - Criterion, -} from "src/models/list-filter/criteria/criterion"; - -interface IOptionsListFilter { - criterion: Criterion; - setCriterion: (c: Criterion) => void; -} - -export const OptionsListFilter: React.FC = ({ - criterion, - setCriterion, -}) => { - function onSelect(v: string) { - const c = cloneDeep(criterion); - if (c.value === v) { - c.value = ""; - } else { - c.value = v; - } - - setCriterion(c); - } - - const { options } = criterion.criterionOption; - - return ( -
- {options?.map((o) => ( - onSelect(o.toString())} - checked={criterion.value === o.toString()} - type="checkbox" - label={o.toString()} - /> - ))} -
- ); -}; diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx index aff7fa268..892ac0989 100644 --- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -17,6 +17,11 @@ import { genderToString, stringToGender, } from "src/utils/gender"; +import { + circumcisedStrings, + circumcisedToString, + stringToCircumcised, +} from "src/utils/circumcised"; import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; @@ -45,6 +50,8 @@ const performerFields = [ // "weight", "measurements", "fake_tits", + "penis_length", + "circumcised", "hair_color", "tattoos", "piercings", @@ -64,10 +71,12 @@ export const EditPerformersDialog: React.FC = ( useState({}); // weight needs conversion to/from number const [weight, setWeight] = useState(); + const [penis_length, setPenisLength] = useState(); const [updateInput, setUpdateInput] = useState( {} ); const genderOptions = [""].concat(genderStrings); + const circumcisedOptions = [""].concat(circumcisedStrings); const [updatePerformers] = useBulkPerformerUpdate(getPerformerInput()); @@ -100,11 +109,19 @@ export const EditPerformersDialog: React.FC = ( updateInput.gender, aggregateState.gender ); + performerInput.circumcised = getAggregateInputValue( + updateInput.circumcised, + aggregateState.circumcised + ); if (weight !== undefined) { performerInput.weight = parseFloat(weight); } + if (penis_length !== undefined) { + performerInput.penis_length = parseFloat(penis_length); + } + return performerInput; } @@ -135,6 +152,7 @@ export const EditPerformersDialog: React.FC = ( const state = props.selected; let updateTagIds: string[] = []; let updateWeight: string | undefined | null = undefined; + let updatePenisLength: string | undefined | null = undefined; let first = true; state.forEach((performer: GQL.SlimPerformerDataFragment) => { @@ -151,6 +169,16 @@ export const EditPerformersDialog: React.FC = ( : performer.weight; updateWeight = getAggregateState(updateWeight, thisWeight, first); + const thisPenisLength = + performer.penis_length !== undefined && performer.penis_length !== null + ? performer.penis_length.toString() + : performer.penis_length; + updatePenisLength = getAggregateState( + updatePenisLength, + thisPenisLength, + first + ); + first = false; }); @@ -270,6 +298,32 @@ export const EditPerformersDialog: React.FC = ( {renderTextField("measurements", updateInput.measurements, (v) => setUpdateField({ measurements: v }) )} + {renderTextField("penis_length", penis_length, (v) => + setPenisLength(v) + )} + + + + + + + setUpdateField({ + circumcised: stringToCircumcised(event.currentTarget.value), + }) + } + > + {circumcisedOptions.map((opt) => ( + + ))} + + + {renderTextField("fake_tits", updateInput.fake_tits, (v) => setUpdateField({ fake_tits: v }) )} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index 9a0aa9f07..514258a38 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -6,7 +6,7 @@ import TextUtils from "src/utils/text"; import { getStashboxBase } from "src/utils/stashbox"; import { getCountryByISO } from "src/utils/country"; import { TextField, URLField } from "src/utils/field"; -import { cmToImperial, kgToLbs } from "src/utils/units"; +import { cmToImperial, cmToInches, kgToLbs } from "src/utils/units"; interface IPerformerDetails { performer: GQL.PerformerDataFragment; @@ -133,6 +133,49 @@ export const PerformerDetailsPanel: React.FC = ({ ); }; + const formatPenisLength = (penis_length?: number | null) => { + if (!penis_length) { + return ""; + } + + const inches = cmToInches(penis_length); + + return ( + + + {intl.formatNumber(penis_length, { + style: "unit", + unit: "centimeter", + unitDisplay: "short", + maximumFractionDigits: 2, + })} + + + {intl.formatNumber(inches, { + style: "unit", + unit: "inch", + unitDisplay: "narrow", + maximumFractionDigits: 2, + })} + + + ); + }; + + const formatCircumcised = (circumcised?: GQL.CircumisedEnum | null) => { + if (!circumcised) { + return ""; + } + + return ( + + {intl.formatMessage({ + id: "circumcised_types." + performer.circumcised, + })} + + ); + }; + return (
= ({ )} + {(performer.penis_length || performer.circumcised) && ( + <> +
+ : +
+
+ {formatPenisLength(performer.penis_length)} + {formatCircumcised(performer.circumcised)} +
+ + )} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index e8c2ef028..03f2dd128 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -31,6 +31,11 @@ import { stringGenderMap, stringToGender, } from "src/utils/gender"; +import { + circumcisedToString, + stringCircumMap, + stringToCircumcised, +} from "src/utils/circumcised"; import { ConfigurationContext } from "src/hooks/Config"; import { PerformerScrapeDialog } from "./PerformerScrapeDialog"; import PerformerScrapeModal from "./PerformerScrapeModal"; @@ -153,6 +158,8 @@ export const PerformerEditPanel: React.FC = ({ weight: yup.number().nullable().defined().default(null), measurements: yup.string().ensure(), fake_tits: yup.string().ensure(), + penis_length: yup.number().nullable().defined().default(null), + circumcised: yup.string().ensure(), tattoos: yup.string().ensure(), piercings: yup.string().ensure(), career_length: yup.string().ensure(), @@ -181,6 +188,8 @@ export const PerformerEditPanel: React.FC = ({ weight: performer.weight ?? null, measurements: performer.measurements ?? "", fake_tits: performer.fake_tits ?? "", + penis_length: performer.penis_length ?? null, + circumcised: (performer.circumcised as GQL.CircumisedEnum) ?? "", tattoos: performer.tattoos ?? "", piercings: performer.piercings ?? "", career_length: performer.career_length ?? "", @@ -219,6 +228,21 @@ export const PerformerEditPanel: React.FC = ({ } } + function translateScrapedCircumcised(scrapedCircumcised?: string) { + if (!scrapedCircumcised) { + return; + } + + const upperCircumcised = scrapedCircumcised.toUpperCase(); + const asEnum = circumcisedToString(upperCircumcised); + if (asEnum) { + return stringToCircumcised(asEnum); + } else { + const caseInsensitive = true; + return stringToCircumcised(scrapedCircumcised, caseInsensitive); + } + } + function renderNewTags() { if (!newTags || newTags.length === 0) { return; @@ -355,6 +379,13 @@ export const PerformerEditPanel: React.FC = ({ formik.setFieldValue("gender", newGender); } } + if (state.circumcised) { + // circumcised is a string in the scraper data + const newCircumcised = translateScrapedCircumcised(state.circumcised); + if (newCircumcised) { + formik.setFieldValue("circumcised", newCircumcised); + } + } if (state.tags) { // map tags to their ids and filter out those not found const newTagIds = state.tags.map((t) => t.stored_id).filter((t) => t); @@ -387,6 +418,9 @@ export const PerformerEditPanel: React.FC = ({ if (state.weight) { formik.setFieldValue("weight", state.weight); } + if (state.penis_length) { + formik.setFieldValue("penis_length", state.penis_length); + } const remoteSiteID = state.remote_site_id; if (remoteSiteID && (scraper as IStashBox).endpoint) { @@ -431,6 +465,8 @@ export const PerformerEditPanel: React.FC = ({ gender: input.gender || null, height_cm: input.height_cm || null, weight: input.weight || null, + penis_length: input.penis_length || null, + circumcised: input.circumcised || null, }, }, }); @@ -446,6 +482,8 @@ export const PerformerEditPanel: React.FC = ({ gender: input.gender || null, height_cm: input.height_cm || null, weight: input.weight || null, + penis_length: input.penis_length || null, + circumcised: input.circumcised || null, }, }, }); @@ -663,6 +701,7 @@ export const PerformerEditPanel: React.FC = ({ const currentPerformer = { ...formik.values, gender: formik.values.gender || null, + circumcised: formik.values.circumcised || null, image: formik.values.image ?? performer.image_path, }; @@ -990,6 +1029,31 @@ export const PerformerEditPanel: React.FC = ({ type: "number", messageID: "weight_kg", })} + {renderField("penis_length", { + type: "number", + messageID: "penis_length_cm", + })} + + + + + + + + + {Array.from(stringCircumMap.entries()).map(([name, value]) => ( + + ))} + + + + {renderField("measurements")} {renderField("fake_tits")} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index 6a6a006f7..90bd6f70c 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -20,6 +20,11 @@ import { genderToString, stringToGender, } from "src/utils/gender"; +import { + circumcisedStrings, + circumcisedToString, + stringToCircumcised, +} from "src/utils/circumcised"; import { IStashBox } from "./PerformerStashBoxModal"; function renderScrapedGender( @@ -120,6 +125,55 @@ function renderScrapedTagsRow( ); } +function renderScrapedCircumcised( + result: ScrapeResult, + isNew?: boolean, + onChange?: (value: string) => void +) { + const selectOptions = [""].concat(circumcisedStrings); + + return ( + { + if (isNew && onChange) { + onChange(e.currentTarget.value); + } + }} + > + {selectOptions.map((opt) => ( + + ))} + + ); +} + +function renderScrapedCircumcisedRow( + title: string, + result: ScrapeResult, + onChange: (value: ScrapeResult) => void +) { + return ( + renderScrapedCircumcised(result)} + renderNewField={() => + renderScrapedCircumcised(result, true, (value) => + onChange(result.cloneWithValue(value)) + ) + } + onChange={onChange} + /> + ); +} + interface IPerformerScrapeDialogProps { performer: Partial; scraped: GQL.ScrapedPerformer; @@ -165,6 +219,27 @@ export const PerformerScrapeDialog: React.FC = ( return genderToString(retEnum); } + function translateScrapedCircumcised(scrapedCircumcised?: string | null) { + if (!scrapedCircumcised) { + return; + } + + let retEnum: GQL.CircumisedEnum | undefined; + + // try to translate from enum values first + const upperCircumcised = scrapedCircumcised.toUpperCase(); + const asEnum = circumcisedToString(upperCircumcised); + if (asEnum) { + retEnum = stringToCircumcised(asEnum); + } else { + // try to match against circumcised strings + const caseInsensitive = true; + retEnum = stringToCircumcised(scrapedCircumcised, caseInsensitive); + } + + return circumcisedToString(retEnum); + } + const [name, setName] = useState>( new ScrapeResult(props.performer.name, props.scraped.name) ); @@ -216,6 +291,12 @@ export const PerformerScrapeDialog: React.FC = ( props.scraped.weight ) ); + const [penisLength, setPenisLength] = useState>( + new ScrapeResult( + props.performer.penis_length?.toString(), + props.scraped.penis_length + ) + ); const [measurements, setMeasurements] = useState>( new ScrapeResult( props.performer.measurements, @@ -252,6 +333,12 @@ export const PerformerScrapeDialog: React.FC = ( translateScrapedGender(props.scraped.gender) ) ); + const [circumcised, setCircumcised] = useState>( + new ScrapeResult( + circumcisedToString(props.performer.circumcised), + translateScrapedCircumcised(props.scraped.circumcised) + ) + ); const [details, setDetails] = useState>( new ScrapeResult(props.performer.details, props.scraped.details) ); @@ -338,6 +425,8 @@ export const PerformerScrapeDialog: React.FC = ( height, measurements, fakeTits, + penisLength, + circumcised, careerLength, tattoos, piercings, @@ -426,6 +515,8 @@ export const PerformerScrapeDialog: React.FC = ( death_date: deathDate.getNewValue(), hair_color: hairColor.getNewValue(), weight: weight.getNewValue(), + penis_length: penisLength.getNewValue(), + circumcised: circumcised.getNewValue(), remote_site_id: remoteSiteID.getNewValue(), }; } @@ -493,6 +584,16 @@ export const PerformerScrapeDialog: React.FC = ( result={height} onChange={(value) => setHeight(value)} /> + setPenisLength(value)} + /> + {renderScrapedCircumcisedRow( + intl.formatMessage({ id: "circumcised" }), + circumcised, + (value) => setCircumcised(value) + )} { death_date: toCreate.death_date, hair_color: toCreate.hair_color, weight: toCreate.weight ? Number(toCreate.weight) : undefined, + penis_length: toCreate.penis_length + ? Number(toCreate.penis_length) + : undefined, + circumcised: stringToCircumcised(toCreate.circumcised), }; return input; }; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 8827d38bc..5a84cba9b 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -141,6 +141,11 @@ "captions": "Captions", "career_length": "Career Length", "chapters": "Chapters", + "circumcised": "Circumcised", + "circumcised_types": { + "UNCUT": "Uncut", + "CUT": "Cut" + }, "component_tagger": { "config": { "active_instance": "Active stash-box instance:", @@ -1016,6 +1021,9 @@ "parent_tags": "Parent Tags", "part_of": "Part of {parent}", "path": "Path", + "penis": "Penis", + "penis_length": "Penis Length", + "penis_length_cm": "Penis Length (cm)", "perceptual_similarity": "Perceptual Similarity (phash)", "performer": "Performer", "performerTags": "Performer Tags", diff --git a/ui/v2.5/src/models/list-filter/criteria/circumcised.ts b/ui/v2.5/src/models/list-filter/criteria/circumcised.ts new file mode 100644 index 000000000..c18aa1b01 --- /dev/null +++ b/ui/v2.5/src/models/list-filter/criteria/circumcised.ts @@ -0,0 +1,36 @@ +import { + CircumcisionCriterionInput, + CircumisedEnum, + CriterionModifier, +} from "src/core/generated-graphql"; +import { circumcisedStrings, stringToCircumcised } from "src/utils/circumcised"; +import { CriterionOption, MultiStringCriterion } from "./criterion"; + +export const CircumcisedCriterionOption = new CriterionOption({ + messageID: "circumcised", + type: "circumcised", + options: circumcisedStrings, + modifierOptions: [ + CriterionModifier.Includes, + CriterionModifier.Excludes, + CriterionModifier.IsNull, + CriterionModifier.NotNull, + ], +}); + +export class CircumcisedCriterion extends MultiStringCriterion { + constructor() { + super(CircumcisedCriterionOption); + } + + protected toCriterionInput(): CircumcisionCriterionInput { + const value = this.value.map((v) => + stringToCircumcised(v) + ) as CircumisedEnum[]; + + return { + value, + modifier: this.modifier, + }; + } +} diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index 7dc299a77..642fe7336 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -28,6 +28,7 @@ import { export type Option = string | number | IOptionType; export type CriterionValue = | string + | string[] | ILabeledId[] | IHierarchicalLabelValue | INumberValue @@ -243,6 +244,24 @@ export class StringCriterion extends Criterion { } } +export class MultiStringCriterion extends Criterion { + constructor(type: CriterionOption) { + super(type, []); + } + + public getLabelValue(_intl: IntlShape) { + return this.value.join(", "); + } + + public isValid(): boolean { + return ( + this.modifier === CriterionModifier.IsNull || + this.modifier === CriterionModifier.NotNull || + this.value.length > 0 + ); + } +} + export class MandatoryStringCriterionOption extends CriterionOption { constructor( messageID: string, diff --git a/ui/v2.5/src/models/list-filter/criteria/factory.ts b/ui/v2.5/src/models/list-filter/criteria/factory.ts index f6c96cab8..311b78728 100644 --- a/ui/v2.5/src/models/list-filter/criteria/factory.ts +++ b/ui/v2.5/src/models/list-filter/criteria/factory.ts @@ -44,6 +44,7 @@ import { TagsCriterionOption, } from "./tags"; import { GenderCriterion } from "./gender"; +import { CircumcisedCriterion } from "./circumcised"; import { MoviesCriterionOption } from "./movies"; import { GalleriesCriterion } from "./galleries"; import { CriterionType } from "../types"; @@ -155,12 +156,16 @@ export function makeCriteria( case "death_year": case "weight": return new NumberCriterion(new NumberCriterionOption(type, type)); + case "penis_length": + return new NumberCriterion(new NumberCriterionOption(type, type)); case "age": return new NumberCriterion( new MandatoryNumberCriterionOption(type, type) ); case "gender": return new GenderCriterion(); + case "circumcised": + return new CircumcisedCriterion(); case "sceneChecksum": case "galleryChecksum": return new StringCriterion( diff --git a/ui/v2.5/src/models/list-filter/performers.ts b/ui/v2.5/src/models/list-filter/performers.ts index 5a628ca2a..2995aebb7 100644 --- a/ui/v2.5/src/models/list-filter/performers.ts +++ b/ui/v2.5/src/models/list-filter/performers.ts @@ -10,6 +10,7 @@ import { } from "./criteria/criterion"; import { FavoriteCriterionOption } from "./criteria/favorite"; import { GenderCriterionOption } from "./criteria/gender"; +import { CircumcisedCriterionOption } from "./criteria/circumcised"; import { PerformerIsMissingCriterionOption } from "./criteria/is-missing"; import { StashIDCriterionOption } from "./criteria/stash-ids"; import { StudiosCriterionOption } from "./criteria/studios"; @@ -25,6 +26,7 @@ const sortByOptions = [ "tag_count", "random", "rating", + "penis_length", ] .map(ListFilterOptions.createSortBy) .concat([ @@ -57,6 +59,7 @@ const numberCriteria: CriterionType[] = [ "death_year", "age", "weight", + "penis_length", ]; const stringCriteria: CriterionType[] = [ @@ -78,6 +81,7 @@ const stringCriteria: CriterionType[] = [ const criterionOptions = [ FavoriteCriterionOption, GenderCriterionOption, + CircumcisedCriterionOption, PerformerIsMissingCriterionOption, TagsCriterionOption, StudiosCriterionOption, diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index e105e8ab8..548adc59f 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -134,6 +134,8 @@ export type CriterionType = | "weight" | "measurements" | "fake_tits" + | "penis_length" + | "circumcised" | "career_length" | "tattoos" | "piercings" diff --git a/ui/v2.5/src/utils/circumcised.ts b/ui/v2.5/src/utils/circumcised.ts new file mode 100644 index 000000000..b922a7795 --- /dev/null +++ b/ui/v2.5/src/utils/circumcised.ts @@ -0,0 +1,51 @@ +import * as GQL from "../core/generated-graphql"; + +export const stringCircumMap = new Map([ + ["Uncut", GQL.CircumisedEnum.Uncut], + ["Cut", GQL.CircumisedEnum.Cut], +]); + +export const circumcisedToString = ( + value?: GQL.CircumisedEnum | String | null +) => { + if (!value) { + return undefined; + } + + const foundEntry = Array.from(stringCircumMap.entries()).find((e) => { + return e[1] === value; + }); + + if (foundEntry) { + return foundEntry[0]; + } +}; + +export const stringToCircumcised = ( + value?: string | null, + caseInsensitive?: boolean +): GQL.CircumisedEnum | undefined => { + if (!value) { + return undefined; + } + + const existing = Object.entries(GQL.CircumisedEnum).find( + (e) => e[1] === value + ); + if (existing) return existing[1]; + + const ret = stringCircumMap.get(value); + if (ret || !caseInsensitive) { + return ret; + } + const asUpper = value.toUpperCase(); + const foundEntry = Array.from(stringCircumMap.entries()).find((e) => { + return e[0].toUpperCase() === asUpper; + }); + + if (foundEntry) { + return foundEntry[1]; + } +}; + +export const circumcisedStrings = Array.from(stringCircumMap.keys()); diff --git a/ui/v2.5/src/utils/units.ts b/ui/v2.5/src/utils/units.ts index 3115eed5f..f0cae7e52 100644 --- a/ui/v2.5/src/utils/units.ts +++ b/ui/v2.5/src/utils/units.ts @@ -9,3 +9,9 @@ export function cmToImperial(cm: number) { export function kgToLbs(kg: number) { return Math.round(kg * 2.20462262185); } + +export function cmToInches(cm: number) { + const cmInInches = 0.393700787; + const inches = cm * cmInInches; + return inches; +} From 94dda493525e3e15e5508e28e838cc5a70aecdad Mon Sep 17 00:00:00 2001 From: Bawdy Ink Slinger <51732963+BawdyInkSlinger@users.noreply.github.com> Date: Wed, 24 May 2023 16:27:37 -0700 Subject: [PATCH 56/81] Updated the English auto_tag_based_on_filenames message (#3682) * Updated the English auto_tag_based_on_filenames message --- ui/v2.5/src/locales/en-GB.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 5a84cba9b..d314780e5 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -412,7 +412,7 @@ "auto_tagging_all_paths": "Auto Tagging all paths", "auto_tagging_paths": "Auto Tagging the following paths" }, - "auto_tag_based_on_filenames": "Auto-tag content based on filenames.", + "auto_tag_based_on_filenames": "Auto-tag content based on file paths.", "auto_tagging": "Auto Tagging", "backing_up_database": "Backing up database", "backup_and_download": "Performs a backup of the database and downloads the resulting file.", From ed7640b7b1a986169e2be147233f24c4f7897557 Mon Sep 17 00:00:00 2001 From: yoshnopa <40072150+yoshnopa@users.noreply.github.com> Date: Thu, 25 May 2023 01:29:05 +0200 Subject: [PATCH 57/81] Update Metadata Bugfix (#3757) --- pkg/file/image/scan.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/pkg/file/image/scan.go b/pkg/file/image/scan.go index afe4210e0..ec4ce542b 100644 --- a/pkg/file/image/scan.go +++ b/pkg/file/image/scan.go @@ -69,10 +69,16 @@ func (d *Decorator) IsMissingMetadata(ctx context.Context, fs file.FS, f file.Fi unsetNumber = -1 ) - imf, ok := f.(*file.ImageFile) - if !ok { + imf, isImage := f.(*file.ImageFile) + vf, isVideo := f.(*file.VideoFile) + + switch { + case isImage: + return imf.Format == unsetString || imf.Width == unsetNumber || imf.Height == unsetNumber + case isVideo: + videoFileDecorator := video.Decorator{FFProbe: d.FFProbe} + return videoFileDecorator.IsMissingMetadata(ctx, fs, vf) + default: return true } - - return imf.Format == unsetString || imf.Width == unsetNumber || imf.Height == unsetNumber } From 2a85d512f4e9d75bad1062cfc6deded233ea1a25 Mon Sep 17 00:00:00 2001 From: yoshnopa <40072150+yoshnopa@users.noreply.github.com> Date: Thu, 25 May 2023 03:42:02 +0200 Subject: [PATCH 58/81] Clip Preview Generation Fix (#3764) --- .../manager/task_generate_clip_preview.go | 8 +---- pkg/image/thumbnail.go | 34 +++++++------------ 2 files changed, 14 insertions(+), 28 deletions(-) diff --git a/internal/manager/task_generate_clip_preview.go b/internal/manager/task_generate_clip_preview.go index b43ca7514..c0ecfeedf 100644 --- a/internal/manager/task_generate_clip_preview.go +++ b/internal/manager/task_generate_clip_preview.go @@ -35,18 +35,12 @@ func (t *GenerateClipPreviewTask) Start(ctx context.Context) { } encoder := image.NewThumbnailEncoder(GetInstance().FFMPEG, GetInstance().FFProbe, clipPreviewOptions) - data, err := encoder.GetPreview(t.Image.Files.Primary(), models.DefaultGthumbWidth) + err := encoder.GetPreview(filePath, prevPath, models.DefaultGthumbWidth) if err != nil { logger.Errorf("getting preview for image %s: %w", filePath, err) return } - err = fsutil.WriteFile(prevPath, data) - if err != nil { - logger.Errorf("writing preview for image %s: %w", filePath, err) - return - } - } func (t *GenerateClipPreviewTask) required() bool { diff --git a/pkg/image/thumbnail.go b/pkg/image/thumbnail.go index ca6fd40b9..dc07b0f55 100644 --- a/pkg/image/thumbnail.go +++ b/pkg/image/thumbnail.go @@ -6,12 +6,14 @@ import ( "errors" "fmt" "os/exec" + "path/filepath" "runtime" "sync" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/ffmpeg/transcoder" "github.com/stashapp/stash/pkg/file" + "github.com/stashapp/stash/pkg/fsutil" ) const ffmpegImageQuality = 5 @@ -110,24 +112,11 @@ func (e *ThumbnailEncoder) GetThumbnail(f file.File, maxSize int) ([]byte, error // GetPreview returns the preview clip of the provided image clip resized to // the provided max size. It resizes based on the largest X/Y direction. -// It returns nil and an error if an error occurs reading, decoding or encoding -// the image, or if the image is not suitable for thumbnails. // It is hardcoded to 30 seconds maximum right now -func (e *ThumbnailEncoder) GetPreview(f file.File, maxSize int) ([]byte, error) { - reader, err := f.Open(&file.OsFS{}) +func (e *ThumbnailEncoder) GetPreview(inPath string, outPath string, maxSize int) error { + fileData, err := e.FFProbe.NewVideoFile(inPath) if err != nil { - return nil, err - } - defer reader.Close() - - buf := new(bytes.Buffer) - if _, err := buf.ReadFrom(reader); err != nil { - return nil, err - } - - fileData, err := e.FFProbe.NewVideoFile(f.Base().Path) - if err != nil { - return nil, err + return err } if fileData.Width <= maxSize { maxSize = fileData.Width @@ -136,7 +125,7 @@ func (e *ThumbnailEncoder) GetPreview(f file.File, maxSize int) ([]byte, error) if clipDuration > 30.0 { clipDuration = 30.0 } - return e.getClipPreview(buf, maxSize, clipDuration, fileData.FrameRate) + return e.getClipPreview(inPath, outPath, maxSize, clipDuration, fileData.FrameRate) } func (e *ThumbnailEncoder) ffmpegImageThumbnail(image *bytes.Buffer, maxSize int) ([]byte, error) { @@ -150,7 +139,7 @@ func (e *ThumbnailEncoder) ffmpegImageThumbnail(image *bytes.Buffer, maxSize int return e.FFMpeg.GenerateOutput(context.TODO(), args, image) } -func (e *ThumbnailEncoder) getClipPreview(image *bytes.Buffer, maxSize int, clipDuration float64, frameRate float64) ([]byte, error) { +func (e *ThumbnailEncoder) getClipPreview(inPath string, outPath string, maxSize int, clipDuration float64, frameRate float64) error { var thumbFilter ffmpeg.VideoFilter thumbFilter = thumbFilter.ScaleMaxSize(maxSize) @@ -173,7 +162,7 @@ func (e *ThumbnailEncoder) getClipPreview(image *bytes.Buffer, maxSize int, clip } thumbOptions := transcoder.TranscodeOptions{ - OutputPath: "-", + OutputPath: outPath, StartTime: 0, Duration: clipDuration, @@ -187,6 +176,9 @@ func (e *ThumbnailEncoder) getClipPreview(image *bytes.Buffer, maxSize int, clip ExtraOutputArgs: o.OutputArgs, } - args := transcoder.Transcode("-", thumbOptions) - return e.FFMpeg.GenerateOutput(context.TODO(), args, image) + if err := fsutil.EnsureDirAll(filepath.Dir(outPath)); err != nil { + return err + } + args := transcoder.Transcode(inPath, thumbOptions) + return e.FFMpeg.Generate(context.TODO(), args) } From 3eb805ca2df3b716ffc09f9be5c800c82c640f03 Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Thu, 25 May 2023 03:48:32 +0200 Subject: [PATCH 59/81] Fix performer image display (#3767) * Fix displayed performer image sticking after save * Reset URL before showing dialog in ImageInput --- .../components/Performers/PerformerDetails/Performer.tsx | 5 +++++ ui/v2.5/src/components/Shared/ImageInput.tsx | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index ddd74cff4..197556c8b 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -122,6 +122,11 @@ const PerformerPage: React.FC = ({ performer }) => { setRating ); + // reset image if performer changed + useEffect(() => { + setImage(undefined); + }, [performer]); + // set up hotkeys useEffect(() => { Mousetrap.bind("a", () => setActiveTabKey("details")); diff --git a/ui/v2.5/src/components/Shared/ImageInput.tsx b/ui/v2.5/src/components/Shared/ImageInput.tsx index cf25aa887..05b5eb264 100644 --- a/ui/v2.5/src/components/Shared/ImageInput.tsx +++ b/ui/v2.5/src/components/Shared/ImageInput.tsx @@ -53,6 +53,11 @@ export const ImageInput: React.FC = ({ ); } + function showDialog() { + setURL(""); + setIsShowDialog(true); + } + function onConfirmURL() { if (!onImageURL) { return; @@ -112,7 +117,7 @@ export const ImageInput: React.FC = ({
- From 45e61b922866f80822d52d92a07c60422343d32f Mon Sep 17 00:00:00 2001 From: yoshnopa <40072150+yoshnopa@users.noreply.github.com> Date: Thu, 25 May 2023 04:02:32 +0200 Subject: [PATCH 60/81] fix Clip Gif Support (#3765) --- ui/v2.5/src/components/Images/ImageDetails/Image.tsx | 4 ++-- ui/v2.5/src/hooks/Lightbox/Lightbox.tsx | 3 ++- ui/v2.5/src/hooks/Lightbox/types.ts | 1 + ui/v2.5/src/utils/visualFile.ts | 9 +++++++++ 4 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 ui/v2.5/src/utils/visualFile.ts diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index dda47e9d2..b9485767c 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -25,6 +25,7 @@ import { ImageDetailPanel } from "./ImageDetailPanel"; import { DeleteImagesDialog } from "../DeleteImagesDialog"; import { faEllipsisV } from "@fortawesome/free-solid-svg-icons"; import { objectPath, objectTitle } from "src/core/files"; +import { isVideo } from "src/utils/visualFile"; interface IImageParams { id?: string; @@ -260,8 +261,7 @@ export const Image: React.FC = () => { } const title = objectTitle(image); - const ImageView = - image.visual_files[0].__typename == "VideoFile" ? "video" : "img"; + const ImageView = isVideo(image.visual_files[0]) ? "video" : "img"; return (
diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index 8cadd2d54..cfe1d5db3 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -47,6 +47,7 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { useDebounce } from "../debounce"; +import { isVideo } from "src/utils/visualFile"; const CLASSNAME = "Lightbox"; const CLASSNAME_HEADER = `${CLASSNAME}-header`; @@ -850,7 +851,7 @@ export const LightboxComponent: React.FC = ({ scrollAttemptsBeforeChange={scrollAttemptsBeforeChange} setZoom={(v) => setZoom(v)} resetPosition={resetPosition} - isVideo={image.visual_files?.[0]?.__typename == "VideoFile"} + isVideo={isVideo(image.visual_files?.[0] ?? {})} /> ) : undefined}
diff --git a/ui/v2.5/src/hooks/Lightbox/types.ts b/ui/v2.5/src/hooks/Lightbox/types.ts index f955a060a..e98fb48f4 100644 --- a/ui/v2.5/src/hooks/Lightbox/types.ts +++ b/ui/v2.5/src/hooks/Lightbox/types.ts @@ -10,6 +10,7 @@ interface IFiles { __typename?: string; width: number; height: number; + video_codec?: GQL.Maybe; } export interface ILightboxImage { diff --git a/ui/v2.5/src/utils/visualFile.ts b/ui/v2.5/src/utils/visualFile.ts new file mode 100644 index 000000000..c88aa83ec --- /dev/null +++ b/ui/v2.5/src/utils/visualFile.ts @@ -0,0 +1,9 @@ +import { Maybe } from "src/core/generated-graphql"; + +// returns true if the file should be treated as a video in the UI +export function isVideo(o: { + __typename?: string; + video_codec?: Maybe; +}) { + return o.__typename == "VideoFile" && o.video_codec != "gif"; +} From 62b6457f4eb3b470ee376d679c67652a33232019 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 25 May 2023 12:03:49 +1000 Subject: [PATCH 61/81] Improve studio/tag/performer filtering (#3619) * Support excludes field * Refactor studio filter * Refactor tags filter * Support excludes in tags --------- Co-authored-by: Kermie --- graphql/schema/types/filters.graphql | 2 + pkg/models/filter.go | 2 + pkg/sqlite/filter.go | 201 +++++++--- pkg/sqlite/gallery.go | 2 +- pkg/sqlite/image.go | 2 +- pkg/sqlite/scene.go | 2 +- pkg/sqlite/scene_marker.go | 4 +- pkg/sqlite/tag.go | 4 +- .../src/components/List/CriterionEditor.tsx | 33 ++ .../List/Filters/PerformersFilter.tsx | 44 +++ .../List/Filters/SelectableFilter.tsx | 342 ++++++++++++++++++ .../components/List/Filters/StudiosFilter.tsx | 44 +++ .../components/List/Filters/TagsFilter.tsx | 41 +++ ui/v2.5/src/components/List/styles.scss | 101 ++++++ .../src/components/Shared/ClearableInput.tsx | 54 +++ ui/v2.5/src/components/Shared/styles.scss | 27 ++ .../StudioDetails/StudioPerformersPanel.tsx | 1 + .../Tags/TagDetails/TagMarkersPanel.tsx | 1 + ui/v2.5/src/core/performers.ts | 8 +- ui/v2.5/src/core/studios.ts | 1 + ui/v2.5/src/core/tags.ts | 1 + ui/v2.5/src/locales/en-GB.json | 1 + .../models/list-filter/criteria/criterion.ts | 80 +++- .../models/list-filter/criteria/performers.ts | 108 +++++- .../models/list-filter/criteria/studios.ts | 19 +- .../src/models/list-filter/criteria/tags.ts | 52 ++- ui/v2.5/src/models/list-filter/filter.ts | 2 +- ui/v2.5/src/models/list-filter/types.ts | 6 + ui/v2.5/src/utils/keyboard.ts | 9 + ui/v2.5/src/utils/navigation.ts | 28 +- 30 files changed, 1105 insertions(+), 117 deletions(-) create mode 100644 ui/v2.5/src/components/List/Filters/PerformersFilter.tsx create mode 100644 ui/v2.5/src/components/List/Filters/SelectableFilter.tsx create mode 100644 ui/v2.5/src/components/List/Filters/StudiosFilter.tsx create mode 100644 ui/v2.5/src/components/List/Filters/TagsFilter.tsx create mode 100644 ui/v2.5/src/components/Shared/ClearableInput.tsx create mode 100644 ui/v2.5/src/utils/keyboard.ts diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 0b18cbfee..55724cc42 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -518,6 +518,7 @@ input FloatCriterionInput { input MultiCriterionInput { value: [ID!] modifier: CriterionModifier! + excludes: [ID!] } input GenderCriterionInput { @@ -534,6 +535,7 @@ input HierarchicalMultiCriterionInput { value: [ID!] modifier: CriterionModifier! depth: Int + excludes: [ID!] } input DateCriterionInput { diff --git a/pkg/models/filter.go b/pkg/models/filter.go index 42cff1118..e0f9b7a54 100644 --- a/pkg/models/filter.go +++ b/pkg/models/filter.go @@ -132,11 +132,13 @@ type HierarchicalMultiCriterionInput struct { Value []string `json:"value"` Modifier CriterionModifier `json:"modifier"` Depth *int `json:"depth"` + Excludes []string `json:"excludes"` } type MultiCriterionInput struct { Value []string `json:"value"` Modifier CriterionModifier `json:"modifier"` + Excludes []string `json:"excludes"` } type DateCriterionInput struct { diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index d0c74772d..d670dc1a7 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -629,9 +629,12 @@ type joinedMultiCriterionHandlerBuilder struct { addJoinTable func(f *filterBuilder) } -func (m *joinedMultiCriterionHandlerBuilder) handler(criterion *models.MultiCriterionInput) criterionHandlerFunc { +func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { + if c != nil { + // make local copy so we can modify it + criterion := *c + joinAlias := m.joinAs if joinAlias == "" { joinAlias = m.joinTable @@ -653,37 +656,68 @@ func (m *joinedMultiCriterionHandlerBuilder) handler(criterion *models.MultiCrit return } - if len(criterion.Value) == 0 { + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { return } - var args []interface{} - for _, tagID := range criterion.Value { - args = append(args, tagID) + // combine excludes if excludes modifier is selected + if criterion.Modifier == models.CriterionModifierExcludes { + criterion.Modifier = models.CriterionModifierIncludesAll + criterion.Excludes = append(criterion.Excludes, criterion.Value...) + criterion.Value = nil } - whereClause := "" - havingClause := "" + if len(criterion.Value) > 0 { + whereClause := "" + havingClause := "" + + var args []interface{} + for _, tagID := range criterion.Value { + args = append(args, tagID) + } + + switch criterion.Modifier { + case models.CriterionModifierIncludes: + // includes any of the provided ids + m.addJoinTable(f) + whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) + case models.CriterionModifierEquals: + // includes only the provided ids + m.addJoinTable(f) + whereClause = utils.StrFormat("{joinAlias}.{foreignFK} IN {inBinding} AND (SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.id) = ?", utils.StrFormatMap{ + "joinAlias": joinAlias, + "foreignFK": m.foreignFK, + "inBinding": getInBinding(len(criterion.Value)), + "joinTable": m.joinTable, + "primaryFK": m.primaryFK, + "primaryTable": m.primaryTable, + }) + havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) + args = append(args, len(criterion.Value)) + case models.CriterionModifierIncludesAll: + // includes all of the provided ids + m.addJoinTable(f) + whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) + havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) + } + + f.addWhere(whereClause, args...) + f.addHaving(havingClause) + } + + if len(criterion.Excludes) > 0 { + var args []interface{} + for _, tagID := range criterion.Excludes { + args = append(args, tagID) + } - switch criterion.Modifier { - case models.CriterionModifierIncludes: - // includes any of the provided ids - m.addJoinTable(f) - whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) - case models.CriterionModifierIncludesAll: - // includes all of the provided ids - m.addJoinTable(f) - whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) - havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) - case models.CriterionModifierExcludes: // excludes all of the provided ids // need to use actual join table name for this // .id NOT IN (select . from where . in ) - whereClause = fmt.Sprintf("%[1]s.id NOT IN (SELECT %[3]s.%[2]s from %[3]s where %[3]s.%[4]s in %[5]s)", m.primaryTable, m.primaryFK, m.joinTable, m.foreignFK, getInBinding(len(criterion.Value))) - } + whereClause := fmt.Sprintf("%[1]s.id NOT IN (SELECT %[3]s.%[2]s from %[3]s where %[3]s.%[4]s in %[5]s)", m.primaryTable, m.primaryFK, m.joinTable, m.foreignFK, getInBinding(len(criterion.Excludes))) - f.addWhere(whereClause, args...) - f.addHaving(havingClause) + f.addWhere(whereClause, args...) + } } } } @@ -890,7 +924,7 @@ WHERE id in {inBinding} return valuesClause } -func addHierarchicalConditionClauses(f *filterBuilder, criterion *models.HierarchicalMultiCriterionInput, table, idColumn string) { +func addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { switch criterion.Modifier { case models.CriterionModifierIncludes: f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) @@ -902,9 +936,12 @@ func addHierarchicalConditionClauses(f *filterBuilder, criterion *models.Hierarc } } -func (m *hierarchicalMultiCriterionHandlerBuilder) handler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { +func (m *hierarchicalMultiCriterionHandlerBuilder) handler(c *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { + if c != nil { + // make a copy so we don't modify the original + criterion := *c + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { var notClause string if criterion.Modifier == models.CriterionModifierNotNull { @@ -919,19 +956,32 @@ func (m *hierarchicalMultiCriterionHandlerBuilder) handler(criterion *models.Hie return } - if len(criterion.Value) == 0 { + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { return } - valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + // combine excludes if excludes modifier is selected + if criterion.Modifier == models.CriterionModifierExcludes { + criterion.Modifier = models.CriterionModifierIncludesAll + criterion.Excludes = append(criterion.Excludes, criterion.Value...) + criterion.Value = nil + } + + if len(criterion.Value) > 0 { + valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + + switch criterion.Modifier { + case models.CriterionModifierIncludes: + f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) + case models.CriterionModifierIncludesAll: + f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) + f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", m.primaryTable, m.foreignFK, len(criterion.Value))) + } + } + + if len(criterion.Excludes) > 0 { + valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) - switch criterion.Modifier { - case models.CriterionModifierIncludes: - f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) - case models.CriterionModifierIncludesAll: - f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) - f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", m.primaryTable, m.foreignFK, len(criterion.Value))) - case models.CriterionModifierExcludes: f.addWhere(fmt.Sprintf("%s.%s NOT IN (SELECT column2 FROM (%s)) OR %[1]s.%[2]s IS NULL", m.primaryTable, m.foreignFK, valuesClause)) } } @@ -953,9 +1003,26 @@ type joinedHierarchicalMultiCriterionHandlerBuilder struct { primaryFK string } -func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { +func (m *joinedHierarchicalMultiCriterionHandlerBuilder) addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { + if criterion.Modifier == models.CriterionModifierEquals { + // includes only the provided ids + f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) + f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", table, idColumn, len(criterion.Value))) + f.addWhere(utils.StrFormat("(SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.id) = ?", utils.StrFormatMap{ + "joinTable": m.joinTable, + "primaryFK": m.primaryFK, + "primaryTable": m.primaryTable, + }), len(criterion.Value)) + } else { + addHierarchicalConditionClauses(f, criterion, table, idColumn) + } +} + +func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { + if c != nil { + // make a copy so we don't modify the original + criterion := *c joinAlias := m.joinAs if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { @@ -974,25 +1041,59 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(criterion *mode return } - if len(criterion.Value) == 0 { + // combine excludes if excludes modifier is selected + if criterion.Modifier == models.CriterionModifierExcludes { + criterion.Modifier = models.CriterionModifierIncludesAll + criterion.Excludes = append(criterion.Excludes, criterion.Value...) + criterion.Value = nil + } + + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { return } - valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + if len(criterion.Value) > 0 { + valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) - joinTable := utils.StrFormat(`( - SELECT j.*, d.column1 AS root_id, d.column2 AS item_id FROM {joinTable} AS j - INNER JOIN ({valuesClause}) AS d ON j.{foreignFK} = d.column2 -) -`, utils.StrFormatMap{ - "joinTable": m.joinTable, - "foreignFK": m.foreignFK, - "valuesClause": valuesClause, - }) + joinTable := utils.StrFormat(`( + SELECT j.*, d.column1 AS root_id, d.column2 AS item_id FROM {joinTable} AS j + INNER JOIN ({valuesClause}) AS d ON j.{foreignFK} = d.column2 + ) + `, utils.StrFormatMap{ + "joinTable": m.joinTable, + "foreignFK": m.foreignFK, + "valuesClause": valuesClause, + }) - f.addLeftJoin(joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.id", joinAlias, m.primaryFK, m.primaryTable)) + f.addLeftJoin(joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.id", joinAlias, m.primaryFK, m.primaryTable)) - addHierarchicalConditionClauses(f, criterion, joinAlias, "root_id") + m.addHierarchicalConditionClauses(f, criterion, joinAlias, "root_id") + } + + if len(criterion.Excludes) > 0 { + valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + + joinTable := utils.StrFormat(`( + SELECT j2.*, e.column1 AS root_id, e.column2 AS item_id FROM {joinTable} AS j2 + INNER JOIN ({valuesClause}) AS e ON j2.{foreignFK} = e.column2 + ) + `, utils.StrFormatMap{ + "joinTable": m.joinTable, + "foreignFK": m.foreignFK, + "valuesClause": valuesClause, + }) + + joinAlias2 := joinAlias + "2" + + f.addLeftJoin(joinTable, joinAlias2, fmt.Sprintf("%s.%s = %s.id", joinAlias2, m.primaryFK, m.primaryTable)) + + // modify for exclusion + criterionCopy := criterion + criterionCopy.Modifier = models.CriterionModifierExcludes + criterionCopy.Value = c.Excludes + + m.addHierarchicalConditionClauses(f, criterionCopy, joinAlias2, "root_id") + } } } } diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index de840b283..5f5291053 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -1011,7 +1011,7 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id f.addLeftJoin("performer_tags", "", "performer_tags.gallery_id = galleries.id") - addHierarchicalConditionClauses(f, tags, "performer_tags", "root_tag_id") + addHierarchicalConditionClauses(f, *tags, "performer_tags", "root_tag_id") } } } diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index f22cacf92..d42de9f85 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -989,7 +989,7 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id f.addLeftJoin("performer_tags", "", "performer_tags.image_id = images.id") - addHierarchicalConditionClauses(f, tags, "performer_tags", "root_tag_id") + addHierarchicalConditionClauses(f, *tags, "performer_tags", "root_tag_id") } } } diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 721a4d456..1a735bcd2 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -1404,7 +1404,7 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id f.addLeftJoin("performer_tags", "", "performer_tags.scene_id = scenes.id") - addHierarchicalConditionClauses(f, tags, "performer_tags", "root_tag_id") + addHierarchicalConditionClauses(f, *tags, "performer_tags", "root_tag_id") } } } diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index df3c73030..c4ae7dda7 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -221,7 +221,7 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = m.primary_tag_id f.addLeftJoin("marker_tags", "", "marker_tags.scene_marker_id = scene_markers.id") - addHierarchicalConditionClauses(f, tags, "marker_tags", "root_tag_id") + addHierarchicalConditionClauses(f, *tags, "marker_tags", "root_tag_id") } } } @@ -254,7 +254,7 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = st.tag_id f.addLeftJoin("scene_tags", "", "scene_tags.scene_id = scene_markers.scene_id") - addHierarchicalConditionClauses(f, tags, "scene_tags", "root_tag_id") + addHierarchicalConditionClauses(f, *tags, "scene_tags", "root_tag_id") } } } diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index c25f3b267..22f7bde1c 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -518,7 +518,7 @@ func tagParentsCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalMu f.addLeftJoin("parents", "", "parents.item_id = tags.id") - addHierarchicalConditionClauses(f, tags, "parents", "root_id") + addHierarchicalConditionClauses(f, *tags, "parents", "root_id") } } } @@ -567,7 +567,7 @@ func tagChildrenCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalM f.addLeftJoin("children", "", "children.item_id = tags.id") - addHierarchicalConditionClauses(f, tags, "children", "root_id") + addHierarchicalConditionClauses(f, *tags, "children", "root_id") } } } diff --git a/ui/v2.5/src/components/List/CriterionEditor.tsx b/ui/v2.5/src/components/List/CriterionEditor.tsx index dd099cacd..fdf5bcad7 100644 --- a/ui/v2.5/src/components/List/CriterionEditor.tsx +++ b/ui/v2.5/src/components/List/CriterionEditor.tsx @@ -38,6 +38,12 @@ import { RatingFilter } from "./Filters/RatingFilter"; import { BooleanFilter } from "./Filters/BooleanFilter"; import { OptionFilter, OptionListFilter } from "./Filters/OptionFilter"; import { PathFilter } from "./Filters/PathFilter"; +import { PerformersCriterion } from "src/models/list-filter/criteria/performers"; +import PerformersFilter from "./Filters/PerformersFilter"; +import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; +import StudiosFilter from "./Filters/StudiosFilter"; +import { TagsCriterion } from "src/models/list-filter/criteria/tags"; +import TagsFilter from "./Filters/TagsFilter"; import { PhashCriterion } from "src/models/list-filter/criteria/phash"; import { PhashFilter } from "./Filters/PhashFilter"; import cx from "classnames"; @@ -110,6 +116,33 @@ const GenericCriterionEditor: React.FC = ({ return; } + if (criterion instanceof PerformersCriterion) { + return ( + setCriterion(c)} + /> + ); + } + + if (criterion instanceof StudiosCriterion) { + return ( + setCriterion(c)} + /> + ); + } + + if (criterion instanceof TagsCriterion) { + return ( + setCriterion(c)} + /> + ); + } + if (criterion instanceof ILabeledIdCriterion) { return ( void; +} + +function usePerformerQuery(query: string) { + const results = useFindPerformersQuery({ + variables: { + filter: { + q: query, + per_page: 200, + }, + }, + }); + + return ( + results.data?.findPerformers.performers.map((p) => { + return { + id: p.id, + label: p.name, + }; + }) ?? [] + ); +} + +const PerformersFilter: React.FC = ({ + criterion, + setCriterion, +}) => { + return ( + + ); +}; + +export default PerformersFilter; diff --git a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx new file mode 100644 index 000000000..d14997ef6 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx @@ -0,0 +1,342 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { Button, Form } from "react-bootstrap"; +import { Icon } from "src/components/Shared/Icon"; +import { + faCheckCircle, + faMinus, + faPlus, + faTimesCircle, +} from "@fortawesome/free-solid-svg-icons"; +import { faTimesCircle as faTimesCircleRegular } from "@fortawesome/free-regular-svg-icons"; +import { ClearableInput } from "src/components/Shared/ClearableInput"; +import { + IHierarchicalLabelValue, + ILabeledId, + ILabeledValueListValue, +} from "src/models/list-filter/types"; +import { cloneDeep, debounce } from "lodash-es"; +import { + Criterion, + IHierarchicalLabeledIdCriterion, +} from "src/models/list-filter/criteria/criterion"; +import { defineMessages, MessageDescriptor, useIntl } from "react-intl"; +import { CriterionModifier } from "src/core/generated-graphql"; +import { keyboardClickHandler } from "src/utils/keyboard"; + +interface ISelectedItem { + item: ILabeledId; + excluded?: boolean; + onClick: () => void; +} + +const SelectedItem: React.FC = ({ + item, + excluded = false, + onClick, +}) => { + const iconClassName = excluded ? "exclude-icon" : "include-button"; + const spanClassName = excluded + ? "excluded-object-label" + : "selected-object-label"; + const [hovered, setHovered] = useState(false); + + const icon = useMemo(() => { + if (!hovered) { + return excluded ? faTimesCircle : faCheckCircle; + } + + return faTimesCircleRegular; + }, [hovered, excluded]); + + function onMouseOver() { + setHovered(true); + } + + function onMouseOut() { + setHovered(false); + } + + return ( + onClick()} + onKeyDown={keyboardClickHandler(onClick)} + onMouseEnter={() => onMouseOver()} + onMouseLeave={() => onMouseOut()} + onFocus={() => onMouseOver()} + onBlur={() => onMouseOut()} + tabIndex={0} + > +
+ + {item.label} +
+
+
+ ); +}; + +interface ISelectableFilter { + query: string; + setQuery: (query: string) => void; + single: boolean; + includeOnly: boolean; + queryResults: ILabeledId[]; + selected: ILabeledId[]; + excluded: ILabeledId[]; + onSelect: (value: ILabeledId, include: boolean) => void; + onUnselect: (value: ILabeledId) => void; +} + +const SelectableFilter: React.FC = ({ + query, + setQuery, + single, + queryResults, + selected, + excluded, + includeOnly, + onSelect, + onUnselect, +}) => { + const [internalQuery, setInternalQuery] = useState(query); + + const onInputChange = useMemo(() => { + return debounce((input: string) => { + setQuery(input); + }, 250); + }, [setQuery]); + + function onInternalInputChange(input: string) { + setInternalQuery(input); + onInputChange(input); + } + + const objects = useMemo(() => { + return queryResults.filter( + (p) => + selected.find((s) => s.id === p.id) === undefined && + excluded.find((s) => s.id === p.id) === undefined + ); + }, [queryResults, selected, excluded]); + + const includingOnly = includeOnly || (selected.length > 0 && single); + const excludingOnly = excluded.length > 0 && single; + + const includeIcon = ; + const excludeIcon = ; + + return ( + + ); +}; + +interface IObjectsFilter> { + criterion: T; + single?: boolean; + setCriterion: (criterion: T) => void; + queryHook: (query: string) => ILabeledId[]; +} + +export const ObjectsFilter = < + T extends Criterion +>( + props: IObjectsFilter +) => { + const { criterion, setCriterion, queryHook, single = false } = props; + + const [query, setQuery] = useState(""); + + const queryResults = queryHook(query); + + function onSelect(value: ILabeledId, newInclude: boolean) { + let newCriterion: T = cloneDeep(criterion); + + if (newInclude) { + newCriterion.value.items.push(value); + } else { + if (newCriterion.value.excluded) { + newCriterion.value.excluded.push(value); + } else { + newCriterion.value.excluded = [value]; + } + } + + setCriterion(newCriterion); + } + + const onUnselect = useCallback( + (value: ILabeledId) => { + if (!criterion) return; + + let newCriterion: T = cloneDeep(criterion); + + newCriterion.value.items = criterion.value.items.filter( + (v) => v.id !== value.id + ); + newCriterion.value.excluded = criterion.value.excluded.filter( + (v) => v.id !== value.id + ); + + setCriterion(newCriterion); + }, + [criterion, setCriterion] + ); + + const sortedSelected = useMemo(() => { + const ret = criterion.value.items.slice(); + ret.sort((a, b) => a.label.localeCompare(b.label)); + return ret; + }, [criterion]); + + const sortedExcluded = useMemo(() => { + if (!criterion.value.excluded) return []; + const ret = criterion.value.excluded.slice(); + ret.sort((a, b) => a.label.localeCompare(b.label)); + return ret; + }, [criterion]); + + return ( + + ); +}; + +interface IHierarchicalObjectsFilter + extends IObjectsFilter {} + +export const HierarchicalObjectsFilter = < + T extends IHierarchicalLabeledIdCriterion +>( + props: IHierarchicalObjectsFilter +) => { + const intl = useIntl(); + const { criterion, setCriterion } = props; + + const messages = defineMessages({ + studio_depth: { + id: "studio_depth", + defaultMessage: "Levels (empty for all)", + }, + }); + + function onDepthChanged(depth: number) { + let newCriterion: T = cloneDeep(criterion); + newCriterion.value.depth = depth; + setCriterion(newCriterion); + } + + function criterionOptionTypeToIncludeID(): string { + if (criterion.criterionOption.type === "studios") { + return "include-sub-studios"; + } + if (criterion.criterionOption.type === "childTags") { + return "include-parent-tags"; + } + return "include-sub-tags"; + } + + function criterionOptionTypeToIncludeUIString(): MessageDescriptor { + const optionType = + criterion.criterionOption.type === "studios" + ? "include_sub_studios" + : criterion.criterionOption.type === "childTags" + ? "include_parent_tags" + : "include_sub_tags"; + return { + id: optionType, + }; + } + + return ( +
+ + onDepthChanged(criterion.value.depth !== 0 ? 0 : -1)} + /> + + + {criterion.value.depth !== 0 && ( + + + onDepthChanged(e.target.value ? parseInt(e.target.value, 10) : -1) + } + defaultValue={ + criterion.value && criterion.value.depth !== -1 + ? criterion.value.depth + : "" + } + min="1" + /> + + )} + + + ); +}; diff --git a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx new file mode 100644 index 000000000..15d300372 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { useFindStudiosQuery } from "src/core/generated-graphql"; +import { HierarchicalObjectsFilter } from "./SelectableFilter"; +import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; + +interface IStudiosFilter { + criterion: StudiosCriterion; + setCriterion: (c: StudiosCriterion) => void; +} + +function useStudioQuery(query: string) { + const results = useFindStudiosQuery({ + variables: { + filter: { + q: query, + per_page: 200, + }, + }, + }); + + return ( + results.data?.findStudios.studios.map((p) => { + return { + id: p.id, + label: p.name, + }; + }) ?? [] + ); +} + +const StudiosFilter: React.FC = ({ + criterion, + setCriterion, +}) => { + return ( + + ); +}; + +export default StudiosFilter; diff --git a/ui/v2.5/src/components/List/Filters/TagsFilter.tsx b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx new file mode 100644 index 000000000..719bada38 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { useFindTagsQuery } from "src/core/generated-graphql"; +import { HierarchicalObjectsFilter } from "./SelectableFilter"; +import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; + +interface ITagsFilter { + criterion: StudiosCriterion; + setCriterion: (c: StudiosCriterion) => void; +} + +function useStudioQuery(query: string) { + const results = useFindTagsQuery({ + variables: { + filter: { + q: query, + per_page: 200, + }, + }, + }); + + return ( + results.data?.findTags.tags.map((p) => { + return { + id: p.id, + label: p.name, + }; + }) ?? [] + ); +} + +const TagsFilter: React.FC = ({ criterion, setCriterion }) => { + return ( + + ); +}; + +export default TagsFilter; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 8b4c67827..1c6a390f4 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -255,6 +255,107 @@ input[type="range"].zoom-slider { } } +.filter-visible-button { + padding-left: 0.3rem; + padding-right: 0.3rem; + + &:focus:not(.active):not(:hover) { + background: none; + } + + &:focus, + &.active:focus { + box-shadow: none; + } +} + +.selectable-filter ul { + list-style-type: none; + margin-top: 0.5rem; + max-height: 300px; + overflow-y: auto; + // to prevent unnecessary vertical scrollbar + padding-bottom: 0.15rem; + padding-inline-start: 0; + + .unselected-object { + opacity: 0.8; + } + + .selected-object, + .excluded-object, + .unselected-object { + cursor: pointer; + height: 2em; + margin-bottom: 0.25rem; + + a { + align-items: center; + display: flex; + height: 2em; + justify-content: space-between; + outline: none; + + &:hover, + &:focus-visible { + background-color: rgba(138, 155, 168, 0.15); + } + + .selected-object-label, + .excluded-object-label { + font-size: 16px; + } + } + + .include-button { + color: $success; + } + + .exclude-icon { + color: $danger; + } + + .exclude-button { + align-items: center; + display: flex; + margin-left: 0.25rem; + padding-left: 0.25rem; + padding-right: 0.25rem; + + .exclude-button-text { + color: $danger; + display: none; + font-size: 12px; + font-weight: 600; + } + + &:hover { + background-color: inherit; + } + + &:hover .exclude-button-text, + &:focus .exclude-button-text { + display: inline; + } + } + + .object-count { + color: $text-muted; + font-size: 12px; + } + } + + .selected-object:hover, + .selected-object a:focus-visible, + .excluded-object:hover, + .excluded-object a:focus-visible { + .include-button, + .exclude-icon { + color: #fff; + } + } +} + .tilted { transform: rotate(45deg); } diff --git a/ui/v2.5/src/components/Shared/ClearableInput.tsx b/ui/v2.5/src/components/Shared/ClearableInput.tsx new file mode 100644 index 000000000..4275b8ee8 --- /dev/null +++ b/ui/v2.5/src/components/Shared/ClearableInput.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { Button, FormControl } from "react-bootstrap"; +import { faTimes } from "@fortawesome/free-solid-svg-icons"; +import { useIntl } from "react-intl"; +import { Icon } from "./Icon"; +import useFocus from "src/utils/focus"; + +interface IClearableInput { + value: string; + setValue: (value: string) => void; +} + +export const ClearableInput: React.FC = ({ + value, + setValue, +}) => { + const intl = useIntl(); + + const [queryRef, setQueryFocus] = useFocus(); + const queryClearShowing = !!value; + + function onChangeQuery(event: React.FormEvent) { + setValue(event.currentTarget.value); + } + + function onClearQuery() { + setValue(""); + setQueryFocus(); + } + + return ( +
+ + {queryClearShowing && ( + + )} +
+ ); +}; + +export default ClearableInput; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 067f8cf4b..4d166878f 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -414,3 +414,30 @@ div.react-datepicker { #date-picker-portal .react-datepicker-popper { z-index: 1600; } + +.clearable-input-group { + align-items: stretch; + display: flex; + flex-wrap: wrap; + position: relative; +} + +.clearable-text-field, +.clearable-text-field:active, +.clearable-text-field:focus { + background-color: #394b59; + border: 0; + border-color: #394b59; + color: #fff; +} + +.clearable-text-field-clear { + background-color: #394b59; + color: #bfccd6; + font-size: 0.875rem; + margin: 0.375rem 0.75rem; + padding: 0; + position: absolute; + right: 0; + z-index: 4; +} diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx index 396b3e790..e13c8b2ec 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx @@ -16,6 +16,7 @@ export const StudioPerformersPanel: React.FC = ({ const studioCriterion = new StudiosCriterion(); studioCriterion.value = { items: [{ id: studio.id!, label: studio.name || `Studio ${studio.id}` }], + excluded: [], depth: 0, }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx index 37d33ea2c..0713f13d5 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx @@ -43,6 +43,7 @@ export const TagMarkersPanel: React.FC = ({ tagCriterion = new TagsCriterion(TagsCriterionOption); tagCriterion.value = { items: [tagValue], + excluded: [], depth: 0, }; filter.criteria.push(tagCriterion); diff --git a/ui/v2.5/src/core/performers.ts b/ui/v2.5/src/core/performers.ts index e13ac8885..597a0be54 100644 --- a/ui/v2.5/src/core/performers.ts +++ b/ui/v2.5/src/core/performers.ts @@ -22,21 +22,21 @@ export const usePerformerFilterHook = ( ) { // add the performer if not present if ( - !performerCriterion.value.find((p) => { + !performerCriterion.value.items.find((p) => { return p.id === performer.id; }) ) { - performerCriterion.value.push(performerValue); + performerCriterion.value.items.push(performerValue); } } else { // overwrite - performerCriterion.value = [performerValue]; + performerCriterion.value.items = [performerValue]; } performerCriterion.modifier = GQL.CriterionModifier.IncludesAll; } else { performerCriterion = new PerformersCriterion(); - performerCriterion.value = [performerValue]; + performerCriterion.value.items = [performerValue]; performerCriterion.modifier = GQL.CriterionModifier.IncludesAll; filter.criteria.push(performerCriterion); } diff --git a/ui/v2.5/src/core/studios.ts b/ui/v2.5/src/core/studios.ts index ef93f191c..95649c199 100644 --- a/ui/v2.5/src/core/studios.ts +++ b/ui/v2.5/src/core/studios.ts @@ -22,6 +22,7 @@ export const useStudioFilterHook = (studio: GQL.StudioDataFragment) => { studioCriterion = new StudiosCriterion(); studioCriterion.value = { items: [studioValue], + excluded: [], depth: (config?.configuration?.ui as IUIConfig)?.showChildStudioContent ? -1 : 0, diff --git a/ui/v2.5/src/core/tags.ts b/ui/v2.5/src/core/tags.ts index 3ec042c84..d4f6fc1bf 100644 --- a/ui/v2.5/src/core/tags.ts +++ b/ui/v2.5/src/core/tags.ts @@ -42,6 +42,7 @@ export const useTagFilterHook = (tag: GQL.TagDataFragment) => { tagCriterion = new TagsCriterion(TagsCriterionOption); tagCriterion.value = { items: [tagValue], + excluded: [], depth: (config?.configuration?.ui as IUIConfig)?.showChildTagContent ? -1 : 0, diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index d314780e5..d07278676 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -726,6 +726,7 @@ "equals": "is", "excludes": "excludes", "format_string": "{criterion} {modifierString} {valueString}", + "format_string_excludes": "{criterion} {modifierString} {valueString} (excludes {excludedString})", "greater_than": "is greater than", "includes": "includes", "includes_all": "includes all", diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index 642fe7336..fdf12995b 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -22,6 +22,7 @@ import { IStashIDValue, IDateValue, ITimestampValue, + ILabeledValueListValue, IPhashDistanceValue, } from "../types"; @@ -31,6 +32,7 @@ export type CriterionValue = | string[] | ILabeledId[] | IHierarchicalLabelValue + | ILabeledValueListValue | INumberValue | IStashIDValue | IDateValue @@ -138,6 +140,10 @@ export abstract class Criterion { return JSON.stringify(encodedCriterion); } + public setValueFromQueryString(v: V) { + this.value = v; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any public apply(outputFilter: Record) { // eslint-disable-next-line no-param-reassign @@ -531,11 +537,21 @@ export class ILabeledIdCriterion extends Criterion { } export class IHierarchicalLabeledIdCriterion extends Criterion { - protected toCriterionInput(): HierarchicalMultiCriterionInput { - return { - value: (this.value.items ?? []).map((v) => v.id), - modifier: this.modifier, - depth: this.value.depth, + constructor(type: CriterionOption) { + const value: IHierarchicalLabelValue = { + items: [], + excluded: [], + depth: 0, + }; + + super(type, value); + } + + public setValueFromQueryString(v: IHierarchicalLabelValue) { + this.value = { + items: v.items || [], + excluded: v.excluded || [], + depth: v.depth || 0, }; } @@ -549,24 +565,62 @@ export class IHierarchicalLabeledIdCriterion extends Criterion 0 ? this.value.depth : "all"})`; } + protected toCriterionInput(): HierarchicalMultiCriterionInput { + let excludes: string[] = []; + if (this.value.excluded) { + excludes = this.value.excluded.map((v) => v.id); + } + return { + value: this.value.items.map((v) => v.id), + excludes: excludes, + modifier: this.modifier, + depth: this.value.depth, + }; + } + public isValid(): boolean { if ( this.modifier === CriterionModifier.IsNull || - this.modifier === CriterionModifier.NotNull + this.modifier === CriterionModifier.NotNull || + this.modifier === CriterionModifier.Equals ) { return true; } - return this.value.items.length > 0; + return ( + this.value.items.length > 0 || + (this.value.excluded && this.value.excluded.length > 0) + ); } - constructor(type: CriterionOption) { - const value: IHierarchicalLabelValue = { - items: [], - depth: 0, - }; + public getLabel(intl: IntlShape): string { + const modifierString = Criterion.getModifierLabel(intl, this.modifier); + let valueString = ""; - super(type, value); + if ( + this.modifier !== CriterionModifier.IsNull && + this.modifier !== CriterionModifier.NotNull + ) { + valueString = this.value.items.map((v) => v.label).join(", "); + } + + let id = "criterion_modifier.format_string"; + let excludedString = ""; + + if (this.value.excluded && this.value.excluded.length > 0) { + id = "criterion_modifier.format_string_excludes"; + excludedString = this.value.excluded.map((v) => v.label).join(", "); + } + + return intl.formatMessage( + { id }, + { + criterion: intl.formatMessage({ id: this.criterionOption.messageID }), + modifierString, + valueString, + excludedString, + } + ); } } diff --git a/ui/v2.5/src/models/list-filter/criteria/performers.ts b/ui/v2.5/src/models/list-filter/criteria/performers.ts index 7b177d939..ef7fba0cb 100644 --- a/ui/v2.5/src/models/list-filter/criteria/performers.ts +++ b/ui/v2.5/src/models/list-filter/criteria/performers.ts @@ -1,14 +1,104 @@ -import { ILabeledIdCriterion, ILabeledIdCriterionOption } from "./criterion"; +/* eslint @typescript-eslint/no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ +import { IntlShape } from "react-intl"; +import { + CriterionModifier, + MultiCriterionInput, +} from "src/core/generated-graphql"; +import { ILabeledId, ILabeledValueListValue } from "../types"; +import { Criterion, CriterionOption } from "./criterion"; -export const PerformersCriterionOption = new ILabeledIdCriterionOption( - "performers", - "performers", - "performers", - true -); +const modifierOptions = [ + CriterionModifier.IncludesAll, + CriterionModifier.Includes, + CriterionModifier.Equals, +]; -export class PerformersCriterion extends ILabeledIdCriterion { +const defaultModifier = CriterionModifier.IncludesAll; + +export const PerformersCriterionOption = new CriterionOption({ + messageID: "performers", + type: "performers", + parameterName: "performers", + modifierOptions, + defaultModifier, +}); + +export class PerformersCriterion extends Criterion { constructor() { - super(PerformersCriterionOption); + super(PerformersCriterionOption, { items: [], excluded: [] }); + } + + public setValueFromQueryString(v: ILabeledId[] | ILabeledValueListValue) { + // #3619 - the format of performer value was changed from an array + // to an object. Check for both formats. + if (Array.isArray(v)) { + this.value = { items: v, excluded: [] }; + } else { + this.value = { + items: v.items || [], + excluded: v.excluded || [], + }; + } + } + + public getLabelValue(_intl: IntlShape): string { + return this.value.items.map((v) => v.label).join(", "); + } + + protected toCriterionInput(): MultiCriterionInput { + let excludes: string[] = []; + if (this.value.excluded) { + excludes = this.value.excluded.map((v) => v.id); + } + return { + value: this.value.items.map((v) => v.id), + excludes: excludes, + modifier: this.modifier, + }; + } + + public isValid(): boolean { + if ( + this.modifier === CriterionModifier.IsNull || + this.modifier === CriterionModifier.NotNull || + this.modifier === CriterionModifier.Equals + ) { + return true; + } + + return ( + this.value.items.length > 0 || + (this.value.excluded && this.value.excluded.length > 0) + ); + } + + public getLabel(intl: IntlShape): string { + const modifierString = Criterion.getModifierLabel(intl, this.modifier); + let valueString = ""; + + if ( + this.modifier !== CriterionModifier.IsNull && + this.modifier !== CriterionModifier.NotNull + ) { + valueString = this.value.items.map((v) => v.label).join(", "); + } + + let id = "criterion_modifier.format_string"; + let excludedString = ""; + + if (this.value.excluded && this.value.excluded.length > 0) { + id = "criterion_modifier.format_string_excludes"; + excludedString = this.value.excluded.map((v) => v.label).join(", "); + } + + return intl.formatMessage( + { id }, + { + criterion: intl.formatMessage({ id: this.criterionOption.messageID }), + modifierString, + valueString, + excludedString, + } + ); } } diff --git a/ui/v2.5/src/models/list-filter/criteria/studios.ts b/ui/v2.5/src/models/list-filter/criteria/studios.ts index 455921543..a78e96200 100644 --- a/ui/v2.5/src/models/list-filter/criteria/studios.ts +++ b/ui/v2.5/src/models/list-filter/criteria/studios.ts @@ -1,15 +1,22 @@ +import { CriterionModifier } from "src/core/generated-graphql"; import { + CriterionOption, IHierarchicalLabeledIdCriterion, ILabeledIdCriterion, ILabeledIdCriterionOption, } from "./criterion"; -export const StudiosCriterionOption = new ILabeledIdCriterionOption( - "studios", - "studios", - "studios", - false -); +const modifierOptions = [CriterionModifier.Includes]; + +const defaultModifier = CriterionModifier.Includes; + +export const StudiosCriterionOption = new CriterionOption({ + messageID: "studios", + type: "studios", + parameterName: "studios", + modifierOptions, + defaultModifier, +}); export class StudiosCriterion extends IHierarchicalLabeledIdCriterion { constructor() { diff --git a/ui/v2.5/src/models/list-filter/criteria/tags.ts b/ui/v2.5/src/models/list-filter/criteria/tags.ts index f3470beee..7266fcf3d 100644 --- a/ui/v2.5/src/models/list-filter/criteria/tags.ts +++ b/ui/v2.5/src/models/list-filter/criteria/tags.ts @@ -1,37 +1,51 @@ -import { - IHierarchicalLabeledIdCriterion, - ILabeledIdCriterionOption, -} from "./criterion"; +import { CriterionModifier } from "src/core/generated-graphql"; +import { CriterionType } from "../types"; +import { CriterionOption, IHierarchicalLabeledIdCriterion } from "./criterion"; export class TagsCriterion extends IHierarchicalLabeledIdCriterion {} -export const TagsCriterionOption = new ILabeledIdCriterionOption( +class tagsCriterionOption extends CriterionOption { + constructor(messageID: string, value: CriterionType, parameterName: string) { + const modifierOptions = [ + CriterionModifier.Includes, + CriterionModifier.IncludesAll, + CriterionModifier.Equals, + ]; + + let defaultModifier = CriterionModifier.IncludesAll; + + super({ + messageID, + type: value, + parameterName, + modifierOptions, + defaultModifier, + }); + } +} + +export const TagsCriterionOption = new tagsCriterionOption( "tags", "tags", - "tags", - true + "tags" ); -export const SceneTagsCriterionOption = new ILabeledIdCriterionOption( +export const SceneTagsCriterionOption = new tagsCriterionOption( "sceneTags", "sceneTags", - "scene_tags", - true + "scene_tags" ); -export const PerformerTagsCriterionOption = new ILabeledIdCriterionOption( +export const PerformerTagsCriterionOption = new tagsCriterionOption( "performerTags", "performerTags", - "performer_tags", - true + "performer_tags" ); -export const ParentTagsCriterionOption = new ILabeledIdCriterionOption( +export const ParentTagsCriterionOption = new tagsCriterionOption( "parent_tags", "parentTags", - "parents", - true + "parents" ); -export const ChildTagsCriterionOption = new ILabeledIdCriterionOption( +export const ChildTagsCriterionOption = new tagsCriterionOption( "sub_tags", "childTags", - "children", - true + "children" ); diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 726c83b6f..def9ac669 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -131,7 +131,7 @@ export class ListFilterModel { // it's possible that we have unsupported criteria. Just skip if so. if (criterion) { if (encodedCriterion.value !== undefined) { - criterion.value = encodedCriterion.value; + criterion.setValueFromQueryString(encodedCriterion.value); } criterion.modifier = encodedCriterion.modifier; this.criteria.push(criterion); diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 548adc59f..79731eb3b 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -18,8 +18,14 @@ export interface ILabeledValue { value: string; } +export interface ILabeledValueListValue { + items: ILabeledId[]; + excluded: ILabeledId[]; +} + export interface IHierarchicalLabelValue { items: ILabeledId[]; + excluded: ILabeledId[]; depth: number; } diff --git a/ui/v2.5/src/utils/keyboard.ts b/ui/v2.5/src/utils/keyboard.ts new file mode 100644 index 000000000..1f02c55bc --- /dev/null +++ b/ui/v2.5/src/utils/keyboard.ts @@ -0,0 +1,9 @@ +export function keyboardClickHandler(onClick: () => void) { + function onKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" || e.key === " ") { + onClick(); + } + } + + return onKeyDown; +} diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index a1ba4cf33..7693ddb69 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -38,12 +38,12 @@ const makePerformerScenesUrl = ( if (!performer.id) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); const criterion = new PerformersCriterion(); - criterion.value = [ + criterion.value.items = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; if (extraPerformer) { - criterion.value.push(extraPerformer); + criterion.value.items.push(extraPerformer); } filter.criteria.push(criterion); @@ -59,12 +59,12 @@ const makePerformerImagesUrl = ( if (!performer.id) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Images, undefined); const criterion = new PerformersCriterion(); - criterion.value = [ + criterion.value.items = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; if (extraPerformer) { - criterion.value.push(extraPerformer); + criterion.value.items.push(extraPerformer); } filter.criteria.push(criterion); @@ -80,12 +80,12 @@ const makePerformerGalleriesUrl = ( if (!performer.id) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Galleries, undefined); const criterion = new PerformersCriterion(); - criterion.value = [ + criterion.value.items = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; if (extraPerformer) { - criterion.value.push(extraPerformer); + criterion.value.items.push(extraPerformer); } filter.criteria.push(criterion); @@ -101,12 +101,12 @@ const makePerformerMoviesUrl = ( if (!performer.id) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Movies, undefined); const criterion = new PerformersCriterion(); - criterion.value = [ + criterion.value.items = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; if (extraPerformer) { - criterion.value.push(extraPerformer); + criterion.value.items.push(extraPerformer); } filter.criteria.push(criterion); @@ -131,6 +131,7 @@ const makeStudioScenesUrl = (studio: Partial) => { const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -143,6 +144,7 @@ const makeStudioImagesUrl = (studio: Partial) => { const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -155,6 +157,7 @@ const makeStudioGalleriesUrl = (studio: Partial) => { const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -167,6 +170,7 @@ const makeStudioMoviesUrl = (studio: Partial) => { const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -179,6 +183,7 @@ const makeStudioPerformersUrl = (studio: Partial) => { const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -218,6 +223,7 @@ const makeParentTagsUrl = (tag: Partial) => { label: tag.name || `Tag ${tag.id}`, }, ], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -235,6 +241,7 @@ const makeChildTagsUrl = (tag: Partial) => { label: tag.name || `Tag ${tag.id}`, }, ], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -247,6 +254,7 @@ const makeTagScenesUrl = (tag: Partial) => { const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -259,6 +267,7 @@ const makeTagPerformersUrl = (tag: Partial) => { const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -271,6 +280,7 @@ const makeTagSceneMarkersUrl = (tag: Partial) => { const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -283,6 +293,7 @@ const makeTagGalleriesUrl = (tag: Partial) => { const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); @@ -295,6 +306,7 @@ const makeTagImagesUrl = (tag: Partial) => { const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], + excluded: [], depth: 0, }; filter.criteria.push(criterion); From cc9ded05a3a241692c3ac0877f6c1b3a3ad49bb3 Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Fri, 26 May 2023 01:49:00 +0200 Subject: [PATCH 62/81] Error logging improvements (#3768) * Improve auto-tag error messages * Ignore another context canceled error * Ignore more graphql context canceled errors --- internal/api/error.go | 38 +++--- internal/manager/task_autotag.go | 199 +++++++++++++++++------------- pkg/scraper/stashbox/stash_box.go | 2 +- 3 files changed, 136 insertions(+), 103 deletions(-) diff --git a/internal/api/error.go b/internal/api/error.go index 208b2521c..5b30a8c12 100644 --- a/internal/api/error.go +++ b/internal/api/error.go @@ -11,27 +11,29 @@ import ( ) func gqlErrorHandler(ctx context.Context, e error) *gqlerror.Error { - // log all errors - for now just log the error message - // we can potentially add more context later - fc := graphql.GetFieldContext(ctx) - if fc != nil && !errors.Is(e, context.Canceled) { - logger.Errorf("%s: %v", fc.Path(), e) + if !errors.Is(ctx.Err(), context.Canceled) { + // log all errors - for now just log the error message + // we can potentially add more context later + fc := graphql.GetFieldContext(ctx) + if fc != nil { + logger.Errorf("%s: %v", fc.Path(), e) - // log the args in debug level - logger.DebugFunc(func() (string, []interface{}) { - var args interface{} - args = fc.Args + // log the args in debug level + logger.DebugFunc(func() (string, []interface{}) { + var args interface{} + args = fc.Args - s, _ := json.Marshal(args) - if len(s) > 0 { - args = string(s) - } + s, _ := json.Marshal(args) + if len(s) > 0 { + args = string(s) + } - return "%s: %v", []interface{}{ - fc.Path(), - args, - } - }) + return "%s: %v", []interface{}{ + fc.Path(), + args, + } + }) + } } // we may also want to transform the error message for the response diff --git a/internal/manager/task_autotag.go b/internal/manager/task_autotag.go index 0dfe59dd3..273e65f28 100644 --- a/internal/manager/task_autotag.go +++ b/internal/manager/task_autotag.go @@ -37,7 +37,7 @@ func (j *autoTagJob) Execute(ctx context.Context, progress *job.Progress) { j.autoTagSpecific(ctx, progress) } - logger.Infof("Finished autotag after %s", time.Since(begin).String()) + logger.Infof("Finished auto-tag after %s", time.Since(begin).String()) } func (j *autoTagJob) isFileBasedAutoTag(input AutoTagMetadataInput) bool { @@ -84,32 +84,34 @@ func (j *autoTagJob) autoTagSpecific(ctx context.Context, progress *job.Progress if performerCount == 1 && performerIds[0] == wildcard { performerCount, err = performerQuery.Count(ctx) if err != nil { - return fmt.Errorf("error getting performer count: %v", err) + return fmt.Errorf("getting performer count: %v", err) } } if studioCount == 1 && studioIds[0] == wildcard { studioCount, err = studioQuery.Count(ctx) if err != nil { - return fmt.Errorf("error getting studio count: %v", err) + return fmt.Errorf("getting studio count: %v", err) } } if tagCount == 1 && tagIds[0] == wildcard { tagCount, err = tagQuery.Count(ctx) if err != nil { - return fmt.Errorf("error getting tag count: %v", err) + return fmt.Errorf("getting tag count: %v", err) } } return nil }); err != nil { - logger.Error(err.Error()) + if !job.IsCancelled(ctx) { + logger.Errorf("auto-tag error: %v", err) + } return } total := performerCount + studioCount + tagCount progress.SetTotal(total) - logger.Infof("Starting autotag of %d performers, %d studios, %d tags", performerCount, studioCount, tagCount) + logger.Infof("Starting auto-tag of %d performers, %d studios, %d tags", performerCount, studioCount, tagCount) j.autoTagPerformers(ctx, progress, input.Paths, performerIds) j.autoTagStudios(ctx, progress, input.Paths, studioIds) @@ -142,7 +144,7 @@ func (j *autoTagJob) autoTagPerformers(ctx context.Context, progress *job.Progre PerPage: &perPage, }) if err != nil { - return fmt.Errorf("error querying performers: %w", err) + return fmt.Errorf("querying performers: %w", err) } } else { performerIdInt, err := strconv.Atoi(performerId) @@ -167,11 +169,10 @@ func (j *autoTagJob) autoTagPerformers(ctx context.Context, progress *job.Progre for _, performer := range performers { if job.IsCancelled(ctx) { - logger.Info("Stopping due to user request") return nil } - if err := func() error { + err := func() error { r := j.txnManager if err := tagger.PerformerScenes(ctx, performer, paths, r.Scene); err != nil { return fmt.Errorf("processing scenes: %w", err) @@ -184,8 +185,14 @@ func (j *autoTagJob) autoTagPerformers(ctx context.Context, progress *job.Progre } return nil - }(); err != nil { - return fmt.Errorf("error auto-tagging performer '%s': %s", performer.Name, err.Error()) + }() + + if job.IsCancelled(ctx) { + return nil + } + + if err != nil { + return fmt.Errorf("tagging performer '%s': %s", performer.Name, err.Error()) } progress.Increment() @@ -193,8 +200,12 @@ func (j *autoTagJob) autoTagPerformers(ctx context.Context, progress *job.Progre return nil }); err != nil { - logger.Error(err.Error()) - continue + logger.Errorf("auto-tag error: %v", err) + } + + if job.IsCancelled(ctx) { + logger.Info("Stopping performer auto-tag due to user request") + return } } } @@ -225,17 +236,17 @@ func (j *autoTagJob) autoTagStudios(ctx context.Context, progress *job.Progress, PerPage: &perPage, }) if err != nil { - return fmt.Errorf("error querying studios: %v", err) + return fmt.Errorf("querying studios: %v", err) } } else { studioIdInt, err := strconv.Atoi(studioId) if err != nil { - return fmt.Errorf("error parsing studio id %s: %s", studioId, err.Error()) + return fmt.Errorf("parsing studio id %s: %s", studioId, err.Error()) } studio, err := studioQuery.Find(ctx, studioIdInt) if err != nil { - return fmt.Errorf("error finding studio id %s: %s", studioId, err.Error()) + return fmt.Errorf("finding studio id %s: %s", studioId, err.Error()) } if studio == nil { @@ -247,11 +258,10 @@ func (j *autoTagJob) autoTagStudios(ctx context.Context, progress *job.Progress, for _, studio := range studios { if job.IsCancelled(ctx) { - logger.Info("Stopping due to user request") return nil } - if err := func() error { + err := func() error { aliases, err := r.Studio.GetAliases(ctx, studio.ID) if err != nil { return fmt.Errorf("getting studio aliases: %w", err) @@ -268,8 +278,14 @@ func (j *autoTagJob) autoTagStudios(ctx context.Context, progress *job.Progress, } return nil - }(); err != nil { - return fmt.Errorf("error auto-tagging studio '%s': %s", studio.Name.String, err.Error()) + }() + + if job.IsCancelled(ctx) { + return nil + } + + if err != nil { + return fmt.Errorf("tagging studio '%s': %s", studio.Name.String, err.Error()) } progress.Increment() @@ -277,8 +293,12 @@ func (j *autoTagJob) autoTagStudios(ctx context.Context, progress *job.Progress, return nil }); err != nil { - logger.Error(err.Error()) - continue + logger.Errorf("auto-tag error: %v", err) + } + + if job.IsCancelled(ctx) { + logger.Info("Stopping studio auto-tag due to user request") + return } } } @@ -308,28 +328,27 @@ func (j *autoTagJob) autoTagTags(ctx context.Context, progress *job.Progress, pa PerPage: &perPage, }) if err != nil { - return fmt.Errorf("error querying tags: %v", err) + return fmt.Errorf("querying tags: %v", err) } } else { tagIdInt, err := strconv.Atoi(tagId) if err != nil { - return fmt.Errorf("error parsing tag id %s: %s", tagId, err.Error()) + return fmt.Errorf("parsing tag id %s: %s", tagId, err.Error()) } tag, err := tagQuery.Find(ctx, tagIdInt) if err != nil { - return fmt.Errorf("error finding tag id %s: %s", tagId, err.Error()) + return fmt.Errorf("finding tag id %s: %s", tagId, err.Error()) } tags = append(tags, tag) } for _, tag := range tags { if job.IsCancelled(ctx) { - logger.Info("Stopping due to user request") return nil } - if err := func() error { + err := func() error { aliases, err := r.Tag.GetAliases(ctx, tag.ID) if err != nil { return fmt.Errorf("getting tag aliases: %w", err) @@ -346,8 +365,14 @@ func (j *autoTagJob) autoTagTags(ctx context.Context, progress *job.Progress, pa } return nil - }(); err != nil { - return fmt.Errorf("error auto-tagging tag '%s': %s", tag.Name, err.Error()) + }() + + if job.IsCancelled(ctx) { + return nil + } + + if err != nil { + return fmt.Errorf("tagging tag '%s': %s", tag.Name, err.Error()) } progress.Increment() @@ -355,8 +380,12 @@ func (j *autoTagJob) autoTagTags(ctx context.Context, progress *job.Progress, pa return nil }); err != nil { - logger.Error(err.Error()) - continue + logger.Errorf("auto-tag error: %v", err) + } + + if job.IsCancelled(ctx) { + logger.Info("Stopping tag auto-tag due to user request") + return } } } @@ -488,11 +517,13 @@ func (t *autoTagFilesTask) getCount(ctx context.Context, r Repository) (int, err return sceneCount + imageCount + galleryCount, nil } -func (t *autoTagFilesTask) processScenes(ctx context.Context, r Repository) error { +func (t *autoTagFilesTask) processScenes(ctx context.Context, r Repository) { if job.IsCancelled(ctx) { - return nil + return } + logger.Info("Auto-tagging scenes...") + batchSize := 1000 findFilter := models.BatchFindFilter(batchSize) @@ -506,12 +537,16 @@ func (t *autoTagFilesTask) processScenes(ctx context.Context, r Repository) erro scenes, err = scene.Query(ctx, r.Scene, sceneFilter, findFilter) return err }); err != nil { - return fmt.Errorf("querying scenes: %w", err) + if !job.IsCancelled(ctx) { + logger.Errorf("error querying scenes for auto-tag: %w", err) + } + return } for _, ss := range scenes { if job.IsCancelled(ctx) { - return nil + logger.Info("Stopping auto-tag due to user request") + return } tt := autoTagSceneTask{ @@ -541,15 +576,15 @@ func (t *autoTagFilesTask) processScenes(ctx context.Context, r Repository) erro } } } - - return nil } -func (t *autoTagFilesTask) processImages(ctx context.Context, r Repository) error { +func (t *autoTagFilesTask) processImages(ctx context.Context, r Repository) { if job.IsCancelled(ctx) { - return nil + return } + logger.Info("Auto-tagging images...") + batchSize := 1000 findFilter := models.BatchFindFilter(batchSize) @@ -563,12 +598,16 @@ func (t *autoTagFilesTask) processImages(ctx context.Context, r Repository) erro images, err = image.Query(ctx, r.Image, imageFilter, findFilter) return err }); err != nil { - return fmt.Errorf("querying images: %w", err) + if !job.IsCancelled(ctx) { + logger.Errorf("error querying images for auto-tag: %w", err) + } + return } for _, ss := range images { if job.IsCancelled(ctx) { - return nil + logger.Info("Stopping auto-tag due to user request") + return } tt := autoTagImageTask{ @@ -598,15 +637,15 @@ func (t *autoTagFilesTask) processImages(ctx context.Context, r Repository) erro } } } - - return nil } -func (t *autoTagFilesTask) processGalleries(ctx context.Context, r Repository) error { +func (t *autoTagFilesTask) processGalleries(ctx context.Context, r Repository) { if job.IsCancelled(ctx) { - return nil + return } + logger.Info("Auto-tagging galleries...") + batchSize := 1000 findFilter := models.BatchFindFilter(batchSize) @@ -620,12 +659,16 @@ func (t *autoTagFilesTask) processGalleries(ctx context.Context, r Repository) e galleries, _, err = r.Gallery.Query(ctx, galleryFilter, findFilter) return err }); err != nil { - return fmt.Errorf("querying galleries: %w", err) + if !job.IsCancelled(ctx) { + logger.Errorf("error querying galleries for auto-tag: %w", err) + } + return } for _, ss := range galleries { if job.IsCancelled(ctx) { - return nil + logger.Info("Stopping auto-tag due to user request") + return } tt := autoTagGalleryTask{ @@ -655,8 +698,6 @@ func (t *autoTagFilesTask) processGalleries(ctx context.Context, r Repository) e } } } - - return nil } func (t *autoTagFilesTask) process(ctx context.Context) { @@ -668,35 +709,19 @@ func (t *autoTagFilesTask) process(ctx context.Context) { } t.progress.SetTotal(total) - logger.Infof("Starting autotag of %d files", total) + logger.Infof("Starting auto-tag of %d files", total) return nil }); err != nil { - logger.Errorf("error getting count for autotag task: %v", err) + if !job.IsCancelled(ctx) { + logger.Errorf("error getting file count for auto-tag task: %v", err) + } return } - logger.Info("Autotagging scenes...") - if err := t.processScenes(ctx, r); err != nil { - logger.Errorf("error processing scenes: %w", err) - return - } - - logger.Info("Autotagging images...") - if err := t.processImages(ctx, r); err != nil { - logger.Errorf("error processing images: %w", err) - return - } - - logger.Info("Autotagging galleries...") - if err := t.processGalleries(ctx, r); err != nil { - logger.Errorf("error processing galleries: %w", err) - return - } - - if job.IsCancelled(ctx) { - logger.Info("Stopping due to user request") - } + t.processScenes(ctx, r) + t.processImages(ctx, r) + t.processGalleries(ctx, r) } type autoTagSceneTask struct { @@ -721,23 +746,25 @@ func (t *autoTagSceneTask) Start(ctx context.Context, wg *sync.WaitGroup) { if t.performers { if err := autotag.ScenePerformers(ctx, t.scene, r.Scene, r.Performer, t.cache); err != nil { - return fmt.Errorf("error tagging scene performers for %s: %v", t.scene.DisplayName(), err) + return fmt.Errorf("tagging scene performers for %s: %v", t.scene.DisplayName(), err) } } if t.studios { if err := autotag.SceneStudios(ctx, t.scene, r.Scene, r.Studio, t.cache); err != nil { - return fmt.Errorf("error tagging scene studio for %s: %v", t.scene.DisplayName(), err) + return fmt.Errorf("tagging scene studio for %s: %v", t.scene.DisplayName(), err) } } if t.tags { if err := autotag.SceneTags(ctx, t.scene, r.Scene, r.Tag, t.cache); err != nil { - return fmt.Errorf("error tagging scene tags for %s: %v", t.scene.DisplayName(), err) + return fmt.Errorf("tagging scene tags for %s: %v", t.scene.DisplayName(), err) } } return nil }); err != nil { - logger.Error(err.Error()) + if !job.IsCancelled(ctx) { + logger.Errorf("auto-tag error: %v", err) + } } } @@ -758,23 +785,25 @@ func (t *autoTagImageTask) Start(ctx context.Context, wg *sync.WaitGroup) { if err := t.txnManager.WithTxn(ctx, func(ctx context.Context) error { if t.performers { if err := autotag.ImagePerformers(ctx, t.image, r.Image, r.Performer, t.cache); err != nil { - return fmt.Errorf("error tagging image performers for %s: %v", t.image.DisplayName(), err) + return fmt.Errorf("tagging image performers for %s: %v", t.image.DisplayName(), err) } } if t.studios { if err := autotag.ImageStudios(ctx, t.image, r.Image, r.Studio, t.cache); err != nil { - return fmt.Errorf("error tagging image studio for %s: %v", t.image.DisplayName(), err) + return fmt.Errorf("tagging image studio for %s: %v", t.image.DisplayName(), err) } } if t.tags { if err := autotag.ImageTags(ctx, t.image, r.Image, r.Tag, t.cache); err != nil { - return fmt.Errorf("error tagging image tags for %s: %v", t.image.DisplayName(), err) + return fmt.Errorf("tagging image tags for %s: %v", t.image.DisplayName(), err) } } return nil }); err != nil { - logger.Error(err.Error()) + if !job.IsCancelled(ctx) { + logger.Errorf("auto-tag error: %v", err) + } } } @@ -795,22 +824,24 @@ func (t *autoTagGalleryTask) Start(ctx context.Context, wg *sync.WaitGroup) { if err := t.txnManager.WithTxn(ctx, func(ctx context.Context) error { if t.performers { if err := autotag.GalleryPerformers(ctx, t.gallery, r.Gallery, r.Performer, t.cache); err != nil { - return fmt.Errorf("error tagging gallery performers for %s: %v", t.gallery.DisplayName(), err) + return fmt.Errorf("tagging gallery performers for %s: %v", t.gallery.DisplayName(), err) } } if t.studios { if err := autotag.GalleryStudios(ctx, t.gallery, r.Gallery, r.Studio, t.cache); err != nil { - return fmt.Errorf("error tagging gallery studio for %s: %v", t.gallery.DisplayName(), err) + return fmt.Errorf("tagging gallery studio for %s: %v", t.gallery.DisplayName(), err) } } if t.tags { if err := autotag.GalleryTags(ctx, t.gallery, r.Gallery, r.Tag, t.cache); err != nil { - return fmt.Errorf("error tagging gallery tags for %s: %v", t.gallery.DisplayName(), err) + return fmt.Errorf("tagging gallery tags for %s: %v", t.gallery.DisplayName(), err) } } return nil }); err != nil { - logger.Error(err.Error()) + if !job.IsCancelled(ctx) { + logger.Errorf("auto-tag error: %v", err) + } } } diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index 713265e7c..1a83c1ab6 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -664,7 +664,7 @@ func performerFragmentToScrapedScenePerformer(p graphql.PerformerFragment) *mode func getFirstImage(ctx context.Context, client *http.Client, images []*graphql.ImageFragment) *string { ret, err := fetchImage(ctx, client, images[0].URL) - if err != nil { + if err != nil && !errors.Is(err, context.Canceled) { logger.Warnf("Error fetching image %s: %s", images[0].URL, err.Error()) } From 1c59d91690e09e2391f25f7f65b32cd51b6792b8 Mon Sep 17 00:00:00 2001 From: hontheinternet <121332499+hontheinternet@users.noreply.github.com> Date: Fri, 26 May 2023 11:55:01 +0900 Subject: [PATCH 63/81] fix interactive heatmaps to match the length of the video (#3758) --- .../manager/generator_interactive_heatmap_speed.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/manager/generator_interactive_heatmap_speed.go b/internal/manager/generator_interactive_heatmap_speed.go index 3b3b98bf4..3cae5f562 100644 --- a/internal/manager/generator_interactive_heatmap_speed.go +++ b/internal/manager/generator_interactive_heatmap_speed.go @@ -73,10 +73,11 @@ func (g *InteractiveHeatmapSpeedGenerator) Generate(funscriptPath string, heatma return fmt.Errorf("no valid actions in funscript") } + sceneDurationMilli := int64(sceneDuration * 1000) g.Funscript = funscript g.Funscript.UpdateIntensityAndSpeed() - err = g.RenderHeatmap(heatmapPath) + err = g.RenderHeatmap(heatmapPath, sceneDurationMilli) if err != nil { return err @@ -155,8 +156,8 @@ func (funscript *Script) UpdateIntensityAndSpeed() { } // funscript needs to have intensity updated first -func (g *InteractiveHeatmapSpeedGenerator) RenderHeatmap(heatmapPath string) error { - gradient := g.Funscript.getGradientTable(g.NumSegments) +func (g *InteractiveHeatmapSpeedGenerator) RenderHeatmap(heatmapPath string, sceneDurationMilli int64) error { + gradient := g.Funscript.getGradientTable(g.NumSegments, sceneDurationMilli) img := image.NewRGBA(image.Rect(0, 0, g.Width, g.Height)) for x := 0; x < g.Width; x++ { @@ -179,7 +180,7 @@ func (g *InteractiveHeatmapSpeedGenerator) RenderHeatmap(heatmapPath string) err } // add 10 minute marks - maxts := g.Funscript.Actions[len(g.Funscript.Actions)-1].At + maxts := sceneDurationMilli const tick = 600000 var ts int64 = tick c, _ := colorful.Hex("#000000") @@ -242,7 +243,7 @@ func (gt GradientTable) GetYRange(t float64) [2]float64 { return gt[len(gt)-1].YRange } -func (funscript Script) getGradientTable(numSegments int) GradientTable { +func (funscript Script) getGradientTable(numSegments int, sceneDurationMilli int64) GradientTable { const windowSize = 15 const backfillThreshold = 500 @@ -255,7 +256,7 @@ func (funscript Script) getGradientTable(numSegments int) GradientTable { gradient := make(GradientTable, numSegments) posList := []int{} - maxts := funscript.Actions[len(funscript.Actions)-1].At + maxts := sceneDurationMilli for _, a := range funscript.Actions { posList = append(posList, a.Pos) From 241aae91001a9b3f3d82728174dd5e58e41fe8b2 Mon Sep 17 00:00:00 2001 From: dogwithakeyboard <128322708+dogwithakeyboard@users.noreply.github.com> Date: Mon, 29 May 2023 20:34:35 +0100 Subject: [PATCH 64/81] check for '0001-01-01' in death_date (#3784) --- pkg/sqlite/performer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 7468db8be..d1079eac0 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -758,7 +758,7 @@ func performerAgeFilterCriterionHandler(age *models.IntCriterionInput) criterion return func(ctx context.Context, f *filterBuilder) { if age != nil && age.Modifier.IsValid() { clause, args := getIntCriterionWhereClause( - "cast(IFNULL(strftime('%Y.%m%d', performers.death_date), strftime('%Y.%m%d', 'now')) - strftime('%Y.%m%d', performers.birthdate) as int)", + "cast(strftime('%Y.%m%d',CASE WHEN performers.death_date IS NULL OR performers.death_date = '0001-01-01' OR performers.death_date = '' THEN 'now' ELSE performers.death_date END) - strftime('%Y.%m%d', performers.birthdate) as int)", *age, ) f.addWhere(clause, args...) From fc53380310733ba832eb95f634cf58a98bdcf337 Mon Sep 17 00:00:00 2001 From: NodudeWasTaken <75137537+NodudeWasTaken@users.noreply.github.com> Date: Wed, 31 May 2023 02:27:45 +0200 Subject: [PATCH 65/81] Safari skip file transcodes (#3507) * Ignore file transcodes on safari --- ui/v2.5/package.json | 2 + .../components/ScenePlayer/ScenePlayer.tsx | 41 ++++++++++++------- ui/v2.5/yarn.lock | 10 +++++ 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index d287b8437..c1ce15750 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -64,6 +64,7 @@ "slick-carousel": "^1.8.1", "string.prototype.replaceall": "^1.0.7", "thehandy": "^1.0.3", + "ua-parser-js": "^1.0.34", "universal-cookie": "^4.0.4", "video.js": "^7.21.3", "videojs-contrib-dash": "^5.1.1", @@ -89,6 +90,7 @@ "@types/react-helmet": "^6.1.6", "@types/react-router-bootstrap": "^0.24.5", "@types/react-router-hash-link": "^2.4.5", + "@types/ua-parser-js": "^0.7.36", "@types/video.js": "^7.3.51", "@types/videojs-mobile-ui": "^0.8.0", "@types/videojs-seek-buttons": "^2.1.0", diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index bb04eec3f..c553fa3cf 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -11,6 +11,7 @@ import videojs, { VideoJsPlayer, VideoJsPlayerOptions } from "video.js"; import "videojs-contrib-dash"; import "videojs-mobile-ui"; import "videojs-seek-buttons"; +import { UAParser } from "ua-parser-js"; import "./live"; import "./PlaylistButtons"; import "./source-selector"; @@ -487,24 +488,36 @@ export const ScenePlayer: React.FC = ({ }; player.mobileUi(mobileUiOptions); + function isDirect(src: URL) { + return ( + src.pathname.endsWith("/stream") || + src.pathname.endsWith("/stream.mpd") || + src.pathname.endsWith("/stream.m3u8") + ); + } + const { duration } = file; const sourceSelector = player.sourceSelector(); + const isSafari = UAParser().browser.name?.includes("Safari"); sourceSelector.setSources( - scene.sceneStreams.map((stream) => { - const src = new URL(stream.url); - const isDirect = - src.pathname.endsWith("/stream") || - src.pathname.endsWith("/stream.mpd") || - src.pathname.endsWith("/stream.m3u8"); + scene.sceneStreams + .filter((stream) => { + const src = new URL(stream.url); + const isFileTranscode = !isDirect(src); - return { - src: stream.url, - type: stream.mime_type ?? undefined, - label: stream.label ?? undefined, - offset: !isDirect, - duration, - }; - }) + return !(isFileTranscode && isSafari); + }) + .map((stream) => { + const src = new URL(stream.url); + + return { + src: stream.url, + type: stream.mime_type ?? undefined, + label: stream.label ?? undefined, + offset: !isDirect(src), + duration, + }; + }) ); function getDefaultLanguageCode() { diff --git a/ui/v2.5/yarn.lock b/ui/v2.5/yarn.lock index 9c82c29b8..8725ca14e 100644 --- a/ui/v2.5/yarn.lock +++ b/ui/v2.5/yarn.lock @@ -2430,6 +2430,11 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.13.tgz#da4bfd73f49bd541d28920ab0e2bf0ee80f71c91" integrity sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw== +"@types/ua-parser-js@^0.7.36": + version "0.7.36" + resolved "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz" + integrity sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ== + "@types/unist@*", "@types/unist@^2.0.0", "@types/unist@^2.0.2", "@types/unist@^2.0.3": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d" @@ -7678,6 +7683,11 @@ ua-parser-js@^1.0.2: resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.33.tgz#f21f01233e90e7ed0f059ceab46eb190ff17f8f4" integrity sha512-RqshF7TPTE0XLYAqmjlu5cLLuGdKrNu9O1KLA/qp39QtbZwuzwv1dT46DZSopoUMsYgXpB3Cv8a03FI8b74oFQ== +ua-parser-js@^1.0.34: + version "1.0.34" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.34.tgz#b33f41c415325839f354005d25a2f588be296976" + integrity sha512-K9mwJm/DaB6mRLZfw6q8IMXipcrmuT6yfhYmwhAkuh+81sChuYstYA+znlgaflUPaYUa3odxKPKGw6Vw/lANew== + unbox-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" From d0847d1ebfd04085df7aa6d747efb2cd64cc6fcc Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Wed, 31 May 2023 02:39:22 +0200 Subject: [PATCH 66/81] Fix performer image display again and refactoring (#3782) * Fix the fix for displayed performer image sticking after save * Refactor for consistency * Fully extract entity create/update logic from edit pages * Fix submit hotkeys * Refactor scene cover preview * Fix atoi error on new scene page --- .../Galleries/GalleryDetails/Gallery.tsx | 18 +++++ .../GalleryDetails/GalleryCreate.tsx | 32 +++++++- .../GalleryDetails/GalleryEditPanel.tsx | 60 +++------------ .../components/Images/ImageDetails/Image.tsx | 14 ++++ .../Images/ImageDetails/ImageEditPanel.tsx | 29 +++----- .../components/Movies/MovieDetails/Movie.tsx | 43 ++++++----- .../Movies/MovieDetails/MovieCreate.tsx | 22 +++--- .../Movies/MovieDetails/MovieEditPanel.tsx | 29 +++++--- .../Performers/PerformerDetails/Performer.tsx | 57 +++++++++----- .../PerformerDetails/PerformerCreate.tsx | 29 +++++++- .../PerformerDetails/PerformerEditPanel.tsx | 74 ++++++------------- .../components/Scenes/SceneDetails/Scene.tsx | 18 +++++ .../Scenes/SceneDetails/SceneCreate.tsx | 29 +++++++- .../Scenes/SceneDetails/SceneEditPanel.tsx | 69 ++++++----------- .../Studios/StudioDetails/Studio.tsx | 43 ++++++----- .../Studios/StudioDetails/StudioCreate.tsx | 18 +++-- .../Studios/StudioDetails/StudioEditPanel.tsx | 39 +++++++--- .../src/components/Tags/TagDetails/Tag.tsx | 59 ++++++++------- .../components/Tags/TagDetails/TagCreate.tsx | 41 +++++----- .../Tags/TagDetails/TagEditPanel.tsx | 39 +++++++--- ui/v2.5/src/core/StashService.ts | 6 +- 21 files changed, 436 insertions(+), 332 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index 057642b1a..f7d50da29 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -64,6 +64,23 @@ export const GalleryPage: React.FC = ({ gallery }) => { const [organizedLoading, setOrganizedLoading] = useState(false); + async function onSave(input: GQL.GalleryCreateInput) { + await updateGallery({ + variables: { + input: { + id: gallery.id, + ...input, + }, + }, + }); + Toast.success({ + content: intl.formatMessage( + { id: "toast.updated_entity" }, + { entity: intl.formatMessage({ id: "gallery" }).toLocaleLowerCase() } + ), + }); + } + const onOrganizedClick = async () => { try { setOrganizedLoading(true); @@ -242,6 +259,7 @@ export const GalleryPage: React.FC = ({ gallery }) => { setIsDeleteAlertOpen(true)} /> diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryCreate.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryCreate.tsx index 62e80e23e..d6519bcd2 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryCreate.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryCreate.tsx @@ -1,16 +1,39 @@ import React, { useMemo } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { useLocation } from "react-router-dom"; +import { useHistory, useLocation } from "react-router-dom"; +import * as GQL from "src/core/generated-graphql"; +import { useGalleryCreate } from "src/core/StashService"; +import { useToast } from "src/hooks/Toast"; import { GalleryEditPanel } from "./GalleryEditPanel"; const GalleryCreate: React.FC = () => { + const history = useHistory(); const intl = useIntl(); + const Toast = useToast(); + const location = useLocation(); const query = useMemo(() => new URLSearchParams(location.search), [location]); const gallery = { title: query.get("q") ?? undefined, }; + const [createGallery] = useGalleryCreate(); + + async function onSave(input: GQL.GalleryCreateInput) { + const result = await createGallery({ + variables: { input }, + }); + if (result.data?.galleryCreate) { + history.push(`/galleries/${result.data.galleryCreate.id}`); + Toast.success({ + content: intl.formatMessage( + { id: "toast.created_entity" }, + { entity: intl.formatMessage({ id: "gallery" }).toLocaleLowerCase() } + ), + }); + } + } + return (
@@ -20,7 +43,12 @@ const GalleryCreate: React.FC = () => { values={{ entityType: intl.formatMessage({ id: "gallery" }) }} /> - {}} /> + {}} + />
); diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index d560127dc..27f3fdb78 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { useHistory, Prompt } from "react-router-dom"; +import { Prompt } from "react-router-dom"; import { Button, Dropdown, @@ -15,8 +15,6 @@ import * as yup from "yup"; import { queryScrapeGallery, queryScrapeGalleryURL, - useGalleryCreate, - useGalleryUpdate, useListGalleryScrapers, mutateReloadScrapers, } from "src/core/StashService"; @@ -44,17 +42,18 @@ import { DateInput } from "src/components/Shared/DateInput"; interface IProps { gallery: Partial; isVisible: boolean; + onSubmit: (input: GQL.GalleryCreateInput) => Promise; onDelete: () => void; } export const GalleryEditPanel: React.FC = ({ gallery, isVisible, + onSubmit, onDelete, }) => { const intl = useIntl(); const Toast = useToast(); - const history = useHistory(); const [scenes, setScenes] = useState<{ id: string; title: string }[]>( (gallery?.scenes ?? []).map((s) => ({ id: s.id, @@ -74,9 +73,6 @@ export const GalleryEditPanel: React.FC = ({ // Network state const [isLoading, setIsLoading] = useState(false); - const [createGallery] = useGalleryCreate(); - const [updateGallery] = useGalleryUpdate(); - const titleRequired = isNew || (gallery?.files?.length === 0 && !gallery?.folder); @@ -151,7 +147,9 @@ export const GalleryEditPanel: React.FC = ({ useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { - formik.handleSubmit(); + if (formik.dirty) { + formik.submitForm(); + } }); Mousetrap.bind("d d", () => { onDelete(); @@ -174,51 +172,11 @@ export const GalleryEditPanel: React.FC = ({ setQueryableScrapers(newQueryableScrapers); }, [Scrapers]); - async function onSave(input: GQL.GalleryCreateInput) { + async function onSave(input: InputValues) { setIsLoading(true); try { - if (isNew) { - const result = await createGallery({ - variables: { - input, - }, - }); - if (result.data?.galleryCreate) { - history.push(`/galleries/${result.data.galleryCreate.id}`); - Toast.success({ - content: intl.formatMessage( - { id: "toast.created_entity" }, - { - entity: intl - .formatMessage({ id: "gallery" }) - .toLocaleLowerCase(), - } - ), - }); - } - } else { - const result = await updateGallery({ - variables: { - input: { - id: gallery.id!, - ...input, - }, - }, - }); - if (result.data?.galleryUpdate) { - Toast.success({ - content: intl.formatMessage( - { id: "toast.updated_entity" }, - { - entity: intl - .formatMessage({ id: "gallery" }) - .toLocaleLowerCase(), - } - ), - }); - formik.resetForm(); - } - } + await onSubmit(input); + formik.resetForm(); } catch (e) { Toast.error(e); } diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index b9485767c..c52ae22d7 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -17,6 +17,7 @@ import { Icon } from "src/components/Shared/Icon"; import { Counter } from "src/components/Shared/Counter"; import { useToast } from "src/hooks/Toast"; import * as Mousetrap from "mousetrap"; +import * as GQL from "src/core/generated-graphql"; import { OCounterButton } from "src/components/Scenes/SceneDetails/OCounterButton"; import { OrganizedButton } from "src/components/Scenes/SceneDetails/OrganizedButton"; import { ImageFileInfoPanel } from "./ImageFileInfoPanel"; @@ -51,6 +52,18 @@ export const Image: React.FC = () => { const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); + async function onSave(input: GQL.ImageUpdateInput) { + await updateImage({ + variables: { input }, + }); + Toast.success({ + content: intl.formatMessage( + { id: "toast.updated_entity" }, + { entity: intl.formatMessage({ id: "image" }).toLocaleLowerCase() } + ), + }); + } + async function onRescan() { if (!image || !image.visual_files.length) { return; @@ -225,6 +238,7 @@ export const Image: React.FC = () => { setIsDeleteAlertOpen(true)} /> diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx index 96ace1609..9d7e55e9f 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx @@ -4,7 +4,6 @@ import { FormattedMessage, useIntl } from "react-intl"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; -import { useImageUpdate } from "src/core/StashService"; import { PerformerSelect, TagSelect, @@ -25,12 +24,14 @@ import { DateInput } from "src/components/Shared/DateInput"; interface IProps { image: GQL.ImageDataFragment; isVisible: boolean; + onSubmit: (input: GQL.ImageUpdateInput) => Promise; onDelete: () => void; } export const ImageEditPanel: React.FC = ({ image, isVisible, + onSubmit, onDelete, }) => { const intl = useIntl(); @@ -41,8 +42,6 @@ export const ImageEditPanel: React.FC = ({ const { configuration } = React.useContext(ConfigurationContext); - const [updateImage] = useImageUpdate(); - const schema = yup.object({ title: yup.string().ensure(), url: yup.string().ensure(), @@ -97,7 +96,9 @@ export const ImageEditPanel: React.FC = ({ useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { - formik.handleSubmit(); + if (formik.dirty) { + formik.submitForm(); + } }); Mousetrap.bind("d d", () => { onDelete(); @@ -113,23 +114,11 @@ export const ImageEditPanel: React.FC = ({ async function onSave(input: InputValues) { setIsLoading(true); try { - const result = await updateImage({ - variables: { - input: { - id: image.id, - ...input, - }, - }, + await onSubmit({ + id: image.id, + ...input, }); - if (result.data?.imageUpdate) { - Toast.success({ - content: intl.formatMessage( - { id: "toast.updated_entity" }, - { entity: intl.formatMessage({ id: "image" }).toLocaleLowerCase() } - ), - }); - formik.resetForm(); - } + formik.resetForm(); } catch (e) { Toast.error(e); } diff --git a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx index 4e723858c..fc04df94b 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx @@ -83,7 +83,7 @@ const MoviePage: React.FC = ({ movie }) => { // set up hotkeys useEffect(() => { - Mousetrap.bind("e", () => setIsEditing(true)); + Mousetrap.bind("e", () => toggleEditing()); Mousetrap.bind("d d", () => { onDelete(); }); @@ -95,22 +95,21 @@ const MoviePage: React.FC = ({ movie }) => { }); async function onSave(input: GQL.MovieCreateInput) { - try { - const result = await updateMovie({ - variables: { - input: { - id: movie.id, - ...input, - }, + await updateMovie({ + variables: { + input: { + id: movie.id, + ...input, }, - }); - if (result.data?.movieUpdate) { - setIsEditing(false); - history.push(`/movies/${result.data.movieUpdate.id}`); - } - } catch (e) { - Toast.error(e); - } + }, + }); + toggleEditing(false); + Toast.success({ + content: intl.formatMessage( + { id: "toast.updated_entity" }, + { entity: intl.formatMessage({ id: "movie" }).toLocaleLowerCase() } + ), + }); } async function onDelete() { @@ -124,8 +123,12 @@ const MoviePage: React.FC = ({ movie }) => { history.push(`/movies`); } - function onToggleEdit() { - setIsEditing(!isEditing); + function toggleEditing(value?: boolean) { + if (value !== undefined) { + setIsEditing(value); + } else { + setIsEditing((e) => !e); + } setFrontImage(undefined); setBackImage(undefined); } @@ -239,7 +242,7 @@ const MoviePage: React.FC = ({ movie }) => { objectName={movie.name} isNew={false} isEditing={isEditing} - onToggleEdit={onToggleEdit} + onToggleEdit={() => toggleEditing()} onSave={() => {}} onImageChange={() => {}} onDelete={onDelete} @@ -249,7 +252,7 @@ const MoviePage: React.FC = ({ movie }) => { toggleEditing()} onDelete={onDelete} setFrontImage={setFrontImage} setBackImage={setBackImage} diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx index 36b6ea5bd..973fe89bd 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx @@ -2,15 +2,17 @@ import React, { useMemo, useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { useMovieCreate } from "src/core/StashService"; import { useHistory, useLocation } from "react-router-dom"; +import { useIntl } from "react-intl"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useToast } from "src/hooks/Toast"; import { MovieEditPanel } from "./MovieEditPanel"; const MovieCreate: React.FC = () => { const history = useHistory(); - const location = useLocation(); + const intl = useIntl(); const Toast = useToast(); + const location = useLocation(); const query = useMemo(() => new URLSearchParams(location.search), [location]); const movie = { name: query.get("q") ?? undefined, @@ -24,15 +26,17 @@ const MovieCreate: React.FC = () => { const [createMovie] = useMovieCreate(); async function onSave(input: GQL.MovieCreateInput) { - try { - const result = await createMovie({ - variables: input, + const result = await createMovie({ + variables: input, + }); + if (result.data?.movieCreate?.id) { + history.push(`/movies/${result.data.movieCreate.id}`); + Toast.success({ + content: intl.formatMessage( + { id: "toast.created_entity" }, + { entity: intl.formatMessage({ id: "gallery" }).toLocaleLowerCase() } + ), }); - if (result.data?.movieCreate?.id) { - history.push(`/movies/${result.data.movieCreate.id}`); - } - } catch (e) { - Toast.error(e); } } diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx index c2c51794c..60f4465ed 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx @@ -28,7 +28,7 @@ import { DateInput } from "src/components/Shared/DateInput"; interface IMovieEditPanel { movie: Partial; - onSubmit: (movie: GQL.MovieCreateInput) => void; + onSubmit: (movie: GQL.MovieCreateInput) => Promise; onCancel: () => void; onDelete: () => void; setFrontImage: (image?: string | null) => void; @@ -103,7 +103,7 @@ export const MovieEditPanel: React.FC = ({ initialValues, enableReinitialize: true, validationSchema: schema, - onSubmit: (values) => onSubmit(values), + onSubmit: (values) => onSave(values), }); function setRating(v: number) { @@ -116,19 +116,17 @@ export const MovieEditPanel: React.FC = ({ setRating ); - function onCancelEditing() { - setFrontImage(undefined); - setBackImage(undefined); - onCancel?.(); - } - // set up hotkeys useEffect(() => { // Mousetrap.bind("u", (e) => { // setStudioFocus() // e.preventDefault(); // }); - Mousetrap.bind("s s", () => formik.handleSubmit()); + Mousetrap.bind("s s", () => { + if (formik.dirty) { + formik.submitForm(); + } + }); return () => { // Mousetrap.unbind("u"); @@ -182,6 +180,17 @@ export const MovieEditPanel: React.FC = ({ } } + async function onSave(input: InputValues) { + setIsLoading(true); + try { + await onSubmit(input); + formik.resetForm(); + } catch (e) { + Toast.error(e); + } + setIsLoading(false); + } + async function onScrapeMovieURL() { const { url } = formik.values; if (!url) return; @@ -488,7 +497,7 @@ export const MovieEditPanel: React.FC = ({ objectName={movie?.name ?? intl.formatMessage({ id: "movie" })} isNew={isNew} isEditing={isEditing} - onToggleEdit={onCancelEditing} + onToggleEdit={onCancel} onSave={formik.handleSubmit} saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})} onImageChange={onFrontImageChange} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index 197556c8b..a024124c8 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -68,15 +68,17 @@ const PerformerPage: React.FC = ({ performer }) => { const activeImage = useMemo(() => { const performerImage = performer.image_path; - if (image === null && performerImage) { - const performerImageURL = new URL(performerImage); - performerImageURL.searchParams.set("default", "true"); - return performerImageURL.toString(); - } else if (image) { - return image; + if (isEditing) { + if (image === null && performerImage) { + const performerImageURL = new URL(performerImage); + performerImageURL.searchParams.set("default", "true"); + return performerImageURL.toString(); + } else if (image) { + return image; + } } return performerImage; - }, [image, performer.image_path]); + }, [image, isEditing, performer.image_path]); const lightboxImages = useMemo( () => [{ paths: { thumbnail: activeImage, image: activeImage } }], @@ -122,15 +124,10 @@ const PerformerPage: React.FC = ({ performer }) => { setRating ); - // reset image if performer changed - useEffect(() => { - setImage(undefined); - }, [performer]); - // set up hotkeys useEffect(() => { Mousetrap.bind("a", () => setActiveTabKey("details")); - Mousetrap.bind("e", () => setIsEditing(!isEditing)); + Mousetrap.bind("e", () => toggleEditing()); Mousetrap.bind("c", () => setActiveTabKey("scenes")); Mousetrap.bind("g", () => setActiveTabKey("galleries")); Mousetrap.bind("m", () => setActiveTabKey("movies")); @@ -147,6 +144,24 @@ const PerformerPage: React.FC = ({ performer }) => { }; }); + async function onSave(input: GQL.PerformerCreateInput) { + await updatePerformer({ + variables: { + input: { + id: performer.id, + ...input, + }, + }, + }); + toggleEditing(false); + Toast.success({ + content: intl.formatMessage( + { id: "toast.updated_entity" }, + { entity: intl.formatMessage({ id: "performer" }).toLocaleLowerCase() } + ), + }); + } + async function onDelete() { try { await deletePerformer({ variables: { id: performer.id } }); @@ -158,6 +173,15 @@ const PerformerPage: React.FC = ({ performer }) => { history.push("/performers"); } + function toggleEditing(value?: boolean) { + if (value !== undefined) { + setIsEditing(value); + } else { + setIsEditing((e) => !e); + } + setImage(undefined); + } + function renderImage() { if (activeImage) { return ( @@ -175,9 +199,7 @@ const PerformerPage: React.FC = ({ performer }) => { objectName={ performer?.name ?? intl.formatMessage({ id: "performer" }) } - onToggleEdit={() => { - setIsEditing(!isEditing); - }} + onToggleEdit={() => toggleEditing()} onDelete={onDelete} onAutoTag={onAutoTag} isNew={false} @@ -297,7 +319,8 @@ const PerformerPage: React.FC = ({ performer }) => { setIsEditing(false)} + onSubmit={onSave} + onCancel={() => toggleEditing()} setImage={setImage} setEncodingImage={setEncodingImage} /> diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerCreate.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerCreate.tsx index 2f3e32b2e..26b3f88f0 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerCreate.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerCreate.tsx @@ -2,9 +2,16 @@ import React, { useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { PerformerEditPanel } from "./PerformerEditPanel"; -import { useLocation } from "react-router-dom"; +import { useHistory, useLocation } from "react-router-dom"; +import { useToast } from "src/hooks/Toast"; +import * as GQL from "src/core/generated-graphql"; +import { usePerformerCreate } from "src/core/StashService"; const PerformerCreate: React.FC = () => { + const Toast = useToast(); + const history = useHistory(); + const intl = useIntl(); + const [image, setImage] = useState(); const [encodingImage, setEncodingImage] = useState(false); @@ -14,7 +21,24 @@ const PerformerCreate: React.FC = () => { name: query.get("q") ?? undefined, }; - const intl = useIntl(); + const [createPerformer] = usePerformerCreate(); + + async function onSave(input: GQL.PerformerCreateInput) { + const result = await createPerformer({ + variables: { input }, + }); + if (result.data?.performerCreate) { + history.push(`/performers/${result.data.performerCreate.id}`); + Toast.success({ + content: intl.formatMessage( + { id: "toast.created_entity" }, + { + entity: intl.formatMessage({ id: "performer" }).toLocaleLowerCase(), + } + ), + }); + } + } function renderPerformerImage() { if (encodingImage) { @@ -46,6 +70,7 @@ const PerformerCreate: React.FC = () => { diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index 03f2dd128..5c2d26d3d 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -8,8 +8,6 @@ import { useListPerformerScrapers, queryScrapePerformer, mutateReloadScrapers, - usePerformerUpdate, - usePerformerCreate, useTagCreate, queryScrapePerformerURL, } from "src/core/StashService"; @@ -24,7 +22,7 @@ import ImageUtils from "src/utils/image"; import { getStashIDs } from "src/utils/stashIds"; import { stashboxDisplayName } from "src/utils/stashbox"; import { useToast } from "src/hooks/Toast"; -import { Prompt, useHistory } from "react-router-dom"; +import { Prompt } from "react-router-dom"; import { useFormik } from "formik"; import { genderToString, @@ -57,6 +55,7 @@ const isScraper = ( interface IPerformerDetails { performer: Partial; isVisible: boolean; + onSubmit: (performer: GQL.PerformerCreateInput) => Promise; onCancel?: () => void; setImage: (image?: string | null) => void; setEncodingImage: (loading: boolean) => void; @@ -65,12 +64,12 @@ interface IPerformerDetails { export const PerformerEditPanel: React.FC = ({ performer, isVisible, + onSubmit, onCancel, setImage, setEncodingImage, }) => { const Toast = useToast(); - const history = useHistory(); const isNew = performer.id === undefined; @@ -82,9 +81,6 @@ export const PerformerEditPanel: React.FC = ({ // Network state const [isLoading, setIsLoading] = useState(false); - const [updatePerformer] = usePerformerUpdate(); - const [createPerformer] = usePerformerCreate(); - const Scrapers = useListPerformerScrapers(); const [queryableScrapers, setQueryableScrapers] = useState([]); @@ -454,61 +450,35 @@ export const PerformerEditPanel: React.FC = ({ ImageUtils.onImageChange(event, onImageLoad); } + function valuesToInput(input: InputValues): GQL.PerformerCreateInput { + return { + ...input, + gender: input.gender || null, + height_cm: input.height_cm || null, + weight: input.weight || null, + penis_length: input.penis_length || null, + circumcised: input.circumcised || null, + }; + } + async function onSave(input: InputValues) { setIsLoading(true); try { - if (isNew) { - const result = await createPerformer({ - variables: { - input: { - ...input, - gender: input.gender || null, - height_cm: input.height_cm || null, - weight: input.weight || null, - penis_length: input.penis_length || null, - circumcised: input.circumcised || null, - }, - }, - }); - if (result.data?.performerCreate) { - history.push(`/performers/${result.data.performerCreate.id}`); - } - } else { - await updatePerformer({ - variables: { - input: { - id: performer.id!, - ...input, - gender: input.gender || null, - height_cm: input.height_cm || null, - weight: input.weight || null, - penis_length: input.penis_length || null, - circumcised: input.circumcised || null, - }, - }, - }); - } + await onSubmit(valuesToInput(input)); + formik.resetForm(); } catch (e) { Toast.error(e); - setIsLoading(false); - return; - } - if (!isNew && onCancel) { - onCancel(); } setIsLoading(false); } - function onCancelEditing() { - setImage(undefined); - onCancel?.(); - } - // set up hotkeys useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { - onSave?.(formik.values); + if (formik.dirty) { + formik.submitForm(); + } }); return () => { @@ -699,9 +669,7 @@ export const PerformerEditPanel: React.FC = ({ } const currentPerformer = { - ...formik.values, - gender: formik.values.gender || null, - circumcised: formik.values.circumcised || null, + ...valuesToInput(formik.values), image: formik.values.image ?? performer.image_path, }; @@ -729,7 +697,7 @@ export const PerformerEditPanel: React.FC = ({ return (
{!isNew && onCancel ? ( - ) : null} diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 5d6bb3690..b3ef0b423 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -169,6 +169,23 @@ const ScenePage: React.FC = ({ }; }); + async function onSave(input: GQL.SceneCreateInput) { + await updateScene({ + variables: { + input: { + id: scene.id, + ...input, + }, + }, + }); + Toast.success({ + content: intl.formatMessage( + { id: "toast.updated_entity" }, + { entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase() } + ), + }); + } + const onOrganizedClick = async () => { try { setOrganizedLoading(true); @@ -461,6 +478,7 @@ const ScenePage: React.FC = ({ setIsDeleteAlertOpen(true)} /> diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneCreate.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneCreate.tsx index 4e289c52d..81181272f 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneCreate.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneCreate.tsx @@ -1,19 +1,23 @@ import React, { useEffect, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { useLocation } from "react-router-dom"; +import { useHistory, useLocation } from "react-router-dom"; import { SceneEditPanel } from "./SceneEditPanel"; -import { useFindScene } from "src/core/StashService"; +import * as GQL from "src/core/generated-graphql"; +import { mutateCreateScene, useFindScene } from "src/core/StashService"; import ImageUtils from "src/utils/image"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; +import { useToast } from "src/hooks/Toast"; const SceneCreate: React.FC = () => { + const history = useHistory(); const intl = useIntl(); + const Toast = useToast(); const location = useLocation(); const query = useMemo(() => new URLSearchParams(location.search), [location]); // create scene from provided scene id if applicable - const { data, loading } = useFindScene(query.get("from_scene_id") ?? ""); + const { data, loading } = useFindScene(query.get("from_scene_id") ?? "new"); const [loadingCoverImage, setLoadingCoverImage] = useState(false); const [coverImage, setCoverImage] = useState(); @@ -53,6 +57,23 @@ const SceneCreate: React.FC = () => { return ; } + async function onSave(input: GQL.SceneCreateInput) { + const fileID = query.get("file_id") ?? undefined; + const result = await mutateCreateScene({ + ...input, + file_ids: fileID ? [fileID] : undefined, + }); + if (result.data?.sceneCreate?.id) { + history.push(`/scenes/${result.data.sceneCreate.id}`); + Toast.success({ + content: intl.formatMessage( + { id: "toast.created_entity" }, + { entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase() } + ), + }); + } + } + return (
@@ -64,10 +85,10 @@ const SceneCreate: React.FC = () => {
diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index d3e81aac0..840621e33 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -16,10 +16,8 @@ import { queryScrapeScene, queryScrapeSceneURL, useListSceneScrapers, - useSceneUpdate, mutateReloadScrapers, queryScrapeSceneQueryFragment, - mutateCreateScene, } from "src/core/StashService"; import { PerformerSelect, @@ -37,7 +35,7 @@ import ImageUtils from "src/utils/image"; import FormUtils from "src/utils/form"; import { getStashIDs } from "src/utils/stashIds"; import { useFormik } from "formik"; -import { Prompt, useHistory } from "react-router-dom"; +import { Prompt } from "react-router-dom"; import { ConfigurationContext } from "src/hooks/Config"; import { stashboxDisplayName } from "src/utils/stashbox"; import { SceneMovieTable } from "./SceneMovieTable"; @@ -59,24 +57,23 @@ const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); interface IProps { scene: Partial; - fileID?: string; initialCoverImage?: string; isNew?: boolean; isVisible: boolean; + onSubmit: (input: GQL.SceneCreateInput) => Promise; onDelete?: () => void; } export const SceneEditPanel: React.FC = ({ scene, - fileID, initialCoverImage, isNew = false, isVisible, + onSubmit, onDelete, }) => { const intl = useIntl(); const Toast = useToast(); - const history = useHistory(); const [galleries, setGalleries] = useState<{ id: string; title: string }[]>( [] @@ -92,14 +89,6 @@ export const SceneEditPanel: React.FC = ({ const [scrapedScene, setScrapedScene] = useState(); const [endpoint, setEndpoint] = useState(); - const [coverImagePreview, setCoverImagePreview] = useState(); - - useEffect(() => { - setCoverImagePreview( - initialCoverImage ?? scene.paths?.screenshot ?? undefined - ); - }, [scene.paths?.screenshot, initialCoverImage]); - useEffect(() => { setGalleries( scene.galleries?.map((g) => ({ @@ -114,8 +103,6 @@ export const SceneEditPanel: React.FC = ({ // Network state const [isLoading, setIsLoading] = useState(false); - const [updateScene] = useSceneUpdate(); - const schema = yup.object({ title: yup.string().ensure(), code: yup.string().ensure(), @@ -183,6 +170,19 @@ export const SceneEditPanel: React.FC = ({ onSubmit: (values) => onSave(values), }); + const coverImagePreview = useMemo(() => { + const sceneImage = scene.paths?.screenshot; + const formImage = formik.values.cover_image; + if (formImage === null && sceneImage) { + const sceneImageURL = new URL(sceneImage); + sceneImageURL.searchParams.set("default", "true"); + return sceneImageURL.toString(); + } else if (formImage) { + return formImage; + } + return sceneImage; + }, [formik.values.cover_image, scene.paths?.screenshot]); + function setRating(v: number) { formik.setFieldValue("rating100", v); } @@ -209,7 +209,9 @@ export const SceneEditPanel: React.FC = ({ useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { - formik.handleSubmit(); + if (formik.dirty) { + formik.submitForm(); + } }); Mousetrap.bind("d d", () => { if (onDelete) { @@ -259,35 +261,8 @@ export const SceneEditPanel: React.FC = ({ async function onSave(input: InputValues) { setIsLoading(true); try { - if (!isNew) { - const result = await updateScene({ - variables: { - input: { - id: scene.id!, - ...input, - }, - }, - }); - if (result.data?.sceneUpdate) { - Toast.success({ - content: intl.formatMessage( - { id: "toast.updated_entity" }, - { - entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase(), - } - ), - }); - formik.resetForm(); - } - } else { - const result = await mutateCreateScene({ - ...input, - file_ids: fileID ? [fileID] : undefined, - }); - if (result.data?.sceneCreate?.id) { - history.push(`/scenes/${result.data?.sceneCreate.id}`); - } - } + await onSubmit(input); + formik.resetForm(); } catch (e) { Toast.error(e); } @@ -318,7 +293,6 @@ export const SceneEditPanel: React.FC = ({ const encodingImage = ImageUtils.usePasteImage(onImageLoad); function onImageLoad(imageData: string) { - setCoverImagePreview(imageData); formik.setFieldValue("cover_image", imageData); } @@ -619,7 +593,6 @@ export const SceneEditPanel: React.FC = ({ if (updatedScene.image) { // image is a base64 string formik.setFieldValue("cover_image", updatedScene.image); - setCoverImagePreview(updatedScene.image); } if (updatedScene.remote_site_id && endpoint) { diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 8ae80d93f..049bfa076 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -69,7 +69,7 @@ const StudioPage: React.FC = ({ studio }) => { // set up hotkeys useEffect(() => { - Mousetrap.bind("e", () => setIsEditing(true)); + Mousetrap.bind("e", () => toggleEditing()); Mousetrap.bind("d d", () => { onDelete(); }); @@ -83,21 +83,21 @@ const StudioPage: React.FC = ({ studio }) => { }); async function onSave(input: GQL.StudioCreateInput) { - try { - const result = await updateStudio({ - variables: { - input: { - id: studio.id, - ...input, - }, + await updateStudio({ + variables: { + input: { + id: studio.id, + ...input, }, - }); - if (result.data?.studioUpdate) { - setIsEditing(false); - } - } catch (e) { - Toast.error(e); - } + }, + }); + toggleEditing(false); + Toast.success({ + content: intl.formatMessage( + { id: "toast.updated_entity" }, + { entity: intl.formatMessage({ id: "studio" }).toLocaleLowerCase() } + ), + }); } async function onAutoTag() { @@ -149,8 +149,13 @@ const StudioPage: React.FC = ({ studio }) => { ); } - function onToggleEdit() { - setIsEditing(!isEditing); + function toggleEditing(value?: boolean) { + if (value !== undefined) { + setIsEditing(value); + } else { + setIsEditing((e) => !e); + } + setImage(undefined); } function renderImage() { @@ -213,7 +218,7 @@ const StudioPage: React.FC = ({ studio }) => { objectName={studio.name ?? intl.formatMessage({ id: "studio" })} isNew={false} isEditing={isEditing} - onToggleEdit={onToggleEdit} + onToggleEdit={() => toggleEditing()} onSave={() => {}} onImageChange={() => {}} onClearImage={() => {}} @@ -225,7 +230,7 @@ const StudioPage: React.FC = ({ studio }) => { toggleEditing()} onDelete={onDelete} setImage={setImage} setEncodingImage={setEncodingImage} diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioCreate.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioCreate.tsx index 44ceb259a..250c85d44 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioCreate.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioCreate.tsx @@ -27,15 +27,17 @@ const StudioCreate: React.FC = () => { const [createStudio] = useStudioCreate(); async function onSave(input: GQL.StudioCreateInput) { - try { - const result = await createStudio({ - variables: { input }, + const result = await createStudio({ + variables: { input }, + }); + if (result.data?.studioCreate?.id) { + history.push(`/studios/${result.data.studioCreate.id}`); + Toast.success({ + content: intl.formatMessage( + { id: "toast.created_entity" }, + { entity: intl.formatMessage({ id: "studio" }).toLocaleLowerCase() } + ), }); - if (result.data?.studioCreate?.id) { - history.push(`/studios/${result.data.studioCreate.id}`); - } - } catch (e) { - Toast.error(e); } } diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index c2a469a70..8eb919483 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -1,9 +1,10 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; import Mousetrap from "mousetrap"; import { Icon } from "src/components/Shared/Icon"; +import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { StudioSelect } from "src/components/Shared/Select"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { Button, Form, Col, Row } from "react-bootstrap"; @@ -18,10 +19,11 @@ import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; import { useRatingKeybinds } from "src/hooks/keybinds"; import { ConfigurationContext } from "src/hooks/Config"; import isEqual from "lodash-es/isEqual"; +import { useToast } from "src/hooks/Toast"; interface IStudioEditPanel { studio: Partial; - onSubmit: (studio: GQL.StudioCreateInput) => void; + onSubmit: (studio: GQL.StudioCreateInput) => Promise; onCancel: () => void; onDelete: () => void; setImage: (image?: string | null) => void; @@ -37,10 +39,14 @@ export const StudioEditPanel: React.FC = ({ setEncodingImage, }) => { const intl = useIntl(); + const Toast = useToast(); const isNew = studio.id === undefined; const { configuration } = React.useContext(ConfigurationContext); + // Network state + const [isLoading, setIsLoading] = useState(false); + const schema = yup.object({ name: yup.string().required(), url: yup.string().ensure(), @@ -73,6 +79,7 @@ export const StudioEditPanel: React.FC = ({ }); const initialValues = { + id: studio.id, name: studio.name ?? "", url: studio.url ?? "", details: studio.details ?? "", @@ -89,7 +96,7 @@ export const StudioEditPanel: React.FC = ({ initialValues, enableReinitialize: true, validationSchema: schema, - onSubmit: (values) => onSubmit(values), + onSubmit: (values) => onSave(values), }); const encodingImage = ImageUtils.usePasteImage((imageData) => @@ -114,20 +121,30 @@ export const StudioEditPanel: React.FC = ({ setRating ); - function onCancelEditing() { - setImage(undefined); - onCancel?.(); - } - // set up hotkeys useEffect(() => { - Mousetrap.bind("s s", () => formik.handleSubmit()); + Mousetrap.bind("s s", () => { + if (formik.dirty) { + formik.submitForm(); + } + }); return () => { Mousetrap.unbind("s s"); }; }); + async function onSave(input: InputValues) { + setIsLoading(true); + try { + await onSubmit(input); + formik.resetForm(); + } catch (e) { + Toast.error(e); + } + setIsLoading(false); + } + function onImageLoad(imageData: string | null) { formik.setFieldValue("image", imageData); } @@ -200,6 +217,8 @@ export const StudioEditPanel: React.FC = ({ : undefined; const aliasErrorIdx = aliasErrors?.split(" ").map((e) => parseInt(e)); + if (isLoading) return ; + return ( <> = ({ objectName={studio?.name ?? intl.formatMessage({ id: "studio" })} isNew={isNew} isEditing - onToggleEdit={onCancelEditing} + onToggleEdit={onCancel} onSave={formik.handleSubmit} saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})} onImageChange={onImageChange} diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index 07f7db40b..3e7cc7d09 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -88,7 +88,7 @@ const TagPage: React.FC = ({ tag }) => { // set up hotkeys useEffect(() => { - Mousetrap.bind("e", () => setIsEditing(true)); + Mousetrap.bind("e", () => toggleEditing()); Mousetrap.bind("d d", () => { onDelete(); }); @@ -106,30 +106,31 @@ const TagPage: React.FC = ({ tag }) => { }); async function onSave(input: GQL.TagCreateInput) { - try { - const oldRelations = { - parents: tag.parents ?? [], - children: tag.children ?? [], - }; - const result = await updateTag({ - variables: { - input: { - id: tag.id, - ...input, - }, + const oldRelations = { + parents: tag.parents ?? [], + children: tag.children ?? [], + }; + const result = await updateTag({ + variables: { + input: { + id: tag.id, + ...input, }, + }, + }); + if (result.data?.tagUpdate) { + toggleEditing(false); + const updated = result.data.tagUpdate; + tagRelationHook(updated, oldRelations, { + parents: updated.parents, + children: updated.children, + }); + Toast.success({ + content: intl.formatMessage( + { id: "toast.updated_entity" }, + { entity: intl.formatMessage({ id: "tag" }).toLocaleLowerCase() } + ), }); - if (result.data?.tagUpdate) { - setIsEditing(false); - const updated = result.data.tagUpdate; - tagRelationHook(updated, oldRelations, { - parents: updated.parents, - children: updated.children, - }); - return updated.id; - } - } catch (e) { - Toast.error(e); } } @@ -190,8 +191,12 @@ const TagPage: React.FC = ({ tag }) => { ); } - function onToggleEdit() { - setIsEditing(!isEditing); + function toggleEditing(value?: boolean) { + if (value !== undefined) { + setIsEditing(value); + } else { + setIsEditing((e) => !e); + } setImage(undefined); } @@ -283,7 +288,7 @@ const TagPage: React.FC = ({ tag }) => { objectName={tag.name} isNew={false} isEditing={isEditing} - onToggleEdit={onToggleEdit} + onToggleEdit={() => toggleEditing()} onSave={() => {}} onImageChange={() => {}} onClearImage={() => {}} @@ -297,7 +302,7 @@ const TagPage: React.FC = ({ tag }) => { toggleEditing()} onDelete={onDelete} setImage={setImage} setEncodingImage={setEncodingImage} diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagCreate.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagCreate.tsx index 428d339b0..4b4c6dfc1 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagCreate.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagCreate.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useState } from "react"; import { useHistory, useLocation } from "react-router-dom"; - +import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { useTagCreate } from "src/core/StashService"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; @@ -9,10 +9,11 @@ import { tagRelationHook } from "src/core/tags"; import { TagEditPanel } from "./TagEditPanel"; const TagCreate: React.FC = () => { + const intl = useIntl(); const history = useHistory(); - const location = useLocation(); const Toast = useToast(); + const location = useLocation(); const query = useMemo(() => new URLSearchParams(location.search), [location]); const tag = { name: query.get("q") ?? undefined, @@ -25,24 +26,26 @@ const TagCreate: React.FC = () => { const [createTag] = useTagCreate(); async function onSave(input: GQL.TagCreateInput) { - try { - const oldRelations = { - parents: [], - children: [], - }; - const result = await createTag({ - variables: { input }, + const oldRelations = { + parents: [], + children: [], + }; + const result = await createTag({ + variables: { input }, + }); + if (result.data?.tagCreate?.id) { + const created = result.data.tagCreate; + tagRelationHook(created, oldRelations, { + parents: created.parents, + children: created.children, + }); + history.push(`/tags/${created.id}`); + Toast.success({ + content: intl.formatMessage( + { id: "toast.created_entity" }, + { entity: intl.formatMessage({ id: "tag" }).toLocaleLowerCase() } + ), }); - if (result.data?.tagCreate?.id) { - const created = result.data.tagCreate; - tagRelationHook(created, oldRelations, { - parents: created.parents, - children: created.children, - }); - history.push(`/tags/${result.data.tagCreate.id}`); - } - } catch (e) { - Toast.error(e); } } diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx index 2c2eca35d..8ad847fc9 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; @@ -10,13 +10,14 @@ import ImageUtils from "src/utils/image"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; import Mousetrap from "mousetrap"; +import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { StringListInput } from "src/components/Shared/StringListInput"; import isEqual from "lodash-es/isEqual"; +import { useToast } from "src/hooks/Toast"; interface ITagEditPanel { tag: Partial; - // returns id - onSubmit: (tag: GQL.TagCreateInput) => void; + onSubmit: (tag: GQL.TagCreateInput) => Promise; onCancel: () => void; onDelete: () => void; setImage: (image?: string | null) => void; @@ -32,9 +33,13 @@ export const TagEditPanel: React.FC = ({ setEncodingImage, }) => { const intl = useIntl(); + const Toast = useToast(); const isNew = tag.id === undefined; + // Network state + const [isLoading, setIsLoading] = useState(false); + const labelXS = 3; const labelXL = 3; const fieldXS = 9; @@ -84,23 +89,33 @@ export const TagEditPanel: React.FC = ({ initialValues, validationSchema: schema, enableReinitialize: true, - onSubmit: (values) => onSubmit(values), + onSubmit: (values) => onSave(values), }); - function onCancelEditing() { - setImage(undefined); - onCancel?.(); - } - // set up hotkeys useEffect(() => { - Mousetrap.bind("s s", () => formik.handleSubmit()); + Mousetrap.bind("s s", () => { + if (formik.dirty) { + formik.submitForm(); + } + }); return () => { Mousetrap.unbind("s s"); }; }); + async function onSave(input: InputValues) { + setIsLoading(true); + try { + await onSubmit(input); + formik.resetForm(); + } catch (e) { + Toast.error(e); + } + setIsLoading(false); + } + const encodingImage = ImageUtils.usePasteImage(onImageLoad); useEffect(() => { @@ -127,6 +142,8 @@ export const TagEditPanel: React.FC = ({ : undefined; const aliasErrorIdx = aliasErrors?.split(" ").map((e) => parseInt(e)); + if (isLoading) return ; + const isEditing = true; // TODO: CSS class @@ -275,7 +292,7 @@ export const TagEditPanel: React.FC = ({ objectName={tag?.name ?? intl.formatMessage({ id: "tag" })} isNew={isNew} isEditing={isEditing} - onToggleEdit={onCancelEditing} + onToggleEdit={onCancel} onSave={formik.handleSubmit} saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})} onImageChange={onImageChange} diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index df4ce69ea..10bb49d3a 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -222,8 +222,10 @@ export const useFindGallery = (id: string) => { const skip = id === "new"; return GQL.useFindGalleryQuery({ variables: { id }, skip }); }; -export const useFindScene = (id: string) => - GQL.useFindSceneQuery({ variables: { id } }); +export const useFindScene = (id: string) => { + const skip = id === "new"; + return GQL.useFindSceneQuery({ variables: { id }, skip }); +}; export const useSceneStreams = (id: string) => GQL.useSceneStreamsQuery({ variables: { id } }); From 88179ed54e56ba627a32bdc50078b60fef8e3393 Mon Sep 17 00:00:00 2001 From: CJ <72030708+Teda1@users.noreply.github.com> Date: Tue, 30 May 2023 20:04:38 -0500 Subject: [PATCH 67/81] Adds videojs-vr support (#3636) * Add button for VR mode * fix canvas disapearing * allow user to specify vr tag --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- ui/v2.5/package.json | 1 + ui/v2.5/public/vr.svg | 5 + .../components/ScenePlayer/ScenePlayer.tsx | 19 ++- .../src/components/ScenePlayer/styles.scss | 11 ++ ui/v2.5/src/components/ScenePlayer/vrmode.ts | 146 ++++++++++++++++++ .../Scenes/SceneDetails/SceneEditPanel.tsx | 6 - .../SceneDetails/SceneVideoFilterPanel.tsx | 4 +- .../SettingsInterfacePanel.tsx | 7 + ui/v2.5/src/core/config.ts | 1 + ui/v2.5/src/locales/en-GB.json | 6 +- ui/v2.5/yarn.lock | 49 +++++- 11 files changed, 245 insertions(+), 10 deletions(-) create mode 100644 ui/v2.5/public/vr.svg create mode 100644 ui/v2.5/src/components/ScenePlayer/vrmode.ts diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index c1ce15750..47356a9d6 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -70,6 +70,7 @@ "videojs-contrib-dash": "^5.1.1", "videojs-mobile-ui": "^0.8.0", "videojs-seek-buttons": "^3.0.1", + "videojs-vr": "^2.0.0", "videojs-vtt.js": "^0.15.4", "yup": "^1.0.0" }, diff --git a/ui/v2.5/public/vr.svg b/ui/v2.5/public/vr.svg new file mode 100644 index 000000000..2c6c29773 --- /dev/null +++ b/ui/v2.5/public/vr.svg @@ -0,0 +1,5 @@ + + diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index c553fa3cf..0eef94528 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -20,6 +20,7 @@ import "./markers"; import "./vtt-thumbnails"; import "./big-buttons"; import "./track-activity"; +import "./vrmode"; import cx from "classnames"; import { useSceneSaveActivity, @@ -213,6 +214,7 @@ export const ScenePlayer: React.FC = ({ const minimumPlayPercent = uiConfig?.minimumPlayPercent ?? 0; const trackActivity = uiConfig?.trackActivity ?? false; + const vrTag = uiConfig?.vrTag ?? undefined; const file = useMemo( () => ((scene?.files.length ?? 0) > 0 ? scene?.files[0] : undefined), @@ -265,6 +267,16 @@ export const ScenePlayer: React.FC = ({ // Initialize VideoJS player useEffect(() => { + function isVrScene() { + if (!scene?.id || !vrTag) return false; + + return scene?.tags.some((tag) => { + if (vrTag == tag.name) { + return true; + } + }); + } + const options: VideoJsPlayerOptions = { id: VIDEO_PLAYER_ID, controls: true, @@ -318,11 +330,15 @@ export const ScenePlayer: React.FC = ({ }, skipButtons: {}, trackActivity: {}, + vrMenu: { + showButton: isVrScene(), + }, }, }; const videoEl = document.createElement("video-js"); videoEl.setAttribute("data-vjs-player", "true"); + videoEl.setAttribute("crossorigin", "anonymous"); videoEl.classList.add("vjs-big-play-centered"); videoRef.current!.appendChild(videoEl); @@ -348,7 +364,7 @@ export const ScenePlayer: React.FC = ({ // reset sceneId to force reload sources sceneId.current = undefined; }; - }, []); + }, [scene, vrTag]); useEffect(() => { const player = getPlayer(); @@ -662,6 +678,7 @@ export const ScenePlayer: React.FC = ({ }, [ getPlayer, scene, + vrTag, trackActivity, minimumPlayPercent, sceneIncrementPlayCount, diff --git a/ui/v2.5/src/components/ScenePlayer/styles.scss b/ui/v2.5/src/components/ScenePlayer/styles.scss index c8bee39ea..63cc0bc3c 100644 --- a/ui/v2.5/src/components/ScenePlayer/styles.scss +++ b/ui/v2.5/src/components/ScenePlayer/styles.scss @@ -189,6 +189,17 @@ $sceneTabWidth: 450px; } } + .vjs-vr-selector { + .vjs-menu li { + font-size: 0.8em; + } + + .vjs-button { + background: url("/vr.svg") center center no-repeat; + width: 50%; + } + } + .vjs-marker { background-color: rgba(33, 33, 33, 0.8); bottom: 0; diff --git a/ui/v2.5/src/components/ScenePlayer/vrmode.ts b/ui/v2.5/src/components/ScenePlayer/vrmode.ts new file mode 100644 index 000000000..93459ab86 --- /dev/null +++ b/ui/v2.5/src/components/ScenePlayer/vrmode.ts @@ -0,0 +1,146 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import videojs, { VideoJsPlayer } from "video.js"; +import "videojs-vr"; + +export interface VRMenuOptions { + /** + * Whether to show the vr button. + * @default false + */ + showButton?: boolean; +} + +enum VRType { + Spherical = "360", + Off = "Off", +} + +const vrTypeProjection = { + [VRType.Spherical]: "360", + [VRType.Off]: "NONE", +}; + +function isVrDevice() { + return navigator.userAgent.match(/oculusbrowser|\svr\s/i); +} + +class VRMenuItem extends videojs.getComponent("MenuItem") { + public type: VRType; + public isSelected = false; + + constructor(parent: VRMenuButton, type: VRType) { + const options = {} as videojs.MenuItemOptions; + options.selectable = true; + options.multiSelectable = false; + options.label = type; + + super(parent.player(), options); + + this.type = type; + + this.addClass("vjs-source-menu-item"); + } + + selected(selected: boolean): void { + super.selected(selected); + this.isSelected = selected; + } + + handleClick() { + if (this.isSelected) return; + + this.trigger("selected"); + } +} + +class VRMenuButton extends videojs.getComponent("MenuButton") { + private items: VRMenuItem[] = []; + private selectedType: VRType = VRType.Off; + + constructor(player: VideoJsPlayer) { + super(player); + this.setTypes(); + } + + private onSelected(item: VRMenuItem) { + this.selectedType = item.type; + + this.items.forEach((i) => { + i.selected(i.type === this.selectedType); + }); + + this.trigger("typeselected", item.type); + } + + public setTypes() { + this.items = Object.values(VRType).map((type) => { + const item = new VRMenuItem(this, type); + + item.on("selected", () => { + this.onSelected(item); + }); + + return item; + }); + this.update(); + } + + createEl() { + return videojs.dom.createEl("div", { + className: + "vjs-vr-selector vjs-menu-button vjs-menu-button-popup vjs-control vjs-button", + }); + } + + createItems() { + if (this.items === undefined) return []; + + for (const item of this.items) { + item.selected(item.type === this.selectedType); + } + + return this.items; + } +} + +class VRMenuPlugin extends videojs.getPlugin("plugin") { + private menu: VRMenuButton; + + constructor(player: VideoJsPlayer, options: VRMenuOptions) { + super(player); + + this.menu = new VRMenuButton(player); + + if (isVrDevice() || !options.showButton) return; + + this.menu.on("typeselected", (_, type: VRType) => { + const projection = vrTypeProjection[type]; + player.vr({ projection }); + player.load(); + }); + + player.on("ready", () => { + const { controlBar } = player; + const fullscreenToggle = controlBar.getChild("fullscreenToggle")!.el(); + controlBar.addChild(this.menu); + controlBar.el().insertBefore(this.menu.el(), fullscreenToggle); + }); + } +} + +// Register the plugin with video.js. +videojs.registerComponent("VRMenuButton", VRMenuButton); +videojs.registerPlugin("vrMenu", VRMenuPlugin); + +/* eslint-disable @typescript-eslint/naming-convention */ +declare module "video.js" { + interface VideoJsPlayer { + vrMenu: () => VRMenuPlugin; + vr: (options: Object) => void; + } + interface VideoJsPlayerPluginOptions { + vrMenu?: VRMenuOptions; + } +} + +export default VRMenuPlugin; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 840621e33..4e11ef6eb 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -743,7 +743,6 @@ export const SceneEditPanel: React.FC = ({ /> - {FormUtils.renderLabel({ title: intl.formatMessage({ id: "date" }), @@ -756,7 +755,6 @@ export const SceneEditPanel: React.FC = ({ /> - {renderTextField( "director", intl.formatMessage({ id: "director" }) @@ -790,7 +788,6 @@ export const SceneEditPanel: React.FC = ({ /> - {FormUtils.renderLabel({ title: intl.formatMessage({ id: "studio" }), @@ -811,7 +808,6 @@ export const SceneEditPanel: React.FC = ({ /> - {FormUtils.renderLabel({ title: intl.formatMessage({ id: "performers" }), @@ -834,7 +830,6 @@ export const SceneEditPanel: React.FC = ({ /> - {FormUtils.renderLabel({ title: `${intl.formatMessage({ @@ -857,7 +852,6 @@ export const SceneEditPanel: React.FC = ({ {renderTableMovies()} - {FormUtils.renderLabel({ title: intl.formatMessage({ id: "tags" }), diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx index f70451d4e..5de8b045a 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx @@ -111,7 +111,9 @@ export const SceneVideoFilterPanel: React.FC = ( function updateVideoStyle() { const playerVideoContainer = document.getElementById(VIDEO_PLAYER_ID); const videoElements = - playerVideoContainer?.getElementsByTagName("video") ?? []; + playerVideoContainer?.getElementsByTagName("canvas") ?? + playerVideoContainer?.getElementsByTagName("video") ?? + []; const playerVideoElement = videoElements.length > 0 ? videoElements[0] : null; diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 97866b4d4..c44f3ab78 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -290,6 +290,13 @@ export const SettingsInterfacePanel: React.FC = () => { checked={ui.trackActivity ?? undefined} onChange={(v) => saveUI({ trackActivity: v })} /> + saveUI({ vrTag: v })} + /> id="ignore-interval" headingID="config.ui.minimum_play_percent.heading" diff --git a/ui/v2.5/src/core/config.ts b/ui/v2.5/src/core/config.ts index 8ca489bf3..90e11742c 100644 --- a/ui/v2.5/src/core/config.ts +++ b/ui/v2.5/src/core/config.ts @@ -59,6 +59,7 @@ export interface IUIConfig { lastNoteSeen?: number; + vrTag?: string; pinnedFilters?: PinnedFilters; } diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index d07278676..0eafd4bca 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -658,7 +658,11 @@ "heading": "Continue playlist by default" }, "show_scrubber": "Show Scrubber", - "track_activity": "Track Activity" + "track_activity": "Track Activity", + "vr_tag": { + "description": "The VR button will only be displayed for scenes with this tag.", + "heading": "VR Tag" + } } }, "scene_wall": { diff --git a/ui/v2.5/yarn.lock b/ui/v2.5/yarn.lock index 8725ca14e..3d150f8d1 100644 --- a/ui/v2.5/yarn.lock +++ b/ui/v2.5/yarn.lock @@ -1081,7 +1081,7 @@ dependencies: regenerator-runtime "^0.13.11" -"@babel/runtime@^7.8.4": +"@babel/runtime@^7.14.5", "@babel/runtime@^7.8.4": version "7.21.0" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.0.tgz#5b55c9d394e5fcf304909a8b00c07dc217b56673" integrity sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw== @@ -3211,6 +3211,15 @@ capital-case@^1.0.4: tslib "^2.0.3" upper-case-first "^2.0.2" +cardboard-vr-display@^1.0.19: + version "1.0.19" + resolved "https://registry.yarnpkg.com/cardboard-vr-display/-/cardboard-vr-display-1.0.19.tgz#81dcde1804b329b8228b757ac00e1fd2afa9d748" + integrity sha512-+MjcnWKAkb95p68elqZLDPzoiF/dGncQilLGvPBM5ZorABp/ao3lCs7nnRcYBckmuNkg1V/5rdGDKoUaCVsHzQ== + dependencies: + gl-preserve-state "^1.0.0" + nosleep.js "^0.7.0" + webvr-polyfill-dpdb "^1.0.17" + ccount@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.1.0.tgz#246687debb6014735131be8abab2d93898f8d043" @@ -4455,6 +4464,11 @@ get-symbol-description@^1.0.0: call-bind "^1.0.2" get-intrinsic "^1.1.1" +gl-preserve-state@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/gl-preserve-state/-/gl-preserve-state-1.0.0.tgz#4ef710d62873f1470ed015c6546c37dacddd4198" + integrity sha512-zQZ25l3haD4hvgJZ6C9+s0ebdkW9y+7U2qxvGu1uWOJh8a4RU+jURIKEQhf8elIlFpMH6CrAY2tH0mYrRjet3Q== + glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" @@ -6002,6 +6016,11 @@ normalize-url@^4.5.1: resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.1.tgz#0dd90cf1288ee1d1313b87081c9a5932ee48518a" integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA== +nosleep.js@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/nosleep.js/-/nosleep.js-0.7.0.tgz#cfd919c25523ca0d0f4a69fb3305c083adaee289" + integrity sha512-Z4B1HgvzR+en62ghwZf6BwAR6x4/pjezsiMcbF9KMLh7xoscpoYhaSXfY3lLkqC68AtW+/qLJ1lzvBIj0FGaTA== + nullthrows@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1" @@ -7471,6 +7490,11 @@ thehandy@^1.0.3: resolved "https://registry.yarnpkg.com/thehandy/-/thehandy-1.0.3.tgz#51c5e9bae5932a6e5c563203711d78610b99d402" integrity sha512-zuuyWKBx/jqku9+MZkdkoK2oLM2mS8byWVR/vkQYq/ygAT6gPAXwiT94rfGuqv+1BLmsyJxm69nhVIzOZjfyIg== +three@0.125.2: + version "0.125.2" + resolved "https://registry.yarnpkg.com/three/-/three-0.125.2.tgz#dcba12749a2eb41522e15212b919cd3fbf729b12" + integrity sha512-7rIRO23jVKWcAPFdW/HREU2NZMGWPBZ4XwEMt0Ak0jwLUKVJhcKM55eCBWyGZq/KiQbeo1IeuAoo/9l2dzhTXA== + throttle-debounce@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-5.0.0.tgz#a17a4039e82a2ed38a5e7268e4132d6960d41933" @@ -7975,6 +7999,17 @@ videojs-seek-buttons@^3.0.1: dependencies: global "^4.4.0" +videojs-vr@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/videojs-vr/-/videojs-vr-2.0.0.tgz#3d86e3fececf7373cfb89b950ed6ab77ca783d2b" + integrity sha512-ix4iN8XHaDSEe89Jqybj9DuLKYuK33EIzcSI0IEdnv1KJuH8bd0PYlQEgqIZTOmWruFpW/+rjYFCVUQ9PTypJw== + dependencies: + "@babel/runtime" "^7.14.5" + global "^4.4.0" + three "0.125.2" + video.js "^6 || ^7" + webvr-polyfill "0.10.12" + videojs-vtt.js@^0.15.4: version "0.15.4" resolved "https://registry.yarnpkg.com/videojs-vtt.js/-/videojs-vtt.js-0.15.4.tgz#5dc5aabcd82ba40c5595469bd855ea8230ca152c" @@ -8052,6 +8087,18 @@ webidl-conversions@^3.0.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== +webvr-polyfill-dpdb@^1.0.17: + version "1.0.18" + resolved "https://registry.yarnpkg.com/webvr-polyfill-dpdb/-/webvr-polyfill-dpdb-1.0.18.tgz#258484ce06b057bf18898acc911bd173847bce11" + integrity sha512-O0S1ZGEWyPvyZEkS2VbyV7mtir/NM9MNK3EuhbHPoJ8EHTky2pTXehjIl+IiDPr+Lldgx129QGt3NGly7rwRPw== + +webvr-polyfill@0.10.12: + version "0.10.12" + resolved "https://registry.yarnpkg.com/webvr-polyfill/-/webvr-polyfill-0.10.12.tgz#47ea0b0d558f09e089bc49fa7b47a4ee7e4b8148" + integrity sha512-trDJEVUQnRIVAnmImjEQ0BlL1NfuWl8+eaEdu+bs4g59c7OtETi/5tFkgEFDRaWEYwHntXs/uFF3OXZuutNGGA== + dependencies: + cardboard-vr-display "^1.0.19" + whatwg-fetch@^3.4.1: version "3.6.2" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz#dced24f37f2624ed0281725d51d0e2e3fe677f8c" From 9c8a6ee495cb4dcdeda1ff73014b94ce47bccbbd Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 31 May 2023 11:05:28 +1000 Subject: [PATCH 68/81] Male performer images (#3770) * Apply cis gender images to default transgender images * Replace male images with consistent ones --- internal/api/images.go | 4 ++-- internal/static/performer_male/Male01.png | Bin 0 -> 29574 bytes internal/static/performer_male/Male02.png | Bin 0 -> 27367 bytes internal/static/performer_male/Male03.png | Bin 0 -> 26475 bytes internal/static/performer_male/Male04.png | Bin 0 -> 26600 bytes internal/static/performer_male/Male05.png | Bin 0 -> 25812 bytes internal/static/performer_male/Male06.png | Bin 0 -> 31704 bytes .../static/performer_male/noname_male_01.jpg | Bin 27775 -> 0 bytes .../static/performer_male/noname_male_02.jpg | Bin 15631 -> 0 bytes 9 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 internal/static/performer_male/Male01.png create mode 100644 internal/static/performer_male/Male02.png create mode 100644 internal/static/performer_male/Male03.png create mode 100644 internal/static/performer_male/Male04.png create mode 100644 internal/static/performer_male/Male05.png create mode 100644 internal/static/performer_male/Male06.png delete mode 100644 internal/static/performer_male/noname_male_01.jpg delete mode 100644 internal/static/performer_male/noname_male_02.jpg diff --git a/internal/api/images.go b/internal/api/images.go index 7ddbbfc10..95ed4c844 100644 --- a/internal/api/images.go +++ b/internal/api/images.go @@ -102,9 +102,9 @@ func getRandomPerformerImageUsingName(name string, gender *models.GenderEnum, cu if box == nil { switch g { - case models.GenderEnumFemale: + case models.GenderEnumFemale, models.GenderEnumTransgenderFemale: box = performerBox - case models.GenderEnumMale: + case models.GenderEnumMale, models.GenderEnumTransgenderMale: box = performerBoxMale default: box = performerBox diff --git a/internal/static/performer_male/Male01.png b/internal/static/performer_male/Male01.png new file mode 100644 index 0000000000000000000000000000000000000000..8a486299ab6fc4ea7ab3cbd98fc3778ff84f7162 GIT binary patch literal 29574 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 literal 0 HcmV?d00001 diff --git a/internal/static/performer_male/Male02.png b/internal/static/performer_male/Male02.png new file mode 100644 index 0000000000000000000000000000000000000000..673b120eb43392fbac967b16b2d2c167a8dfe815 GIT binary patch literal 27367 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 z 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 diff --git a/internal/static/performer_male/noname_male_02.jpg b/internal/static/performer_male/noname_male_02.jpg deleted file mode 100644 index 93ad7ec9dd8e8a3db3efac695a484a1c73752853..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15631 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 Date: Wed, 31 May 2023 11:06:01 +1000 Subject: [PATCH 69/81] Update gallery UpdatedAt timestamp on contents change (#3771) * Update gallery updatedAt on content change * Update gallery in UI on image change --- internal/api/resolver_mutation_image.go | 23 +++++++ internal/manager/repository.go | 2 + pkg/gallery/service.go | 5 ++ pkg/gallery/update.go | 27 ++++++-- pkg/models/update.go | 42 +++++++++++ pkg/models/update_test.go | 92 +++++++++++++++++++++++++ ui/v2.5/src/core/StashService.ts | 2 + 7 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 pkg/models/update_test.go diff --git a/internal/api/resolver_mutation_image.go b/internal/api/resolver_mutation_image.go index 353dab744..24b81967a 100644 --- a/internal/api/resolver_mutation_image.go +++ b/internal/api/resolver_mutation_image.go @@ -10,6 +10,7 @@ import ( "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin" + "github.com/stashapp/stash/pkg/sliceutil/intslice" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/utils" ) @@ -138,6 +139,8 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInp } } + var updatedGalleryIDs []int + if translator.hasField("gallery_ids") { updatedImage.GalleryIDs, err = translateUpdateIDs(input.GalleryIds, models.RelationshipUpdateModeSet) if err != nil { @@ -152,6 +155,8 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInp if err := r.galleryService.ValidateImageGalleryChange(ctx, i, *updatedImage.GalleryIDs); err != nil { return nil, err } + + updatedGalleryIDs = updatedImage.GalleryIDs.ImpactedIDs(i.GalleryIDs.List()) } if translator.hasField("performer_ids") { @@ -174,6 +179,13 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInp return nil, err } + // #3759 - update all impacted galleries + for _, galleryID := range updatedGalleryIDs { + if err := r.galleryService.Updated(ctx, galleryID); err != nil { + return nil, fmt.Errorf("updating gallery %d: %w", galleryID, err) + } + } + return image, nil } @@ -223,6 +235,7 @@ func (r *mutationResolver) BulkImageUpdate(ctx context.Context, input BulkImageU // Start the transaction and save the image marker if err := r.withTxn(ctx, func(ctx context.Context) error { + var updatedGalleryIDs []int qb := r.repository.Image for _, imageID := range imageIDs { @@ -244,6 +257,9 @@ func (r *mutationResolver) BulkImageUpdate(ctx context.Context, input BulkImageU if err := r.galleryService.ValidateImageGalleryChange(ctx, i, *updatedImage.GalleryIDs); err != nil { return err } + + thisUpdatedGalleryIDs := updatedImage.GalleryIDs.ImpactedIDs(i.GalleryIDs.List()) + updatedGalleryIDs = intslice.IntAppendUniques(updatedGalleryIDs, thisUpdatedGalleryIDs) } image, err := qb.UpdatePartial(ctx, imageID, updatedImage) @@ -254,6 +270,13 @@ func (r *mutationResolver) BulkImageUpdate(ctx context.Context, input BulkImageU ret = append(ret, image) } + // #3759 - update all impacted galleries + for _, galleryID := range updatedGalleryIDs { + if err := r.galleryService.Updated(ctx, galleryID); err != nil { + return fmt.Errorf("updating gallery %d: %w", galleryID, err) + } + } + return nil }); err != nil { return nil, err diff --git a/internal/manager/repository.go b/internal/manager/repository.go index dd49c4af7..55fea1672 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -113,4 +113,6 @@ type GalleryService interface { Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) ValidateImageGalleryChange(ctx context.Context, i *models.Image, updateIDs models.UpdateIDs) error + + Updated(ctx context.Context, galleryID int) error } diff --git a/pkg/gallery/service.go b/pkg/gallery/service.go index acf70763f..7dfc3857f 100644 --- a/pkg/gallery/service.go +++ b/pkg/gallery/service.go @@ -18,6 +18,11 @@ type Repository interface { Destroy(ctx context.Context, id int) error models.FileLoader ImageUpdater + PartialUpdater +} + +type PartialUpdater interface { + UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error) } type ImageFinder interface { diff --git a/pkg/gallery/update.go b/pkg/gallery/update.go index 5350499ac..72f479bea 100644 --- a/pkg/gallery/update.go +++ b/pkg/gallery/update.go @@ -2,20 +2,25 @@ package gallery import ( "context" + "fmt" + "time" "github.com/stashapp/stash/pkg/models" ) -type PartialUpdater interface { - UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error) -} - type ImageUpdater interface { GetImageIDs(ctx context.Context, galleryID int) ([]int, error) AddImages(ctx context.Context, galleryID int, imageIDs ...int) error RemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error } +func (s *Service) Updated(ctx context.Context, galleryID int) error { + _, err := s.Repository.UpdatePartial(ctx, galleryID, models.GalleryPartial{ + UpdatedAt: models.NewOptionalTime(time.Now()), + }) + return err +} + // AddImages adds images to the provided gallery. // It returns an error if the gallery does not support adding images, or if // the operation fails. @@ -24,7 +29,12 @@ func (s *Service) AddImages(ctx context.Context, g *models.Gallery, toAdd ...int return err } - return s.Repository.AddImages(ctx, g.ID, toAdd...) + if err := s.Repository.AddImages(ctx, g.ID, toAdd...); err != nil { + return fmt.Errorf("failed to add images to gallery: %w", err) + } + + // #3759 - update the gallery's UpdatedAt timestamp + return s.Updated(ctx, g.ID) } // RemoveImages removes images from the provided gallery. @@ -36,7 +46,12 @@ func (s *Service) RemoveImages(ctx context.Context, g *models.Gallery, toRemove return err } - return s.Repository.RemoveImages(ctx, g.ID, toRemove...) + if err := s.Repository.RemoveImages(ctx, g.ID, toRemove...); err != nil { + return fmt.Errorf("failed to remove images from gallery: %w", err) + } + + // #3759 - update the gallery's UpdatedAt timestamp + return s.Updated(ctx, g.ID) } func AddPerformer(ctx context.Context, qb PartialUpdater, o *models.Gallery, performerID int) error { diff --git a/pkg/models/update.go b/pkg/models/update.go index fbfab3d30..ffa793bda 100644 --- a/pkg/models/update.go +++ b/pkg/models/update.go @@ -64,6 +64,48 @@ func (u *UpdateIDs) IDStrings() []string { return intslice.IntSliceToStringSlice(u.IDs) } +// GetImpactedIDs returns the IDs that will be impacted by the update. +// If the update is to add IDs, then the impacted IDs are the IDs being added. +// If the update is to remove IDs, then the impacted IDs are the IDs being removed. +// If the update is to set IDs, then the impacted IDs are the IDs being removed and the IDs being added. +// Any IDs that are already present and are being added are not returned. +// Likewise, any IDs that are not present that are being removed are not returned. +func (u *UpdateIDs) ImpactedIDs(existing []int) []int { + if u == nil { + return nil + } + + switch u.Mode { + case RelationshipUpdateModeAdd: + return intslice.IntExclude(u.IDs, existing) + case RelationshipUpdateModeRemove: + return intslice.IntIntercect(existing, u.IDs) + case RelationshipUpdateModeSet: + // get the difference between the two lists + return intslice.IntNotIntersect(existing, u.IDs) + } + + return nil +} + +// GetEffectiveIDs returns the new IDs that will be effective after the update. +func (u *UpdateIDs) EffectiveIDs(existing []int) []int { + if u == nil { + return nil + } + + switch u.Mode { + case RelationshipUpdateModeAdd: + return intslice.IntAppendUniques(existing, u.IDs) + case RelationshipUpdateModeRemove: + return intslice.IntExclude(existing, u.IDs) + case RelationshipUpdateModeSet: + return u.IDs + } + + return nil +} + type UpdateStrings struct { Values []string `json:"values"` Mode RelationshipUpdateMode `json:"mode"` diff --git a/pkg/models/update_test.go b/pkg/models/update_test.go new file mode 100644 index 000000000..0baf7926f --- /dev/null +++ b/pkg/models/update_test.go @@ -0,0 +1,92 @@ +package models + +import ( + "reflect" + "testing" +) + +func TestUpdateIDs_ImpactedIDs(t *testing.T) { + tests := []struct { + name string + IDs []int + Mode RelationshipUpdateMode + existing []int + want []int + }{ + { + name: "add", + IDs: []int{1, 2, 3}, + Mode: RelationshipUpdateModeAdd, + existing: []int{1, 2}, + want: []int{3}, + }, + { + name: "remove", + IDs: []int{1, 2, 3}, + Mode: RelationshipUpdateModeRemove, + existing: []int{1, 2}, + want: []int{1, 2}, + }, + { + name: "set", + IDs: []int{1, 2, 3}, + Mode: RelationshipUpdateModeSet, + existing: []int{1, 2}, + want: []int{3}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u := &UpdateIDs{ + IDs: tt.IDs, + Mode: tt.Mode, + } + if got := u.ImpactedIDs(tt.existing); !reflect.DeepEqual(got, tt.want) { + t.Errorf("UpdateIDs.ImpactedIDs() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestUpdateIDs_EffectiveIDs(t *testing.T) { + tests := []struct { + name string + IDs []int + Mode RelationshipUpdateMode + existing []int + want []int + }{ + { + name: "add", + IDs: []int{2, 3}, + Mode: RelationshipUpdateModeAdd, + existing: []int{1, 2}, + want: []int{1, 2, 3}, + }, + { + name: "remove", + IDs: []int{2, 3}, + Mode: RelationshipUpdateModeRemove, + existing: []int{1, 2}, + want: []int{1}, + }, + { + name: "set", + IDs: []int{1, 2, 3}, + Mode: RelationshipUpdateModeSet, + existing: []int{1, 2}, + want: []int{1, 2, 3}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u := &UpdateIDs{ + IDs: tt.IDs, + Mode: tt.Mode, + } + if got := u.EffectiveIDs(tt.existing); !reflect.DeepEqual(got, tt.want) { + t.Errorf("UpdateIDs.EffectiveIDs() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 10bb49d3a..7e79db3b1 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -734,6 +734,7 @@ export const mutateAddGalleryImages = (input: GQL.GalleryAddInput) => mutation: GQL.AddGalleryImagesDocument, variables: input, update: deleteCache(galleryMutationImpactedQueries), + refetchQueries: getQueryNames([GQL.FindGalleryDocument]), }); export const mutateRemoveGalleryImages = (input: GQL.GalleryRemoveInput) => @@ -741,6 +742,7 @@ export const mutateRemoveGalleryImages = (input: GQL.GalleryRemoveInput) => mutation: GQL.RemoveGalleryImagesDocument, variables: input, update: deleteCache(galleryMutationImpactedQueries), + refetchQueries: getQueryNames([GQL.FindGalleryDocument]), }); export const mutateGallerySetPrimaryFile = (id: string, fileID: string) => From 94450da8b5385d7c7d79ab5edffc655f5a4adedd Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 31 May 2023 11:42:28 +1000 Subject: [PATCH 70/81] Use string criterion for name (#3788) --- ui/v2.5/src/models/list-filter/criteria/factory.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/v2.5/src/models/list-filter/criteria/factory.ts b/ui/v2.5/src/models/list-filter/criteria/factory.ts index 311b78728..5096a14b0 100644 --- a/ui/v2.5/src/models/list-filter/criteria/factory.ts +++ b/ui/v2.5/src/models/list-filter/criteria/factory.ts @@ -66,6 +66,9 @@ export function makeCriteria( case "none": return new NoneCriterion(); case "name": + return new StringCriterion( + new MandatoryStringCriterionOption(type, type) + ); case "path": return new StringCriterion(new PathCriterionOption(type, type)); case "checksum": From c8a796e12582389163a4e9bfabd0de96e2dba63b Mon Sep 17 00:00:00 2001 From: CJ <72030708+Teda1@users.noreply.github.com> Date: Thu, 1 Jun 2023 20:13:28 -0500 Subject: [PATCH 71/81] Fixes video filter issues (#3792) --- .../SceneDetails/SceneVideoFilterPanel.tsx | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx index 5de8b045a..e547e750f 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneVideoFilterPanel.tsx @@ -108,15 +108,26 @@ export const SceneVideoFilterPanel: React.FC = ( aspectRatioRange.default ); - function updateVideoStyle() { - const playerVideoContainer = document.getElementById(VIDEO_PLAYER_ID); - const videoElements = - playerVideoContainer?.getElementsByTagName("canvas") ?? - playerVideoContainer?.getElementsByTagName("video") ?? - []; - const playerVideoElement = - videoElements.length > 0 ? videoElements[0] : null; + // eslint-disable-next-line + function getVideoElement(playerVideoContainer: any) { + let videoElements = playerVideoContainer.getElementsByTagName("canvas"); + if (videoElements.length == 0) { + videoElements = playerVideoContainer.getElementsByTagName("video"); + } + + if (videoElements.length > 0) { + return videoElements[0]; + } + } + + function updateVideoStyle() { + const playerVideoContainer = document.getElementById(VIDEO_PLAYER_ID)!; + if (!playerVideoContainer) { + return; + } + + const playerVideoElement = getVideoElement(playerVideoContainer); if (playerVideoElement != null) { let styleString = "filter:"; let style = playerVideoElement.attributes.getNamedItem("style"); @@ -188,6 +199,10 @@ export const SceneVideoFilterPanel: React.FC = ( styleString += ` scale(${xScale},${yScale})`; } + if (playerVideoElement.tagName == "CANVAS") { + styleString += "; width: 100%; height: 100%; position: absolute; top:0"; + } + style.value = `${styleString};`; } } From 4acf84322901bf5eba92f6d432c1eebee368619f Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Fri, 2 Jun 2023 03:15:33 +0200 Subject: [PATCH 72/81] Fix videojs-vr issues (#3793) * Add videojs-vr.d.ts * Improve dynamic VR toggling --- ui/v2.5/src/@types/videojs-vr.d.ts | 116 ++++++++++++++++++ .../components/ScenePlayer/ScenePlayer.tsx | 32 ++--- ui/v2.5/src/components/ScenePlayer/vrmode.ts | 58 +++++++-- 3 files changed, 181 insertions(+), 25 deletions(-) create mode 100644 ui/v2.5/src/@types/videojs-vr.d.ts diff --git a/ui/v2.5/src/@types/videojs-vr.d.ts b/ui/v2.5/src/@types/videojs-vr.d.ts new file mode 100644 index 000000000..54111718f --- /dev/null +++ b/ui/v2.5/src/@types/videojs-vr.d.ts @@ -0,0 +1,116 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +declare module "videojs-vr" { + import videojs from "video.js"; + + declare function videojsVR(options?: videojsVR.Options): videojsVR.Plugin; + + declare namespace videojsVR { + const VERSION: typeof videojs.VERSION; + + type ProjectionType = + // The video is half sphere and the user should not be able to look behind themselves + | "180" + // Used for side-by-side 180 videos The video is half sphere and the user should not be able to look behind themselves + | "180_LR" + // Used for monoscopic 180 videos The video is half sphere and the user should not be able to look behind themselves + | "180_MONO" + // The video is a sphere + | "360" + | "Sphere" + | "equirectangular" + // The video is a cube + | "360_CUBE" + | "Cube" + // This video is not a 360 video + | "NONE" + // Check player.mediainfo.projection to see if the current video is a 360 video. + | "AUTO" + // Used for side-by-side 360 videos + | "360_LR" + // Used for top-to-bottom 360 videos + | "360_TB" + // Used for Equi-Angular Cubemap videos + | "EAC" + // Used for side-by-side Equi-Angular Cubemap videos + | "EAC_LR"; + + interface Options { + /** + * Force the cardboard button to display on all devices even if we don't think they support it. + * + * @default false + */ + forceCardboard?: boolean; + + /** + * Whether motion/gyro controls should be enabled. + * + * @default true on iOS and Android + */ + motionControls?: boolean; + + /** + * Defines the projection type. + * + * @default "AUTO" + */ + projection?: ProjectionType; + + /** + * This alters the number of segments in the spherical mesh onto which equirectangular videos are projected. + * The default is 32 but in some circumstances you may notice artifacts and need to increase this number. + * + * @default 32 + */ + sphereDetail?: number; + + /** + * Enable debug logging for this plugin + * + * @default false + */ + debug?: boolean; + + /** + * Use this property to pass the Omnitone library object to the plugin. Please be aware of, the Omnitone library is not included in the build files. + */ + omnitone?: object; + + /** + * Default options for the Omnitone library. Please check available options on https://github.com/GoogleChrome/omnitone + */ + omnitoneOptions?: object; + + /** + * Feature to disable the togglePlay manually. This functionality is useful in live events so that users cannot stop the live, but still have a controlBar available. + * + * @default false + */ + disableTogglePlay?: boolean; + } + + interface PlayerMediaInfo { + /** + * This should be set on a source-by-source basis to turn 360 videos on an off depending upon the video. + * Note that AUTO is the same as NONE for player.mediainfo.projection. + */ + projection?: ProjectionType; + } + + class Plugin extends videojs.Plugin { + setProjection(projection: ProjectionType): void; + init(): void; + reset(): void; + } + } + + export = videojsVR; + + declare module "video.js" { + interface VideoJsPlayer { + vr: typeof videojsVR; + mediainfo?: videojsVR.PlayerMediaInfo; + } + } +} diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 0eef94528..b4699e454 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -267,16 +267,6 @@ export const ScenePlayer: React.FC = ({ // Initialize VideoJS player useEffect(() => { - function isVrScene() { - if (!scene?.id || !vrTag) return false; - - return scene?.tags.some((tag) => { - if (vrTag == tag.name) { - return true; - } - }); - } - const options: VideoJsPlayerOptions = { id: VIDEO_PLAYER_ID, controls: true, @@ -330,9 +320,7 @@ export const ScenePlayer: React.FC = ({ }, skipButtons: {}, trackActivity: {}, - vrMenu: { - showButton: isVrScene(), - }, + vrMenu: {}, }, }; @@ -364,7 +352,8 @@ export const ScenePlayer: React.FC = ({ // reset sceneId to force reload sources sceneId.current = undefined; }; - }, [scene, vrTag]); + // empty deps - only init once + }, []); useEffect(() => { const player = getPlayer(); @@ -388,6 +377,21 @@ export const ScenePlayer: React.FC = ({ scene?.paths.funscript, ]); + useEffect(() => { + const player = getPlayer(); + if (!player) return; + + const vrMenu = player.vrMenu(); + + let showButton = false; + + if (scene && vrTag) { + showButton = scene.tags.some((tag) => vrTag === tag.name); + } + + vrMenu.setShowButton(showButton); + }, [getPlayer, scene, vrTag]); + // Player event handlers useEffect(() => { const player = getPlayer(); diff --git a/ui/v2.5/src/components/ScenePlayer/vrmode.ts b/ui/v2.5/src/components/ScenePlayer/vrmode.ts index 93459ab86..b11be3364 100644 --- a/ui/v2.5/src/components/ScenePlayer/vrmode.ts +++ b/ui/v2.5/src/components/ScenePlayer/vrmode.ts @@ -1,6 +1,9 @@ /* eslint-disable @typescript-eslint/naming-convention */ import videojs, { VideoJsPlayer } from "video.js"; import "videojs-vr"; +// separate type import, otherwise typescript elides the above import +// and the plugin does not get initialized +import type { ProjectionType, Plugin as VideoJsVRPlugin } from "videojs-vr"; export interface VRMenuOptions { /** @@ -15,7 +18,7 @@ enum VRType { Off = "Off", } -const vrTypeProjection = { +const vrTypeProjection: Record = { [VRType.Spherical]: "360", [VRType.Off]: "NONE", }; @@ -29,7 +32,7 @@ class VRMenuItem extends videojs.getComponent("MenuItem") { public isSelected = false; constructor(parent: VRMenuButton, type: VRType) { - const options = {} as videojs.MenuItemOptions; + const options: videojs.MenuItemOptions = {}; options.selectable = true; options.multiSelectable = false; options.label = type; @@ -105,27 +108,61 @@ class VRMenuButton extends videojs.getComponent("MenuButton") { class VRMenuPlugin extends videojs.getPlugin("plugin") { private menu: VRMenuButton; + private showButton: boolean; + private vr?: VideoJsVRPlugin; constructor(player: VideoJsPlayer, options: VRMenuOptions) { super(player); this.menu = new VRMenuButton(player); + this.showButton = options.showButton ?? false; - if (isVrDevice() || !options.showButton) return; + if (isVrDevice()) return; + + this.vr = this.player.vr(); this.menu.on("typeselected", (_, type: VRType) => { - const projection = vrTypeProjection[type]; - player.vr({ projection }); - player.load(); + this.loadVR(type); }); player.on("ready", () => { - const { controlBar } = player; - const fullscreenToggle = controlBar.getChild("fullscreenToggle")!.el(); - controlBar.addChild(this.menu); - controlBar.el().insertBefore(this.menu.el(), fullscreenToggle); + if (this.showButton) { + this.addButton(); + } }); } + + private loadVR(type: VRType) { + const projection = vrTypeProjection[type]; + this.vr?.setProjection(projection); + this.vr?.init(); + } + + private addButton() { + const { controlBar } = this.player; + const fullscreenToggle = controlBar.getChild("fullscreenToggle")!.el(); + controlBar.addChild(this.menu); + controlBar.el().insertBefore(this.menu.el(), fullscreenToggle); + } + + private removeButton() { + const { controlBar } = this.player; + controlBar.removeChild(this.menu); + } + + public setShowButton(showButton: boolean) { + if (isVrDevice()) return; + + if (showButton === this.showButton) return; + + this.showButton = showButton; + if (showButton) { + this.addButton(); + } else { + this.removeButton(); + this.loadVR(VRType.Off); + } + } } // Register the plugin with video.js. @@ -136,7 +173,6 @@ videojs.registerPlugin("vrMenu", VRMenuPlugin); declare module "video.js" { interface VideoJsPlayer { vrMenu: () => VRMenuPlugin; - vr: (options: Object) => void; } interface VideoJsPlayerPluginOptions { vrMenu?: VRMenuOptions; From 256e0a11ea7d090999da2c377ed4fe77378b49dd Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 6 Jun 2023 13:01:50 +1000 Subject: [PATCH 73/81] Fix joined hierarchical filtering (#3775) * Fix joined hierarchical filtering * Fix scene performer tag filter * Generalise performer tag handler * Add unit tests * Add equals handling * Make performer tags equals/not equals unsupported * Make tags not equals unsupported * Make not equals unsupported for performers criterion * Support equals/not equals for studio criterion * Fix marker scene tags equals filter * Fix scene performer tag filter * Make equals/not equals unsupported for hierarchical criterion * Use existing studio handler in movie * Hide unsupported tag modifier options * Use existing performer tags logic where possible * Restore old parent/child filter logic * Disable sub-tags in equals modifier for tags criterion --- pkg/models/filter.go | 11 + pkg/sqlite/filter.go | 192 ++++- pkg/sqlite/gallery.go | 53 +- pkg/sqlite/gallery_test.go | 716 +++++++++++----- pkg/sqlite/image.go | 53 +- pkg/sqlite/image_test.go | 768 ++++++++++++------ pkg/sqlite/movies.go | 15 +- pkg/sqlite/performer.go | 6 +- pkg/sqlite/performer_test.go | 21 +- pkg/sqlite/scene.go | 53 +- pkg/sqlite/scene_marker.go | 41 +- pkg/sqlite/scene_marker_test.go | 112 ++- pkg/sqlite/scene_test.go | 648 +++++++++++---- pkg/sqlite/setup_test.go | 134 +-- pkg/sqlite/tag.go | 194 +++-- pkg/sqlite/tag_test.go | 8 +- .../List/Filters/SelectableFilter.tsx | 20 +- .../models/list-filter/criteria/criterion.ts | 7 +- .../src/models/list-filter/criteria/tags.ts | 39 +- 19 files changed, 2153 insertions(+), 938 deletions(-) diff --git a/pkg/models/filter.go b/pkg/models/filter.go index e0f9b7a54..e9ddf7ab3 100644 --- a/pkg/models/filter.go +++ b/pkg/models/filter.go @@ -135,6 +135,17 @@ type HierarchicalMultiCriterionInput struct { Excludes []string `json:"excludes"` } +func (i HierarchicalMultiCriterionInput) CombineExcludes() HierarchicalMultiCriterionInput { + ii := i + if ii.Modifier == CriterionModifierExcludes { + ii.Modifier = CriterionModifierIncludesAll + ii.Excludes = append(ii.Excludes, ii.Value...) + ii.Value = nil + } + + return ii +} + type MultiCriterionInput struct { Value []string `json:"value"` Modifier CriterionModifier `json:"modifier"` diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index d670dc1a7..5934b2c99 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -9,7 +9,6 @@ import ( "strconv" "strings" - "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/utils" "github.com/stashapp/stash/pkg/models" @@ -694,6 +693,8 @@ func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInp }) havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) args = append(args, len(criterion.Value)) + case models.CriterionModifierNotEquals: + f.setError(fmt.Errorf("not equals modifier is not supported for multi criterion input")) case models.CriterionModifierIncludesAll: // includes all of the provided ids m.addJoinTable(f) @@ -830,6 +831,33 @@ func (m *stringListCriterionHandlerBuilder) handler(criterion *models.StringCrit } } +func studioCriterionHandler(primaryTable string, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if studios == nil { + return + } + + studiosCopy := *studios + switch studiosCopy.Modifier { + case models.CriterionModifierEquals: + studiosCopy.Modifier = models.CriterionModifierIncludesAll + case models.CriterionModifierNotEquals: + studiosCopy.Modifier = models.CriterionModifierExcludes + } + + hh := hierarchicalMultiCriterionHandlerBuilder{ + tx: dbWrapper{}, + + primaryTable: primaryTable, + foreignTable: studioTable, + foreignFK: studioIDColumn, + parentFK: "parent_id", + } + + hh.handler(&studiosCopy)(ctx, f) + } +} + type hierarchicalMultiCriterionHandlerBuilder struct { tx dbWrapper @@ -838,12 +866,20 @@ type hierarchicalMultiCriterionHandlerBuilder struct { foreignFK string parentFK string + childFK string relationsTable string } -func getHierarchicalValues(ctx context.Context, tx dbWrapper, values []string, table, relationsTable, parentFK string, depth *int) string { +func getHierarchicalValues(ctx context.Context, tx dbWrapper, values []string, table, relationsTable, parentFK string, childFK string, depth *int) (string, error) { var args []interface{} + if parentFK == "" { + parentFK = "parent_id" + } + if childFK == "" { + childFK = "child_id" + } + depthVal := 0 if depth != nil { depthVal = *depth @@ -865,7 +901,7 @@ func getHierarchicalValues(ctx context.Context, tx dbWrapper, values []string, t } if valid { - return "VALUES" + strings.Join(valuesClauses, ",") + return "VALUES" + strings.Join(valuesClauses, ","), nil } } @@ -885,13 +921,14 @@ func getHierarchicalValues(ctx context.Context, tx dbWrapper, values []string, t "inBinding": getInBinding(inCount), "recursiveSelect": "", "parentFK": parentFK, + "childFK": childFK, "depthCondition": depthCondition, "unionClause": "", } if relationsTable != "" { - withClauseMap["recursiveSelect"] = utils.StrFormat(`SELECT p.root_id, c.child_id, depth + 1 FROM {relationsTable} AS c -INNER JOIN items as p ON c.parent_id = p.item_id + withClauseMap["recursiveSelect"] = utils.StrFormat(`SELECT p.root_id, c.{childFK}, depth + 1 FROM {relationsTable} AS c +INNER JOIN items as p ON c.{parentFK} = p.item_id `, withClauseMap) } else { withClauseMap["recursiveSelect"] = utils.StrFormat(`SELECT p.root_id, c.id, depth + 1 FROM {table} as c @@ -916,12 +953,10 @@ WHERE id in {inBinding} var valuesClause string err := tx.Get(ctx, &valuesClause, query, args...) if err != nil { - logger.Error(err) - // return record which never matches so we don't have to handle error here - return "VALUES(NULL, NULL)" + return "", fmt.Errorf("failed to get hierarchical values: %w", err) } - return valuesClause + return valuesClause, nil } func addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { @@ -942,6 +977,12 @@ func (m *hierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hierarchica // make a copy so we don't modify the original criterion := *c + // don't support equals/not equals + if criterion.Modifier == models.CriterionModifierEquals || criterion.Modifier == models.CriterionModifierNotEquals { + f.setError(fmt.Errorf("modifier %s is not supported for hierarchical multi criterion", criterion.Modifier)) + return + } + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { var notClause string if criterion.Modifier == models.CriterionModifierNotNull { @@ -968,7 +1009,11 @@ func (m *hierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hierarchica } if len(criterion.Value) > 0 { - valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + valuesClause, err := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) + if err != nil { + f.setError(err) + return + } switch criterion.Modifier { case models.CriterionModifierIncludes: @@ -980,7 +1025,11 @@ func (m *hierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hierarchica } if len(criterion.Excludes) > 0 { - valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + valuesClause, err := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) + if err != nil { + f.setError(err) + return + } f.addWhere(fmt.Sprintf("%s.%s NOT IN (SELECT column2 FROM (%s)) OR %[1]s.%[2]s IS NULL", m.primaryTable, m.foreignFK, valuesClause)) } @@ -992,10 +1041,12 @@ type joinedHierarchicalMultiCriterionHandlerBuilder struct { tx dbWrapper primaryTable string + primaryKey string foreignTable string foreignFK string parentFK string + childFK string relationsTable string joinAs string @@ -1004,16 +1055,25 @@ type joinedHierarchicalMultiCriterionHandlerBuilder struct { } func (m *joinedHierarchicalMultiCriterionHandlerBuilder) addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { - if criterion.Modifier == models.CriterionModifierEquals { + primaryKey := m.primaryKey + if primaryKey == "" { + primaryKey = "id" + } + + switch criterion.Modifier { + case models.CriterionModifierEquals: // includes only the provided ids f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", table, idColumn, len(criterion.Value))) - f.addWhere(utils.StrFormat("(SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.id) = ?", utils.StrFormatMap{ + f.addWhere(utils.StrFormat("(SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.{primaryKey}) = ?", utils.StrFormatMap{ "joinTable": m.joinTable, "primaryFK": m.primaryFK, "primaryTable": m.primaryTable, + "primaryKey": primaryKey, }), len(criterion.Value)) - } else { + case models.CriterionModifierNotEquals: + f.setError(fmt.Errorf("not equals modifier is not supported for hierarchical multi criterion input")) + default: addHierarchicalConditionClauses(f, criterion, table, idColumn) } } @@ -1024,6 +1084,15 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hiera // make a copy so we don't modify the original criterion := *c joinAlias := m.joinAs + primaryKey := m.primaryKey + if primaryKey == "" { + primaryKey = "id" + } + + if criterion.Modifier == models.CriterionModifierEquals && criterion.Depth != nil && *criterion.Depth != 0 { + f.setError(fmt.Errorf("depth is not supported for equals modifier in hierarchical multi criterion input")) + return + } if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { var notClause string @@ -1031,7 +1100,7 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hiera notClause = "NOT" } - f.addLeftJoin(m.joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.id", joinAlias, m.primaryFK, m.primaryTable)) + f.addLeftJoin(m.joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.%s", joinAlias, m.primaryFK, m.primaryTable, primaryKey)) f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ "table": joinAlias, @@ -1053,7 +1122,11 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hiera } if len(criterion.Value) > 0 { - valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + valuesClause, err := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) + if err != nil { + f.setError(err) + return + } joinTable := utils.StrFormat(`( SELECT j.*, d.column1 AS root_id, d.column2 AS item_id FROM {joinTable} AS j @@ -1065,13 +1138,17 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hiera "valuesClause": valuesClause, }) - f.addLeftJoin(joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.id", joinAlias, m.primaryFK, m.primaryTable)) + f.addLeftJoin(joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.%s", joinAlias, m.primaryFK, m.primaryTable, primaryKey)) m.addHierarchicalConditionClauses(f, criterion, joinAlias, "root_id") } if len(criterion.Excludes) > 0 { - valuesClause := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, criterion.Depth) + valuesClause, err := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) + if err != nil { + f.setError(err) + return + } joinTable := utils.StrFormat(`( SELECT j2.*, e.column1 AS root_id, e.column2 AS item_id FROM {joinTable} AS j2 @@ -1085,7 +1162,7 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hiera joinAlias2 := joinAlias + "2" - f.addLeftJoin(joinTable, joinAlias2, fmt.Sprintf("%s.%s = %s.id", joinAlias2, m.primaryFK, m.primaryTable)) + f.addLeftJoin(joinTable, joinAlias2, fmt.Sprintf("%s.%s = %s.%s", joinAlias2, m.primaryFK, m.primaryTable, primaryKey)) // modify for exclusion criterionCopy := criterion @@ -1098,6 +1175,83 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.Hiera } } +type joinedPerformerTagsHandler struct { + criterion *models.HierarchicalMultiCriterionInput + + primaryTable string // eg scenes + joinTable string // eg performers_scenes + joinPrimaryKey string // eg scene_id +} + +func (h *joinedPerformerTagsHandler) handle(ctx context.Context, f *filterBuilder) { + tags := h.criterion + + if tags != nil { + criterion := tags.CombineExcludes() + + // validate the modifier + switch criterion.Modifier { + case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: + // valid + default: + f.setError(fmt.Errorf("invalid modifier %s for performer tags", criterion.Modifier)) + } + + strFormatMap := utils.StrFormatMap{ + "primaryTable": h.primaryTable, + "joinTable": h.joinTable, + "joinPrimaryKey": h.joinPrimaryKey, + "inBinding": getInBinding(len(criterion.Value)), + } + + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { + var notClause string + if criterion.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addLeftJoin(h.joinTable, "", utils.StrFormat("{primaryTable}.id = {joinTable}.{joinPrimaryKey}", strFormatMap)) + f.addLeftJoin("performers_tags", "", utils.StrFormat("{joinTable}.performer_id = performers_tags.performer_id", strFormatMap)) + + f.addWhere(fmt.Sprintf("performers_tags.tag_id IS %s NULL", notClause)) + return + } + + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { + return + } + + if len(criterion.Value) > 0 { + valuesClause, err := getHierarchicalValues(ctx, dbWrapper{}, criterion.Value, tagTable, "tags_relations", "", "", criterion.Depth) + if err != nil { + f.setError(err) + return + } + + f.addWith(utils.StrFormat(`performer_tags AS ( +SELECT ps.{joinPrimaryKey} as primaryID, t.column1 AS root_tag_id FROM {joinTable} ps +INNER JOIN performers_tags pt ON pt.performer_id = ps.performer_id +INNER JOIN (`+valuesClause+`) t ON t.column2 = pt.tag_id +)`, strFormatMap)) + + f.addLeftJoin("performer_tags", "", utils.StrFormat("performer_tags.primaryID = {primaryTable}.id", strFormatMap)) + + addHierarchicalConditionClauses(f, criterion, "performer_tags", "root_tag_id") + } + + if len(criterion.Excludes) > 0 { + valuesClause, err := getHierarchicalValues(ctx, dbWrapper{}, criterion.Excludes, tagTable, "tags_relations", "", "", criterion.Depth) + if err != nil { + f.setError(err) + return + } + + clause := utils.StrFormat("{primaryTable}.id NOT IN (SELECT {joinTable}.{joinPrimaryKey} FROM {joinTable} INNER JOIN performers_tags ON {joinTable}.performer_id = performers_tags.performer_id WHERE performers_tags.tag_id IN (SELECT column2 FROM (%s)))", strFormatMap) + f.addWhere(fmt.Sprintf(clause, valuesClause)) + } + } +} + type stashIDCriterionHandler struct { c *models.StashIDCriterionInput stashIDRepository *stashIDRepository diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 5f5291053..2e857cc34 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -670,7 +670,7 @@ func (qb *GalleryStore) makeFilter(ctx context.Context, galleryFilter *models.Ga query.handleCriterion(ctx, galleryPerformersCriterionHandler(qb, galleryFilter.Performers)) query.handleCriterion(ctx, galleryPerformerCountCriterionHandler(qb, galleryFilter.PerformerCount)) query.handleCriterion(ctx, hasChaptersCriterionHandler(galleryFilter.HasChapters)) - query.handleCriterion(ctx, galleryStudioCriterionHandler(qb, galleryFilter.Studios)) + query.handleCriterion(ctx, studioCriterionHandler(galleryTable, galleryFilter.Studios)) query.handleCriterion(ctx, galleryPerformerTagsCriterionHandler(qb, galleryFilter.PerformerTags)) query.handleCriterion(ctx, galleryAverageResolutionCriterionHandler(qb, galleryFilter.AverageResolution)) query.handleCriterion(ctx, galleryImageCountCriterionHandler(qb, galleryFilter.ImageCount)) @@ -968,51 +968,12 @@ func hasChaptersCriterionHandler(hasChapters *string) criterionHandlerFunc { } } -func galleryStudioCriterionHandler(qb *GalleryStore, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - h := hierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: galleryTable, - foreignTable: studioTable, - foreignFK: studioIDColumn, - parentFK: "parent_id", - } - - return h.handler(studios) -} - -func galleryPerformerTagsCriterionHandler(qb *GalleryStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if tags != nil { - if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { - var notClause string - if tags.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin("performers_galleries", "", "galleries.id = performers_galleries.gallery_id") - f.addLeftJoin("performers_tags", "", "performers_galleries.performer_id = performers_tags.performer_id") - - f.addWhere(fmt.Sprintf("performers_tags.tag_id IS %s NULL", notClause)) - return - } - - if len(tags.Value) == 0 { - return - } - - valuesClause := getHierarchicalValues(ctx, qb.tx, tags.Value, tagTable, "tags_relations", "", tags.Depth) - - f.addWith(`performer_tags AS ( -SELECT pg.gallery_id, t.column1 AS root_tag_id FROM performers_galleries pg -INNER JOIN performers_tags pt ON pt.performer_id = pg.performer_id -INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id -)`) - - f.addLeftJoin("performer_tags", "", "performer_tags.gallery_id = galleries.id") - - addHierarchicalConditionClauses(f, *tags, "performer_tags", "root_tag_id") - } +func galleryPerformerTagsCriterionHandler(qb *GalleryStore, tags *models.HierarchicalMultiCriterionInput) criterionHandler { + return &joinedPerformerTagsHandler{ + criterion: tags, + primaryTable: galleryTable, + joinTable: performersGalleriesTable, + joinPrimaryKey: galleryIDColumn, } } diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index 6d145cb1b..bad75d035 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -1945,154 +1945,369 @@ func TestGalleryQueryIsMissingDate(t *testing.T) { } func TestGalleryQueryPerformers(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Gallery - performerCriterion := models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdxWithGallery]), - strconv.Itoa(performerIDs[performerIdx1WithGallery]), + tests := []struct { + name string + filter models.MultiCriterionInput + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(performerIDs[performerIdxWithGallery]), + strconv.Itoa(performerIDs[performerIdx1WithGallery]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - galleryFilter := models.GalleryFilterType{ - Performers: &performerCriterion, - } - - galleries := queryGallery(ctx, t, sqb, &galleryFilter, nil) - - assert.Len(t, galleries, 2) - - // ensure ids are correct - for _, gallery := range galleries { - assert.True(t, gallery.ID == galleryIDs[galleryIdxWithPerformer] || gallery.ID == galleryIDs[galleryIdxWithTwoPerformers]) - } - - performerCriterion = models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdx1WithGallery]), - strconv.Itoa(performerIDs[performerIdx2WithGallery]), + []int{ + galleryIdxWithPerformer, + galleryIdxWithTwoPerformers, }, - Modifier: models.CriterionModifierIncludesAll, - } - - galleries = queryGallery(ctx, t, sqb, &galleryFilter, nil) - - assert.Len(t, galleries, 1) - assert.Equal(t, galleryIDs[galleryIdxWithTwoPerformers], galleries[0].ID) - - performerCriterion = models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdx1WithGallery]), + []int{ + galleryIdxWithImage, }, - Modifier: models.CriterionModifierExcludes, - } + false, + }, + { + "includes all", + models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(performerIDs[performerIdx1WithGallery]), + strconv.Itoa(performerIDs[performerIdx2WithGallery]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + []int{ + galleryIdxWithTwoPerformers, + }, + []int{ + galleryIdxWithPerformer, + }, + false, + }, + { + "excludes", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[performerIdx1WithGallery])}, + }, + nil, + []int{galleryIdxWithTwoPerformers}, + false, + }, + { + "is null", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + []int{galleryIdxWithTag}, + []int{ + galleryIdxWithPerformer, + galleryIdxWithTwoPerformers, + galleryIdxWithPerformerTwoTags, + }, + false, + }, + { + "not null", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + []int{ + galleryIdxWithPerformer, + galleryIdxWithTwoPerformers, + galleryIdxWithPerformerTwoTags, + }, + []int{galleryIdxWithTag}, + false, + }, + { + "equals", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[performerIdx1WithGallery]), + strconv.Itoa(tagIDs[performerIdx2WithGallery]), + }, + }, + []int{galleryIdxWithTwoPerformers}, + []int{ + galleryIdxWithThreePerformers, + }, + false, + }, + { + "not equals", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[performerIdx1WithGallery]), + strconv.Itoa(tagIDs[performerIdx2WithGallery]), + }, + }, + nil, + nil, + true, + }, + } - q := getGalleryStringValue(galleryIdxWithTwoPerformers, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 0) + results, _, err := db.Gallery.Query(ctx, &models.GalleryFilterType{ + Performers: &tt.filter, + }, nil) + if (err != nil) != tt.wantErr { + t.Errorf("GalleryStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - return nil - }) + ids := galleriesToIDs(results) + + include := indexesToIDs(galleryIDs, tt.includeIdxs) + exclude := indexesToIDs(galleryIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } } func TestGalleryQueryTags(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Gallery - tagCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdxWithGallery]), - strconv.Itoa(tagIDs[tagIdx1WithGallery]), + tests := []struct { + name string + filter models.HierarchicalMultiCriterionInput + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithGallery]), + strconv.Itoa(tagIDs[tagIdx1WithGallery]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - galleryFilter := models.GalleryFilterType{ - Tags: &tagCriterion, - } - - galleries := queryGallery(ctx, t, sqb, &galleryFilter, nil) - assert.Len(t, galleries, 2) - - // ensure ids are correct - for _, gallery := range galleries { - assert.True(t, gallery.ID == galleryIDs[galleryIdxWithTag] || gallery.ID == galleryIDs[galleryIdxWithTwoTags]) - } - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithGallery]), - strconv.Itoa(tagIDs[tagIdx2WithGallery]), + []int{ + galleryIdxWithTag, + galleryIdxWithTwoTags, }, - Modifier: models.CriterionModifierIncludesAll, - } - - galleries = queryGallery(ctx, t, sqb, &galleryFilter, nil) - - assert.Len(t, galleries, 1) - assert.Equal(t, galleryIDs[galleryIdxWithTwoTags], galleries[0].ID) - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithGallery]), + []int{ + galleryIdxWithImage, }, - Modifier: models.CriterionModifierExcludes, - } + false, + }, + { + "includes all", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithGallery]), + strconv.Itoa(tagIDs[tagIdx2WithGallery]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + []int{ + galleryIdxWithTwoTags, + }, + []int{ + galleryIdxWithTag, + }, + false, + }, + { + "excludes", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[tagIdx1WithGallery])}, + }, + nil, + []int{galleryIdxWithTwoTags}, + false, + }, + { + "is null", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + []int{galleryIdx1WithPerformer}, + []int{ + galleryIdxWithTag, + galleryIdxWithTwoTags, + galleryIdxWithThreeTags, + }, + false, + }, + { + "not null", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + []int{ + galleryIdxWithTag, + galleryIdxWithTwoTags, + galleryIdxWithThreeTags, + }, + []int{galleryIdx1WithPerformer}, + false, + }, + { + "equals", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithGallery]), + strconv.Itoa(tagIDs[tagIdx2WithGallery]), + }, + }, + []int{galleryIdxWithTwoTags}, + []int{ + galleryIdxWithThreeTags, + }, + false, + }, + { + "not equals", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithGallery]), + strconv.Itoa(tagIDs[tagIdx2WithGallery]), + }, + }, + nil, + nil, + true, + }, + } - q := getGalleryStringValue(galleryIdxWithTwoTags, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 0) + results, _, err := db.Gallery.Query(ctx, &models.GalleryFilterType{ + Tags: &tt.filter, + }, nil) + if (err != nil) != tt.wantErr { + t.Errorf("GalleryStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - return nil - }) + ids := galleriesToIDs(results) + + include := indexesToIDs(imageIDs, tt.includeIdxs) + exclude := indexesToIDs(imageIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } } func TestGalleryQueryStudio(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Gallery - studioCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(studioIDs[studioIdxWithGallery]), + tests := []struct { + name string + q string + studioCriterion models.HierarchicalMultiCriterionInput + expectedIDs []int + wantErr bool + }{ + { + "includes", + "", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithGallery]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - galleryFilter := models.GalleryFilterType{ - Studios: &studioCriterion, - } - - galleries := queryGallery(ctx, t, sqb, &galleryFilter, nil) - - assert.Len(t, galleries, 1) - - // ensure id is correct - assert.Equal(t, galleryIDs[galleryIdxWithStudio], galleries[0].ID) - - studioCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(studioIDs[studioIdxWithGallery]), + []int{galleryIDs[galleryIdxWithStudio]}, + false, + }, + { + "excludes", + getGalleryStringValue(galleryIdxWithStudio, titleField), + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithGallery]), + }, + Modifier: models.CriterionModifierExcludes, }, - Modifier: models.CriterionModifierExcludes, - } + []int{}, + false, + }, + { + "excludes includes null", + getGalleryStringValue(galleryIdxWithImage, titleField), + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithGallery]), + }, + Modifier: models.CriterionModifierExcludes, + }, + []int{galleryIDs[galleryIdxWithImage]}, + false, + }, + { + "equals", + "", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithGallery]), + }, + Modifier: models.CriterionModifierEquals, + }, + []int{galleryIDs[galleryIdxWithStudio]}, + false, + }, + { + "not equals", + getGalleryStringValue(galleryIdxWithStudio, titleField), + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithGallery]), + }, + Modifier: models.CriterionModifierNotEquals, + }, + []int{}, + false, + }, + } - q := getGalleryStringValue(galleryIdxWithStudio, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + qb := db.Gallery - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 0) + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + studioCriterion := tt.studioCriterion - return nil - }) + galleryFilter := models.GalleryFilterType{ + Studios: &studioCriterion, + } + + var findFilter *models.FindFilterType + if tt.q != "" { + findFilter = &models.FindFilterType{ + Q: &tt.q, + } + } + + gallerys := queryGallery(ctx, t, qb, &galleryFilter, findFilter) + + assert.ElementsMatch(t, galleriesToIDs(gallerys), tt.expectedIDs) + }) + } } func TestGalleryQueryStudioDepth(t *testing.T) { @@ -2157,81 +2372,198 @@ func TestGalleryQueryStudioDepth(t *testing.T) { } func TestGalleryQueryPerformerTags(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Gallery - tagCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdxWithPerformer]), - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + allDepth := -1 + + tests := []struct { + name string + findFilter *models.FindFilterType + filter *models.GalleryFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithPerformer]), + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + }, + Modifier: models.CriterionModifierIncludes, + }, }, - Modifier: models.CriterionModifierIncludes, - } - - galleryFilter := models.GalleryFilterType{ - PerformerTags: &tagCriterion, - } - - galleries := queryGallery(ctx, t, sqb, &galleryFilter, nil) - assert.Len(t, galleries, 2) - - // ensure ids are correct - for _, gallery := range galleries { - assert.True(t, gallery.ID == galleryIDs[galleryIdxWithPerformerTag] || gallery.ID == galleryIDs[galleryIdxWithPerformerTwoTags]) - } - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), - strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + []int{ + galleryIdxWithPerformerTag, + galleryIdxWithPerformerTwoTags, + galleryIdxWithTwoPerformerTag, }, - Modifier: models.CriterionModifierIncludesAll, - } - - galleries = queryGallery(ctx, t, sqb, &galleryFilter, nil) - - assert.Len(t, galleries, 1) - assert.Equal(t, galleryIDs[galleryIdxWithPerformerTwoTags], galleries[0].ID) - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + []int{ + galleryIdxWithPerformer, }, - Modifier: models.CriterionModifierExcludes, - } + false, + }, + { + "includes sub-tags", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), + }, + Depth: &allDepth, + Modifier: models.CriterionModifierIncludes, + }, + }, + []int{ + galleryIdxWithPerformerParentTag, + }, + []int{ + galleryIdxWithPerformer, + galleryIdxWithPerformerTag, + galleryIdxWithPerformerTwoTags, + galleryIdxWithTwoPerformerTag, + }, + false, + }, + { + "includes all", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + }, + []int{ + galleryIdxWithPerformerTwoTags, + }, + []int{ + galleryIdxWithPerformer, + galleryIdxWithPerformerTag, + galleryIdxWithTwoPerformerTag, + }, + false, + }, + { + "excludes performer tag tagIdx2WithPerformer", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[tagIdx2WithPerformer])}, + }, + }, + nil, + []int{galleryIdxWithTwoPerformerTag}, + false, + }, + { + "excludes sub-tags", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), + }, + Depth: &allDepth, + Modifier: models.CriterionModifierExcludes, + }, + }, + []int{ + galleryIdxWithPerformer, + galleryIdxWithPerformerTag, + galleryIdxWithPerformerTwoTags, + galleryIdxWithTwoPerformerTag, + }, + []int{ + galleryIdxWithPerformerParentTag, + }, + false, + }, + { + "is null", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + }, + []int{galleryIdx1WithImage}, + []int{galleryIdxWithPerformerTag}, + false, + }, + { + "not null", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + }, + []int{galleryIdxWithPerformerTag}, + []int{galleryIdx1WithImage}, + false, + }, + { + "equals", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + }, + }, + nil, + nil, + true, + }, + { + "not equals", + nil, + &models.GalleryFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + }, + }, + nil, + nil, + true, + }, + } - q := getGalleryStringValue(galleryIdxWithPerformerTwoTags, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 0) + results, _, err := db.Gallery.Query(ctx, tt.filter, tt.findFilter) + if (err != nil) != tt.wantErr { + t.Errorf("ImageStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - tagCriterion = models.HierarchicalMultiCriterionInput{ - Modifier: models.CriterionModifierIsNull, - } - q = getGalleryStringValue(galleryIdx1WithImage, titleField) + ids := galleriesToIDs(results) - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 1) - assert.Equal(t, galleryIDs[galleryIdx1WithImage], galleries[0].ID) + include := indexesToIDs(galleryIDs, tt.includeIdxs) + exclude := indexesToIDs(galleryIDs, tt.excludeIdxs) - q = getGalleryStringValue(galleryIdxWithPerformerTag, titleField) - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 0) - - tagCriterion.Modifier = models.CriterionModifierNotNull - - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 1) - assert.Equal(t, galleryIDs[galleryIdxWithPerformerTag], galleries[0].ID) - - q = getGalleryStringValue(galleryIdx1WithImage, titleField) - galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter) - assert.Len(t, galleries, 0) - - return nil - }) + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } } func TestGalleryQueryTagCount(t *testing.T) { diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index d42de9f85..9dee5ed28 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -669,7 +669,7 @@ func (qb *ImageStore) makeFilter(ctx context.Context, imageFilter *models.ImageF query.handleCriterion(ctx, imageGalleriesCriterionHandler(qb, imageFilter.Galleries)) query.handleCriterion(ctx, imagePerformersCriterionHandler(qb, imageFilter.Performers)) query.handleCriterion(ctx, imagePerformerCountCriterionHandler(qb, imageFilter.PerformerCount)) - query.handleCriterion(ctx, imageStudioCriterionHandler(qb, imageFilter.Studios)) + query.handleCriterion(ctx, studioCriterionHandler(imageTable, imageFilter.Studios)) query.handleCriterion(ctx, imagePerformerTagsCriterionHandler(qb, imageFilter.PerformerTags)) query.handleCriterion(ctx, imagePerformerFavoriteCriterionHandler(imageFilter.PerformerFavorite)) query.handleCriterion(ctx, timestampCriterionHandler(imageFilter.CreatedAt, "images.created_at")) @@ -946,51 +946,12 @@ GROUP BY performers_images.image_id HAVING SUM(performers.favorite) = 0)`, "nofa } } -func imageStudioCriterionHandler(qb *ImageStore, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - h := hierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: imageTable, - foreignTable: studioTable, - foreignFK: studioIDColumn, - parentFK: "parent_id", - } - - return h.handler(studios) -} - -func imagePerformerTagsCriterionHandler(qb *ImageStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if tags != nil { - if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { - var notClause string - if tags.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin("performers_images", "", "images.id = performers_images.image_id") - f.addLeftJoin("performers_tags", "", "performers_images.performer_id = performers_tags.performer_id") - - f.addWhere(fmt.Sprintf("performers_tags.tag_id IS %s NULL", notClause)) - return - } - - if len(tags.Value) == 0 { - return - } - - valuesClause := getHierarchicalValues(ctx, qb.tx, tags.Value, tagTable, "tags_relations", "", tags.Depth) - - f.addWith(`performer_tags AS ( -SELECT pi.image_id, t.column1 AS root_tag_id FROM performers_images pi -INNER JOIN performers_tags pt ON pt.performer_id = pi.performer_id -INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id -)`) - - f.addLeftJoin("performer_tags", "", "performer_tags.image_id = images.id") - - addHierarchicalConditionClauses(f, *tags, "performer_tags", "root_tag_id") - } +func imagePerformerTagsCriterionHandler(qb *ImageStore, tags *models.HierarchicalMultiCriterionInput) criterionHandler { + return &joinedPerformerTagsHandler{ + criterion: tags, + primaryTable: imageTable, + joinTable: performersImagesTable, + joinPrimaryKey: imageIDColumn, } } diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index 1a0fceb29..3ec159877 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -2124,203 +2124,369 @@ func TestImageQueryGallery(t *testing.T) { } func TestImageQueryPerformers(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Image - performerCriterion := models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdxWithImage]), - strconv.Itoa(performerIDs[performerIdx1WithImage]), + tests := []struct { + name string + filter models.MultiCriterionInput + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(performerIDs[performerIdxWithImage]), + strconv.Itoa(performerIDs[performerIdx1WithImage]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - imageFilter := models.ImageFilterType{ - Performers: &performerCriterion, - } - - images := queryImages(ctx, t, sqb, &imageFilter, nil) - assert.Len(t, images, 2) - - // ensure ids are correct - for _, image := range images { - assert.True(t, image.ID == imageIDs[imageIdxWithPerformer] || image.ID == imageIDs[imageIdxWithTwoPerformers]) - } - - performerCriterion = models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdx1WithImage]), - strconv.Itoa(performerIDs[performerIdx2WithImage]), + []int{ + imageIdxWithPerformer, + imageIdxWithTwoPerformers, }, - Modifier: models.CriterionModifierIncludesAll, - } - - images = queryImages(ctx, t, sqb, &imageFilter, nil) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithTwoPerformers], images[0].ID) - - performerCriterion = models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdx1WithImage]), + []int{ + imageIdxWithGallery, }, - Modifier: models.CriterionModifierExcludes, - } + false, + }, + { + "includes all", + models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(performerIDs[performerIdx1WithImage]), + strconv.Itoa(performerIDs[performerIdx2WithImage]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + []int{ + imageIdxWithTwoPerformers, + }, + []int{ + imageIdxWithPerformer, + }, + false, + }, + { + "excludes", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[performerIdx1WithImage])}, + }, + nil, + []int{imageIdxWithTwoPerformers}, + false, + }, + { + "is null", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + []int{imageIdxWithTag}, + []int{ + imageIdxWithPerformer, + imageIdxWithTwoPerformers, + imageIdxWithPerformerTwoTags, + }, + false, + }, + { + "not null", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + []int{ + imageIdxWithPerformer, + imageIdxWithTwoPerformers, + imageIdxWithPerformerTwoTags, + }, + []int{imageIdxWithTag}, + false, + }, + { + "equals", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[performerIdx1WithImage]), + strconv.Itoa(tagIDs[performerIdx2WithImage]), + }, + }, + []int{imageIdxWithTwoPerformers}, + []int{ + imageIdxWithThreePerformers, + }, + false, + }, + { + "not equals", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[performerIdx1WithImage]), + strconv.Itoa(tagIDs[performerIdx2WithImage]), + }, + }, + nil, + nil, + true, + }, + } - q := getImageStringValue(imageIdxWithTwoPerformers, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) + results, err := db.Image.Query(ctx, models.ImageQueryOptions{ + ImageFilter: &models.ImageFilterType{ + Performers: &tt.filter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("ImageStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - performerCriterion = models.MultiCriterionInput{ - Modifier: models.CriterionModifierIsNull, - } - q = getImageStringValue(imageIdxWithGallery, titleField) + include := indexesToIDs(imageIDs, tt.includeIdxs) + exclude := indexesToIDs(imageIDs, tt.excludeIdxs) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithGallery], images[0].ID) - - q = getImageStringValue(imageIdxWithPerformerTag, titleField) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) - - performerCriterion.Modifier = models.CriterionModifierNotNull - - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithPerformerTag], images[0].ID) - - q = getImageStringValue(imageIdxWithGallery, titleField) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) - - return nil - }) + for _, i := range include { + assert.Contains(results.IDs, i) + } + for _, e := range exclude { + assert.NotContains(results.IDs, e) + } + }) + } } func TestImageQueryTags(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Image - tagCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdxWithImage]), - strconv.Itoa(tagIDs[tagIdx1WithImage]), + tests := []struct { + name string + filter models.HierarchicalMultiCriterionInput + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithImage]), + strconv.Itoa(tagIDs[tagIdx1WithImage]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - imageFilter := models.ImageFilterType{ - Tags: &tagCriterion, - } - - images := queryImages(ctx, t, sqb, &imageFilter, nil) - assert.Len(t, images, 2) - - // ensure ids are correct - for _, image := range images { - assert.True(t, image.ID == imageIDs[imageIdxWithTag] || image.ID == imageIDs[imageIdxWithTwoTags]) - } - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithImage]), - strconv.Itoa(tagIDs[tagIdx2WithImage]), + []int{ + imageIdxWithTag, + imageIdxWithTwoTags, }, - Modifier: models.CriterionModifierIncludesAll, - } - - images = queryImages(ctx, t, sqb, &imageFilter, nil) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithTwoTags], images[0].ID) - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithImage]), + []int{ + imageIdxWithGallery, }, - Modifier: models.CriterionModifierExcludes, - } + false, + }, + { + "includes all", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithImage]), + strconv.Itoa(tagIDs[tagIdx2WithImage]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + []int{ + imageIdxWithTwoTags, + }, + []int{ + imageIdxWithTag, + }, + false, + }, + { + "excludes", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[tagIdx1WithImage])}, + }, + nil, + []int{imageIdxWithTwoTags}, + false, + }, + { + "is null", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + []int{imageIdx1WithPerformer}, + []int{ + imageIdxWithTag, + imageIdxWithTwoTags, + imageIdxWithThreeTags, + }, + false, + }, + { + "not null", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + []int{ + imageIdxWithTag, + imageIdxWithTwoTags, + imageIdxWithThreeTags, + }, + []int{imageIdx1WithPerformer}, + false, + }, + { + "equals", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithImage]), + strconv.Itoa(tagIDs[tagIdx2WithImage]), + }, + }, + []int{imageIdxWithTwoTags}, + []int{ + imageIdxWithThreeTags, + }, + false, + }, + { + "not equals", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithImage]), + strconv.Itoa(tagIDs[tagIdx2WithImage]), + }, + }, + nil, + nil, + true, + }, + } - q := getImageStringValue(imageIdxWithTwoTags, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) + results, err := db.Image.Query(ctx, models.ImageQueryOptions{ + ImageFilter: &models.ImageFilterType{ + Tags: &tt.filter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("ImageStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - tagCriterion = models.HierarchicalMultiCriterionInput{ - Modifier: models.CriterionModifierIsNull, - } - q = getImageStringValue(imageIdxWithGallery, titleField) + include := indexesToIDs(imageIDs, tt.includeIdxs) + exclude := indexesToIDs(imageIDs, tt.excludeIdxs) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithGallery], images[0].ID) - - q = getImageStringValue(imageIdxWithTag, titleField) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) - - tagCriterion.Modifier = models.CriterionModifierNotNull - - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithTag], images[0].ID) - - q = getImageStringValue(imageIdxWithGallery, titleField) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) - - return nil - }) + for _, i := range include { + assert.Contains(results.IDs, i) + } + for _, e := range exclude { + assert.NotContains(results.IDs, e) + } + }) + } } func TestImageQueryStudio(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Image - studioCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(studioIDs[studioIdxWithImage]), + tests := []struct { + name string + q string + studioCriterion models.HierarchicalMultiCriterionInput + expectedIDs []int + wantErr bool + }{ + { + "includes", + "", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithImage]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - imageFilter := models.ImageFilterType{ - Studios: &studioCriterion, - } - - images, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, nil) - if err != nil { - t.Errorf("Error querying image: %s", err.Error()) - } - - assert.Len(t, images, 1) - - // ensure id is correct - assert.Equal(t, imageIDs[imageIdxWithStudio], images[0].ID) - - studioCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(studioIDs[studioIdxWithImage]), + []int{imageIDs[imageIdxWithStudio]}, + false, + }, + { + "excludes", + getImageStringValue(imageIdxWithStudio, titleField), + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithImage]), + }, + Modifier: models.CriterionModifierExcludes, }, - Modifier: models.CriterionModifierExcludes, - } + []int{}, + false, + }, + { + "excludes includes null", + getImageStringValue(imageIdxWithGallery, titleField), + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithImage]), + }, + Modifier: models.CriterionModifierExcludes, + }, + []int{imageIDs[imageIdxWithGallery]}, + false, + }, + { + "equals", + "", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithImage]), + }, + Modifier: models.CriterionModifierEquals, + }, + []int{imageIDs[imageIdxWithStudio]}, + false, + }, + { + "not equals", + getImageStringValue(imageIdxWithStudio, titleField), + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithImage]), + }, + Modifier: models.CriterionModifierNotEquals, + }, + []int{}, + false, + }, + } - q := getImageStringValue(imageIdxWithStudio, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + qb := db.Image - images, _, err = queryImagesWithCount(ctx, sqb, &imageFilter, &findFilter) - if err != nil { - t.Errorf("Error querying image: %s", err.Error()) - } - assert.Len(t, images, 0) + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + studioCriterion := tt.studioCriterion - return nil - }) + imageFilter := models.ImageFilterType{ + Studios: &studioCriterion, + } + + var findFilter *models.FindFilterType + if tt.q != "" { + findFilter = &models.FindFilterType{ + Q: &tt.q, + } + } + + images := queryImages(ctx, t, qb, &imageFilter, findFilter) + + assert.ElementsMatch(t, imagesToIDs(images), tt.expectedIDs) + }) + } } func TestImageQueryStudioDepth(t *testing.T) { @@ -2394,81 +2560,201 @@ func queryImages(ctx context.Context, t *testing.T, sqb models.ImageReader, imag } func TestImageQueryPerformerTags(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Image - tagCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdxWithPerformer]), - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + allDepth := -1 + + tests := []struct { + name string + findFilter *models.FindFilterType + filter *models.ImageFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithPerformer]), + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + }, + Modifier: models.CriterionModifierIncludes, + }, }, - Modifier: models.CriterionModifierIncludes, - } - - imageFilter := models.ImageFilterType{ - PerformerTags: &tagCriterion, - } - - images := queryImages(ctx, t, sqb, &imageFilter, nil) - assert.Len(t, images, 2) - - // ensure ids are correct - for _, image := range images { - assert.True(t, image.ID == imageIDs[imageIdxWithPerformerTag] || image.ID == imageIDs[imageIdxWithPerformerTwoTags]) - } - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), - strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + []int{ + imageIdxWithPerformerTag, + imageIdxWithPerformerTwoTags, + imageIdxWithTwoPerformerTag, }, - Modifier: models.CriterionModifierIncludesAll, - } - - images = queryImages(ctx, t, sqb, &imageFilter, nil) - - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithPerformerTwoTags], images[0].ID) - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + []int{ + imageIdxWithPerformer, }, - Modifier: models.CriterionModifierExcludes, - } + false, + }, + { + "includes sub-tags", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), + }, + Depth: &allDepth, + Modifier: models.CriterionModifierIncludes, + }, + }, + []int{ + imageIdxWithPerformerParentTag, + }, + []int{ + imageIdxWithPerformer, + imageIdxWithPerformerTag, + imageIdxWithPerformerTwoTags, + imageIdxWithTwoPerformerTag, + }, + false, + }, + { + "includes all", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + }, + []int{ + imageIdxWithPerformerTwoTags, + }, + []int{ + imageIdxWithPerformer, + imageIdxWithPerformerTag, + imageIdxWithTwoPerformerTag, + }, + false, + }, + { + "excludes performer tag tagIdx2WithPerformer", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[tagIdx2WithPerformer])}, + }, + }, + nil, + []int{imageIdxWithTwoPerformerTag}, + false, + }, + { + "excludes sub-tags", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), + }, + Depth: &allDepth, + Modifier: models.CriterionModifierExcludes, + }, + }, + []int{ + imageIdxWithPerformer, + imageIdxWithPerformerTag, + imageIdxWithPerformerTwoTags, + imageIdxWithTwoPerformerTag, + }, + []int{ + imageIdxWithPerformerParentTag, + }, + false, + }, + { + "is null", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + }, + []int{imageIdxWithGallery}, + []int{imageIdxWithPerformerTag}, + false, + }, + { + "not null", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + }, + []int{imageIdxWithPerformerTag}, + []int{imageIdxWithGallery}, + false, + }, + { + "equals", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + }, + }, + nil, + nil, + true, + }, + { + "not equals", + nil, + &models.ImageFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + }, + }, + nil, + nil, + true, + }, + } - q := getImageStringValue(imageIdxWithPerformerTwoTags, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) + results, err := db.Image.Query(ctx, models.ImageQueryOptions{ + ImageFilter: tt.filter, + QueryOptions: models.QueryOptions{ + FindFilter: tt.findFilter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("ImageStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - tagCriterion = models.HierarchicalMultiCriterionInput{ - Modifier: models.CriterionModifierIsNull, - } - q = getImageStringValue(imageIdxWithGallery, titleField) + include := indexesToIDs(imageIDs, tt.includeIdxs) + exclude := indexesToIDs(imageIDs, tt.excludeIdxs) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithGallery], images[0].ID) - - q = getImageStringValue(imageIdxWithPerformerTag, titleField) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) - - tagCriterion.Modifier = models.CriterionModifierNotNull - - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 1) - assert.Equal(t, imageIDs[imageIdxWithPerformerTag], images[0].ID) - - q = getImageStringValue(imageIdxWithGallery, titleField) - images = queryImages(ctx, t, sqb, &imageFilter, &findFilter) - assert.Len(t, images, 0) - - return nil - }) + for _, i := range include { + assert.Contains(results.IDs, i) + } + for _, e := range exclude { + assert.NotContains(results.IDs, e) + } + }) + } } func TestImageQueryTagCount(t *testing.T) { @@ -2587,7 +2873,7 @@ func TestImageQuerySorting(t *testing.T) { "date", models.SortDirectionEnumDesc, imageIdxWithTwoGalleries, - imageIdxWithGrandChildStudio, + imageIdxWithPerformerParentTag, }, } diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index 7ff13c2e3..3bc273cbf 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -176,7 +176,7 @@ func (qb *movieQueryBuilder) makeFilter(ctx context.Context, movieFilter *models query.handleCriterion(ctx, floatIntCriterionHandler(movieFilter.Duration, "movies.duration", nil)) query.handleCriterion(ctx, movieIsMissingCriterionHandler(qb, movieFilter.IsMissing)) query.handleCriterion(ctx, stringCriterionHandler(movieFilter.URL, "movies.url")) - query.handleCriterion(ctx, movieStudioCriterionHandler(qb, movieFilter.Studios)) + query.handleCriterion(ctx, studioCriterionHandler(movieTable, movieFilter.Studios)) query.handleCriterion(ctx, moviePerformersCriterionHandler(qb, movieFilter.Performers)) query.handleCriterion(ctx, dateCriterionHandler(movieFilter.Date, "movies.date")) query.handleCriterion(ctx, timestampCriterionHandler(movieFilter.CreatedAt, "movies.created_at")) @@ -239,19 +239,6 @@ func movieIsMissingCriterionHandler(qb *movieQueryBuilder, isMissing *string) cr } } -func movieStudioCriterionHandler(qb *movieQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - h := hierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: movieTable, - foreignTable: studioTable, - foreignFK: studioIDColumn, - parentFK: "parent_id", - } - - return h.handler(studios) -} - func moviePerformersCriterionHandler(qb *movieQueryBuilder, performers *models.MultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if performers != nil { diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index d1079eac0..f4f11e684 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -908,7 +908,11 @@ func performerStudiosCriterionHandler(qb *PerformerStore, studios *models.Hierar } const derivedPerformerStudioTable = "performer_studio" - valuesClause := getHierarchicalValues(ctx, qb.tx, studios.Value, studioTable, "", "parent_id", studios.Depth) + valuesClause, err := getHierarchicalValues(ctx, qb.tx, studios.Value, studioTable, "", "parent_id", "child_id", studios.Depth) + if err != nil { + f.setError(err) + return + } f.addWith("studio(root_id, item_id) AS (" + valuesClause + ")") templStr := `SELECT performer_id FROM {primaryTable} diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index a874f3967..89605ac89 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -513,12 +513,13 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { performerIDs[performerIdxWithTwoTags], clearPerformerPartial(), models.Performer{ - ID: performerIDs[performerIdxWithTwoTags], - Name: getPerformerStringValue(performerIdxWithTwoTags, "Name"), - Favorite: true, - Aliases: models.NewRelatedStrings([]string{}), - TagIDs: models.NewRelatedIDs([]int{}), - StashIDs: models.NewRelatedStashIDs([]models.StashID{}), + ID: performerIDs[performerIdxWithTwoTags], + Name: getPerformerStringValue(performerIdxWithTwoTags, "Name"), + Favorite: getPerformerBoolValue(performerIdxWithTwoTags), + Aliases: models.NewRelatedStrings([]string{}), + TagIDs: models.NewRelatedIDs([]int{}), + StashIDs: models.NewRelatedStashIDs([]models.StashID{}), + IgnoreAutoTag: getIgnoreAutoTag(performerIdxWithTwoTags), }, false, }, @@ -1904,10 +1905,10 @@ func TestPerformerQuerySortScenesCount(t *testing.T) { assert.True(t, len(performers) > 0) - // first performer should be performerIdxWithTwoScenes + // first performer should be performerIdx1WithScene firstPerformer := performers[0] - assert.Equal(t, performerIDs[performerIdxWithTwoScenes], firstPerformer.ID) + assert.Equal(t, performerIDs[performerIdx1WithScene], firstPerformer.ID) // sort in ascending order direction = models.SortDirectionEnumAsc @@ -1920,7 +1921,7 @@ func TestPerformerQuerySortScenesCount(t *testing.T) { assert.True(t, len(performers) > 0) lastPerformer := performers[len(performers)-1] - assert.Equal(t, performerIDs[performerIdxWithTwoScenes], lastPerformer.ID) + assert.Equal(t, performerIDs[performerIdxWithTag], lastPerformer.ID) return nil }) @@ -2060,7 +2061,7 @@ func TestPerformerStore_FindByStashIDStatus(t *testing.T) { name: "!hasStashID", hasStashID: false, stashboxEndpoint: getPerformerStringValue(performerIdxWithScene, "endpoint"), - include: []int{performerIdxWithImage}, + include: []int{performerIdxWithTwoScenes}, exclude: []int{performerIdx2WithScene}, wantErr: false, }, diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 1a735bcd2..1fe5bcdb0 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -959,7 +959,7 @@ func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneF query.handleCriterion(ctx, sceneTagCountCriterionHandler(qb, sceneFilter.TagCount)) query.handleCriterion(ctx, scenePerformersCriterionHandler(qb, sceneFilter.Performers)) query.handleCriterion(ctx, scenePerformerCountCriterionHandler(qb, sceneFilter.PerformerCount)) - query.handleCriterion(ctx, sceneStudioCriterionHandler(qb, sceneFilter.Studios)) + query.handleCriterion(ctx, studioCriterionHandler(sceneTable, sceneFilter.Studios)) query.handleCriterion(ctx, sceneMoviesCriterionHandler(qb, sceneFilter.Movies)) query.handleCriterion(ctx, scenePerformerTagsCriterionHandler(qb, sceneFilter.PerformerTags)) query.handleCriterion(ctx, scenePerformerFavoriteCriterionHandler(sceneFilter.PerformerFavorite)) @@ -1352,19 +1352,6 @@ func scenePerformerAgeCriterionHandler(performerAge *models.IntCriterionInput) c } } -func sceneStudioCriterionHandler(qb *SceneStore, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - h := hierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: sceneTable, - foreignTable: studioTable, - foreignFK: studioIDColumn, - parentFK: "parent_id", - } - - return h.handler(studios) -} - func sceneMoviesCriterionHandler(qb *SceneStore, movies *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { qb.moviesRepository().join(f, "", "scenes.id") @@ -1374,38 +1361,12 @@ func sceneMoviesCriterionHandler(qb *SceneStore, movies *models.MultiCriterionIn return h.handler(movies) } -func scenePerformerTagsCriterionHandler(qb *SceneStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if tags != nil { - if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { - var notClause string - if tags.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin("performers_scenes", "", "scenes.id = performers_scenes.scene_id") - f.addLeftJoin("performers_tags", "", "performers_scenes.performer_id = performers_tags.performer_id") - - f.addWhere(fmt.Sprintf("performers_tags.tag_id IS %s NULL", notClause)) - return - } - - if len(tags.Value) == 0 { - return - } - - valuesClause := getHierarchicalValues(ctx, qb.tx, tags.Value, tagTable, "tags_relations", "", tags.Depth) - - f.addWith(`performer_tags AS ( -SELECT ps.scene_id, t.column1 AS root_tag_id FROM performers_scenes ps -INNER JOIN performers_tags pt ON pt.performer_id = ps.performer_id -INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id -)`) - - f.addLeftJoin("performer_tags", "", "performer_tags.scene_id = scenes.id") - - addHierarchicalConditionClauses(f, *tags, "performer_tags", "root_tag_id") - } +func scenePerformerTagsCriterionHandler(qb *SceneStore, tags *models.HierarchicalMultiCriterionInput) criterionHandler { + return &joinedPerformerTagsHandler{ + criterion: tags, + primaryTable: sceneTable, + joinTable: performersScenesTable, + joinPrimaryKey: sceneIDColumn, } } diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index c4ae7dda7..04eeb1e3a 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -209,7 +209,11 @@ func sceneMarkerTagsCriterionHandler(qb *sceneMarkerQueryBuilder, tags *models.H if len(tags.Value) == 0 { return } - valuesClause := getHierarchicalValues(ctx, qb.tx, tags.Value, tagTable, "tags_relations", "", tags.Depth) + valuesClause, err := getHierarchicalValues(ctx, qb.tx, tags.Value, tagTable, "tags_relations", "parent_id", "child_id", tags.Depth) + if err != nil { + f.setError(err) + return + } f.addWith(`marker_tags AS ( SELECT mt.scene_marker_id, t.column1 AS root_tag_id FROM scene_markers_tags mt @@ -229,32 +233,23 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = m.primary_tag_id func sceneMarkerSceneTagsCriterionHandler(qb *sceneMarkerQueryBuilder, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if tags != nil { - if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { - var notClause string - if tags.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } + f.addLeftJoin("scenes_tags", "", "scene_markers.scene_id = scenes_tags.scene_id") - f.addLeftJoin("scenes_tags", "", "scene_markers.scene_id = scenes_tags.scene_id") + h := joinedHierarchicalMultiCriterionHandlerBuilder{ + tx: qb.tx, - f.addWhere(fmt.Sprintf("scenes_tags.tag_id IS %s NULL", notClause)) - return + primaryTable: "scene_markers", + primaryKey: sceneIDColumn, + foreignTable: tagTable, + foreignFK: tagIDColumn, + + relationsTable: "tags_relations", + joinTable: "scenes_tags", + joinAs: "marker_scenes_tags", + primaryFK: sceneIDColumn, } - if len(tags.Value) == 0 { - return - } - - valuesClause := getHierarchicalValues(ctx, qb.tx, tags.Value, tagTable, "tags_relations", "", tags.Depth) - - f.addWith(`scene_tags AS ( -SELECT st.scene_id, t.column1 AS root_tag_id FROM scenes_tags st -INNER JOIN (` + valuesClause + `) t ON t.column2 = st.tag_id -)`) - - f.addLeftJoin("scene_tags", "", "scene_tags.scene_id = scene_markers.scene_id") - - addHierarchicalConditionClauses(f, *tags, "scene_tags", "root_tag_id") + h.handler(tags).handle(ctx, f) } } } diff --git a/pkg/sqlite/scene_marker_test.go b/pkg/sqlite/scene_marker_test.go index 9c5ae866f..b2f7b2ee6 100644 --- a/pkg/sqlite/scene_marker_test.go +++ b/pkg/sqlite/scene_marker_test.go @@ -5,9 +5,12 @@ package sqlite_test import ( "context" + "strconv" "testing" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil/intslice" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/sqlite" "github.com/stretchr/testify/assert" ) @@ -50,7 +53,7 @@ func TestMarkerCountByTagID(t *testing.T) { t.Errorf("error calling CountByTagID: %s", err.Error()) } - assert.Equal(t, 3, markerCount) + assert.Equal(t, 4, markerCount) markerCount, err = mqb.CountByTagID(ctx, tagIDs[tagIdxWithMarkers]) @@ -151,7 +154,7 @@ func TestMarkerQuerySceneTags(t *testing.T) { } withTxn(func(ctx context.Context) error { - testTags := func(m *models.SceneMarker, markerFilter *models.SceneMarkerFilterType) { + testTags := func(t *testing.T, m *models.SceneMarker, markerFilter *models.SceneMarkerFilterType) { s, err := db.Scene.Find(ctx, int(m.SceneID.Int64)) if err != nil { t.Errorf("error getting marker tag ids: %v", err) @@ -164,11 +167,40 @@ func TestMarkerQuerySceneTags(t *testing.T) { } tagIDs := s.TagIDs.List() - if markerFilter.SceneTags.Modifier == models.CriterionModifierIsNull && len(tagIDs) > 0 { - t.Errorf("expected marker %d to have no scene tags - found %d", m.ID, len(tagIDs)) - } - if markerFilter.SceneTags.Modifier == models.CriterionModifierNotNull && len(tagIDs) == 0 { - t.Errorf("expected marker %d to have scene tags - found 0", m.ID) + values, _ := stringslice.StringSliceToIntSlice(markerFilter.SceneTags.Value) + switch markerFilter.SceneTags.Modifier { + case models.CriterionModifierIsNull: + if len(tagIDs) > 0 { + t.Errorf("expected marker %d to have no scene tags - found %d", m.ID, len(tagIDs)) + } + case models.CriterionModifierNotNull: + if len(tagIDs) == 0 { + t.Errorf("expected marker %d to have scene tags - found 0", m.ID) + } + case models.CriterionModifierIncludes: + for _, v := range values { + assert.Contains(t, tagIDs, v) + } + case models.CriterionModifierExcludes: + for _, v := range values { + assert.NotContains(t, tagIDs, v) + } + case models.CriterionModifierEquals: + for _, v := range values { + assert.Contains(t, tagIDs, v) + } + assert.Len(t, tagIDs, len(values)) + case models.CriterionModifierNotEquals: + foundAll := true + for _, v := range values { + if !intslice.IntInclude(tagIDs, v) { + foundAll = false + break + } + } + if foundAll && len(tagIDs) == len(values) { + t.Errorf("expected marker %d to have scene tags not equal to %v - found %v", m.ID, values, tagIDs) + } } } @@ -191,6 +223,70 @@ func TestMarkerQuerySceneTags(t *testing.T) { }, nil, }, + { + "includes", + &models.SceneMarkerFilterType{ + SceneTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIncludes, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx3WithScene]), + }, + }, + }, + nil, + }, + { + "includes all", + &models.SceneMarkerFilterType{ + SceneTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIncludesAll, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithScene]), + strconv.Itoa(tagIDs[tagIdx3WithScene]), + }, + }, + }, + nil, + }, + { + "equals", + &models.SceneMarkerFilterType{ + SceneTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithScene]), + strconv.Itoa(tagIDs[tagIdx3WithScene]), + }, + }, + }, + nil, + }, + // not equals not supported + // { + // "not equals", + // &models.SceneMarkerFilterType{ + // SceneTags: &models.HierarchicalMultiCriterionInput{ + // Modifier: models.CriterionModifierNotEquals, + // Value: []string{ + // strconv.Itoa(tagIDs[tagIdx2WithScene]), + // strconv.Itoa(tagIDs[tagIdx3WithScene]), + // }, + // }, + // }, + // nil, + // }, + { + "excludes", + &models.SceneMarkerFilterType{ + SceneTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIncludes, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithScene]), + }, + }, + }, + nil, + }, } for _, tc := range cases { @@ -198,7 +294,7 @@ func TestMarkerQuerySceneTags(t *testing.T) { markers := queryMarkers(ctx, t, sqlite.SceneMarkerReaderWriter, tc.markerFilter, tc.findFilter) assert.Greater(t, len(markers), 0) for _, m := range markers { - testTags(m, tc.markerFilter) + testTags(t, m, tc.markerFilter) } }) } diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index 137319c31..7b676fe76 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -668,7 +668,8 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) { sceneIDs[sceneIdxWithSpacedName], clearScenePartial(), models.Scene{ - ID: sceneIDs[sceneIdxWithSpacedName], + ID: sceneIDs[sceneIdxWithSpacedName], + OCounter: getOCounter(sceneIdxWithSpacedName), Files: models.NewRelatedVideoFiles([]*file.VideoFile{ makeSceneFile(sceneIdxWithSpacedName), }), @@ -677,6 +678,10 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) { PerformerIDs: models.NewRelatedIDs([]int{}), Movies: models.NewRelatedMovies([]models.MoviesScenes{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), + PlayCount: getScenePlayCount(sceneIdxWithSpacedName), + PlayDuration: getScenePlayDuration(sceneIdxWithSpacedName), + LastPlayedAt: getSceneLastPlayed(sceneIdxWithSpacedName), + ResumeTime: getSceneResumeTime(sceneIdxWithSpacedName), }, false, }, @@ -2101,6 +2106,8 @@ func sceneQueryQ(ctx context.Context, t *testing.T, sqb models.SceneReader, q st // no Q should return all results filter.Q = nil + pp := totalScenes + filter.PerPage = &pp scenes = queryScene(ctx, t, sqb, nil, &filter) assert.Len(t, scenes, totalScenes) @@ -2230,8 +2237,8 @@ func TestSceneQuery(t *testing.T) { return } - include := indexesToIDs(performerIDs, tt.includeIdxs) - exclude := indexesToIDs(performerIDs, tt.excludeIdxs) + include := indexesToIDs(sceneIDs, tt.includeIdxs) + exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) for _, i := range include { assert.Contains(results.IDs, i) @@ -3057,7 +3064,13 @@ func queryScenes(ctx context.Context, t *testing.T, queryBuilder models.SceneRea }, } - return queryScene(ctx, t, queryBuilder, &sceneFilter, nil) + // needed so that we don't hit the default limit of 25 scenes + pp := 1000 + findFilter := &models.FindFilterType{ + PerPage: &pp, + } + + return queryScene(ctx, t, queryBuilder, &sceneFilter, findFilter) } func createScene(ctx context.Context, width int, height int) (*models.Scene, error) { @@ -3329,192 +3342,473 @@ func TestSceneQueryIsMissingPhash(t *testing.T) { } func TestSceneQueryPerformers(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Scene - performerCriterion := models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdxWithScene]), - strconv.Itoa(performerIDs[performerIdx1WithScene]), + tests := []struct { + name string + filter models.MultiCriterionInput + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(performerIDs[performerIdxWithScene]), + strconv.Itoa(performerIDs[performerIdx1WithScene]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - sceneFilter := models.SceneFilterType{ - Performers: &performerCriterion, - } - - scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) - - assert.Len(t, scenes, 2) - - // ensure ids are correct - for _, scene := range scenes { - assert.True(t, scene.ID == sceneIDs[sceneIdxWithPerformer] || scene.ID == sceneIDs[sceneIdxWithTwoPerformers]) - } - - performerCriterion = models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdx1WithScene]), - strconv.Itoa(performerIDs[performerIdx2WithScene]), + []int{ + sceneIdxWithPerformer, + sceneIdxWithTwoPerformers, }, - Modifier: models.CriterionModifierIncludesAll, - } - - scenes = queryScene(ctx, t, sqb, &sceneFilter, nil) - - assert.Len(t, scenes, 1) - assert.Equal(t, sceneIDs[sceneIdxWithTwoPerformers], scenes[0].ID) - - performerCriterion = models.MultiCriterionInput{ - Value: []string{ - strconv.Itoa(performerIDs[performerIdx1WithScene]), + []int{ + sceneIdxWithGallery, }, - Modifier: models.CriterionModifierExcludes, - } + false, + }, + { + "includes all", + models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(performerIDs[performerIdx1WithScene]), + strconv.Itoa(performerIDs[performerIdx2WithScene]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + []int{ + sceneIdxWithTwoPerformers, + }, + []int{ + sceneIdxWithPerformer, + }, + false, + }, + { + "excludes", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[performerIdx1WithScene])}, + }, + nil, + []int{sceneIdxWithTwoPerformers}, + false, + }, + { + "is null", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + []int{sceneIdxWithTag}, + []int{ + sceneIdxWithPerformer, + sceneIdxWithTwoPerformers, + sceneIdxWithPerformerTwoTags, + }, + false, + }, + { + "not null", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + []int{ + sceneIdxWithPerformer, + sceneIdxWithTwoPerformers, + sceneIdxWithPerformerTwoTags, + }, + []int{sceneIdxWithTag}, + false, + }, + { + "equals", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[performerIdx1WithScene]), + strconv.Itoa(tagIDs[performerIdx2WithScene]), + }, + }, + []int{sceneIdxWithTwoPerformers}, + []int{ + sceneIdxWithThreePerformers, + }, + false, + }, + { + "not equals", + models.MultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[performerIdx1WithScene]), + strconv.Itoa(tagIDs[performerIdx2WithScene]), + }, + }, + nil, + nil, + true, + }, + } - q := getSceneStringValue(sceneIdxWithTwoPerformers, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) - assert.Len(t, scenes, 0) + results, err := db.Scene.Query(ctx, models.SceneQueryOptions{ + SceneFilter: &models.SceneFilterType{ + Performers: &tt.filter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - return nil - }) + include := indexesToIDs(sceneIDs, tt.includeIdxs) + exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(results.IDs, i) + } + for _, e := range exclude { + assert.NotContains(results.IDs, e) + } + }) + } } func TestSceneQueryTags(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Scene - tagCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdxWithScene]), - strconv.Itoa(tagIDs[tagIdx1WithScene]), + tests := []struct { + name string + filter models.HierarchicalMultiCriterionInput + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithScene]), + strconv.Itoa(tagIDs[tagIdx1WithScene]), + }, + Modifier: models.CriterionModifierIncludes, }, - Modifier: models.CriterionModifierIncludes, - } - - sceneFilter := models.SceneFilterType{ - Tags: &tagCriterion, - } - - scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) - assert.Len(t, scenes, 2) - - // ensure ids are correct - for _, scene := range scenes { - assert.True(t, scene.ID == sceneIDs[sceneIdxWithTag] || scene.ID == sceneIDs[sceneIdxWithTwoTags]) - } - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithScene]), - strconv.Itoa(tagIDs[tagIdx2WithScene]), + []int{ + sceneIdxWithTag, + sceneIdxWithTwoTags, }, - Modifier: models.CriterionModifierIncludesAll, - } - - scenes = queryScene(ctx, t, sqb, &sceneFilter, nil) - - assert.Len(t, scenes, 1) - assert.Equal(t, sceneIDs[sceneIdxWithTwoTags], scenes[0].ID) - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithScene]), + []int{ + sceneIdxWithGallery, }, - Modifier: models.CriterionModifierExcludes, - } + false, + }, + { + "includes all", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithScene]), + strconv.Itoa(tagIDs[tagIdx2WithScene]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + []int{ + sceneIdxWithTwoTags, + }, + []int{ + sceneIdxWithTag, + }, + false, + }, + { + "excludes", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[tagIdx1WithScene])}, + }, + nil, + []int{sceneIdxWithTwoTags}, + false, + }, + { + "is null", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + []int{sceneIdx1WithPerformer}, + []int{ + sceneIdxWithTag, + sceneIdxWithTwoTags, + sceneIdxWithMarkerAndTag, + }, + false, + }, + { + "not null", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + []int{ + sceneIdxWithTag, + sceneIdxWithTwoTags, + sceneIdxWithMarkerAndTag, + }, + []int{sceneIdx1WithPerformer}, + false, + }, + { + "equals", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithScene]), + strconv.Itoa(tagIDs[tagIdx2WithScene]), + }, + }, + []int{sceneIdxWithTwoTags}, + []int{ + sceneIdxWithThreeTags, + }, + false, + }, + { + "not equals", + models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithScene]), + strconv.Itoa(tagIDs[tagIdx2WithScene]), + }, + }, + nil, + nil, + true, + }, + } - q := getSceneStringValue(sceneIdxWithTwoTags, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) - assert.Len(t, scenes, 0) + results, err := db.Scene.Query(ctx, models.SceneQueryOptions{ + SceneFilter: &models.SceneFilterType{ + Tags: &tt.filter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - return nil - }) + include := indexesToIDs(sceneIDs, tt.includeIdxs) + exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(results.IDs, i) + } + for _, e := range exclude { + assert.NotContains(results.IDs, e) + } + }) + } } func TestSceneQueryPerformerTags(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Scene - tagCriterion := models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdxWithPerformer]), - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + allDepth := -1 + + tests := []struct { + name string + findFilter *models.FindFilterType + filter *models.SceneFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "includes", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithPerformer]), + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + }, + Modifier: models.CriterionModifierIncludes, + }, }, - Modifier: models.CriterionModifierIncludes, - } - - sceneFilter := models.SceneFilterType{ - PerformerTags: &tagCriterion, - } - - scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) - assert.Len(t, scenes, 2) - - // ensure ids are correct - for _, scene := range scenes { - assert.True(t, scene.ID == sceneIDs[sceneIdxWithPerformerTag] || scene.ID == sceneIDs[sceneIdxWithPerformerTwoTags]) - } - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), - strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + []int{ + sceneIdxWithPerformerTag, + sceneIdxWithPerformerTwoTags, + sceneIdxWithTwoPerformerTag, }, - Modifier: models.CriterionModifierIncludesAll, - } - - scenes = queryScene(ctx, t, sqb, &sceneFilter, nil) - - assert.Len(t, scenes, 1) - assert.Equal(t, sceneIDs[sceneIdxWithPerformerTwoTags], scenes[0].ID) - - tagCriterion = models.HierarchicalMultiCriterionInput{ - Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + []int{ + sceneIdxWithPerformer, }, - Modifier: models.CriterionModifierExcludes, - } + false, + }, + { + "includes sub-tags", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), + }, + Depth: &allDepth, + Modifier: models.CriterionModifierIncludes, + }, + }, + []int{ + sceneIdxWithPerformerParentTag, + }, + []int{ + sceneIdxWithPerformer, + sceneIdxWithPerformerTag, + sceneIdxWithPerformerTwoTags, + sceneIdxWithTwoPerformerTag, + }, + false, + }, + { + "includes all", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + Modifier: models.CriterionModifierIncludesAll, + }, + }, + []int{ + sceneIdxWithPerformerTwoTags, + }, + []int{ + sceneIdxWithPerformer, + sceneIdxWithPerformerTag, + sceneIdxWithTwoPerformerTag, + }, + false, + }, + { + "excludes performer tag tagIdx2WithPerformer", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierExcludes, + Value: []string{strconv.Itoa(tagIDs[tagIdx2WithPerformer])}, + }, + }, + nil, + []int{sceneIdxWithTwoPerformerTag}, + false, + }, + { + "excludes sub-tags", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithParentAndChild]), + }, + Depth: &allDepth, + Modifier: models.CriterionModifierExcludes, + }, + }, + []int{ + sceneIdxWithPerformer, + sceneIdxWithPerformerTag, + sceneIdxWithPerformerTwoTags, + sceneIdxWithTwoPerformerTag, + }, + []int{ + sceneIdxWithPerformerParentTag, + }, + false, + }, + { + "is null", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + }, + []int{sceneIdx1WithPerformer}, + []int{sceneIdxWithPerformerTag}, + false, + }, + { + "not null", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + }, + []int{sceneIdxWithPerformerTag}, + []int{sceneIdx1WithPerformer}, + false, + }, + { + "equals", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + }, + }, + nil, + nil, + true, + }, + { + "not equals", + nil, + &models.SceneFilterType{ + PerformerTags: &models.HierarchicalMultiCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: []string{ + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + }, + }, + nil, + nil, + true, + }, + } - q := getSceneStringValue(sceneIdxWithPerformerTwoTags, titleField) - findFilter := models.FindFilterType{ - Q: &q, - } + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) - scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) - assert.Len(t, scenes, 0) + results, err := db.Scene.Query(ctx, models.SceneQueryOptions{ + SceneFilter: tt.filter, + QueryOptions: models.QueryOptions{ + FindFilter: tt.findFilter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } - tagCriterion = models.HierarchicalMultiCriterionInput{ - Modifier: models.CriterionModifierIsNull, - } - q = getSceneStringValue(sceneIdx1WithPerformer, titleField) + include := indexesToIDs(sceneIDs, tt.includeIdxs) + exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) - scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) - assert.Len(t, scenes, 1) - assert.Equal(t, sceneIDs[sceneIdx1WithPerformer], scenes[0].ID) - - q = getSceneStringValue(sceneIdxWithPerformerTag, titleField) - scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) - assert.Len(t, scenes, 0) - - tagCriterion.Modifier = models.CriterionModifierNotNull - - scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) - assert.Len(t, scenes, 1) - assert.Equal(t, sceneIDs[sceneIdxWithPerformerTag], scenes[0].ID) - - q = getSceneStringValue(sceneIdx1WithPerformer, titleField) - scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter) - assert.Len(t, scenes, 0) - - return nil - }) + for _, i := range include { + assert.Contains(results.IDs, i) + } + for _, e := range exclude { + assert.NotContains(results.IDs, e) + } + }) + } } func TestSceneQueryStudio(t *testing.T) { @@ -3561,6 +3855,30 @@ func TestSceneQueryStudio(t *testing.T) { []int{sceneIDs[sceneIdxWithGallery]}, false, }, + { + "equals", + "", + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithScene]), + }, + Modifier: models.CriterionModifierEquals, + }, + []int{sceneIDs[sceneIdxWithStudio]}, + false, + }, + { + "not equals", + getSceneStringValue(sceneIdxWithStudio, titleField), + models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithScene]), + }, + Modifier: models.CriterionModifierNotEquals, + }, + []int{}, + false, + }, } qb := db.Scene diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 94c92035b..12a56947b 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -60,19 +60,24 @@ const ( sceneIdx1WithPerformer sceneIdx2WithPerformer sceneIdxWithTwoPerformers + sceneIdxWithThreePerformers sceneIdxWithTag sceneIdxWithTwoTags + sceneIdxWithThreeTags sceneIdxWithMarkerAndTag + sceneIdxWithMarkerTwoTags sceneIdxWithStudio sceneIdx1WithStudio sceneIdx2WithStudio sceneIdxWithMarkers sceneIdxWithPerformerTag + sceneIdxWithTwoPerformerTag sceneIdxWithPerformerTwoTags sceneIdxWithSpacedName sceneIdxWithStudioPerformer sceneIdxWithGrandChildStudio sceneIdxMissingPhash + sceneIdxWithPerformerParentTag // new indexes above lastSceneIdx @@ -90,16 +95,20 @@ const ( imageIdx1WithPerformer imageIdx2WithPerformer imageIdxWithTwoPerformers + imageIdxWithThreePerformers imageIdxWithTag imageIdxWithTwoTags + imageIdxWithThreeTags imageIdxWithStudio imageIdx1WithStudio imageIdx2WithStudio imageIdxWithStudioPerformer imageIdxInZip imageIdxWithPerformerTag + imageIdxWithTwoPerformerTag imageIdxWithPerformerTwoTags imageIdxWithGrandChildStudio + imageIdxWithPerformerParentTag // new indexes above totalImages ) @@ -108,20 +117,25 @@ const ( performerIdxWithScene = iota performerIdx1WithScene performerIdx2WithScene + performerIdx3WithScene performerIdxWithTwoScenes performerIdxWithImage performerIdxWithTwoImages performerIdx1WithImage performerIdx2WithImage + performerIdx3WithImage performerIdxWithTag + performerIdx2WithTag performerIdxWithTwoTags performerIdxWithGallery performerIdxWithTwoGalleries performerIdx1WithGallery performerIdx2WithGallery + performerIdx3WithGallery performerIdxWithSceneStudio performerIdxWithImageStudio performerIdxWithGalleryStudio + performerIdxWithParentTag // new indexes above // performers with dup names start from the end performerIdx1WithDupName @@ -155,16 +169,20 @@ const ( galleryIdx1WithPerformer galleryIdx2WithPerformer galleryIdxWithTwoPerformers + galleryIdxWithThreePerformers galleryIdxWithTag galleryIdxWithTwoTags + galleryIdxWithThreeTags galleryIdxWithStudio galleryIdx1WithStudio galleryIdx2WithStudio galleryIdxWithPerformerTag + galleryIdxWithTwoPerformerTag galleryIdxWithPerformerTwoTags galleryIdxWithStudioPerformer galleryIdxWithGrandChildStudio galleryIdxWithoutFile + galleryIdxWithPerformerParentTag // new indexes above lastGalleryIdx @@ -182,12 +200,14 @@ const ( tagIdxWithImage tagIdx1WithImage tagIdx2WithImage + tagIdx3WithImage tagIdxWithPerformer tagIdx1WithPerformer tagIdx2WithPerformer tagIdxWithGallery tagIdx1WithGallery tagIdx2WithGallery + tagIdx3WithGallery tagIdxWithChildTag tagIdxWithParentTag tagIdxWithGrandChild @@ -332,19 +352,24 @@ var ( var ( sceneTags = linkMap{ - sceneIdxWithTag: {tagIdxWithScene}, - sceneIdxWithTwoTags: {tagIdx1WithScene, tagIdx2WithScene}, - sceneIdxWithMarkerAndTag: {tagIdx3WithScene}, + sceneIdxWithTag: {tagIdxWithScene}, + sceneIdxWithTwoTags: {tagIdx1WithScene, tagIdx2WithScene}, + sceneIdxWithThreeTags: {tagIdx1WithScene, tagIdx2WithScene, tagIdx3WithScene}, + sceneIdxWithMarkerAndTag: {tagIdx3WithScene}, + sceneIdxWithMarkerTwoTags: {tagIdx2WithScene, tagIdx3WithScene}, } scenePerformers = linkMap{ - sceneIdxWithPerformer: {performerIdxWithScene}, - sceneIdxWithTwoPerformers: {performerIdx1WithScene, performerIdx2WithScene}, - sceneIdxWithPerformerTag: {performerIdxWithTag}, - sceneIdxWithPerformerTwoTags: {performerIdxWithTwoTags}, - sceneIdx1WithPerformer: {performerIdxWithTwoScenes}, - sceneIdx2WithPerformer: {performerIdxWithTwoScenes}, - sceneIdxWithStudioPerformer: {performerIdxWithSceneStudio}, + sceneIdxWithPerformer: {performerIdxWithScene}, + sceneIdxWithTwoPerformers: {performerIdx1WithScene, performerIdx2WithScene}, + sceneIdxWithThreePerformers: {performerIdx1WithScene, performerIdx2WithScene, performerIdx3WithScene}, + sceneIdxWithPerformerTag: {performerIdxWithTag}, + sceneIdxWithTwoPerformerTag: {performerIdxWithTag, performerIdx2WithTag}, + sceneIdxWithPerformerTwoTags: {performerIdxWithTwoTags}, + sceneIdx1WithPerformer: {performerIdxWithTwoScenes}, + sceneIdx2WithPerformer: {performerIdxWithTwoScenes}, + sceneIdxWithStudioPerformer: {performerIdxWithSceneStudio}, + sceneIdxWithPerformerParentTag: {performerIdxWithParentTag}, } sceneGalleries = linkMap{ @@ -376,6 +401,7 @@ var ( {sceneIdxWithMarkers, tagIdxWithPrimaryMarkers, nil}, {sceneIdxWithMarkers, tagIdxWithPrimaryMarkers, []int{tagIdxWithMarkers}}, {sceneIdxWithMarkerAndTag, tagIdxWithPrimaryMarkers, nil}, + {sceneIdxWithMarkerTwoTags, tagIdxWithPrimaryMarkers, nil}, } ) @@ -407,29 +433,36 @@ var ( imageIdxWithGrandChildStudio: studioIdxWithGrandParent, } imageTags = linkMap{ - imageIdxWithTag: {tagIdxWithImage}, - imageIdxWithTwoTags: {tagIdx1WithImage, tagIdx2WithImage}, + imageIdxWithTag: {tagIdxWithImage}, + imageIdxWithTwoTags: {tagIdx1WithImage, tagIdx2WithImage}, + imageIdxWithThreeTags: {tagIdx1WithImage, tagIdx2WithImage, tagIdx3WithImage}, } imagePerformers = linkMap{ - imageIdxWithPerformer: {performerIdxWithImage}, - imageIdxWithTwoPerformers: {performerIdx1WithImage, performerIdx2WithImage}, - imageIdxWithPerformerTag: {performerIdxWithTag}, - imageIdxWithPerformerTwoTags: {performerIdxWithTwoTags}, - imageIdx1WithPerformer: {performerIdxWithTwoImages}, - imageIdx2WithPerformer: {performerIdxWithTwoImages}, - imageIdxWithStudioPerformer: {performerIdxWithImageStudio}, + imageIdxWithPerformer: {performerIdxWithImage}, + imageIdxWithTwoPerformers: {performerIdx1WithImage, performerIdx2WithImage}, + imageIdxWithThreePerformers: {performerIdx1WithImage, performerIdx2WithImage, performerIdx3WithImage}, + imageIdxWithPerformerTag: {performerIdxWithTag}, + imageIdxWithTwoPerformerTag: {performerIdxWithTag, performerIdx2WithTag}, + imageIdxWithPerformerTwoTags: {performerIdxWithTwoTags}, + imageIdx1WithPerformer: {performerIdxWithTwoImages}, + imageIdx2WithPerformer: {performerIdxWithTwoImages}, + imageIdxWithStudioPerformer: {performerIdxWithImageStudio}, + imageIdxWithPerformerParentTag: {performerIdxWithParentTag}, } ) var ( galleryPerformers = linkMap{ - galleryIdxWithPerformer: {performerIdxWithGallery}, - galleryIdxWithTwoPerformers: {performerIdx1WithGallery, performerIdx2WithGallery}, - galleryIdxWithPerformerTag: {performerIdxWithTag}, - galleryIdxWithPerformerTwoTags: {performerIdxWithTwoTags}, - galleryIdx1WithPerformer: {performerIdxWithTwoGalleries}, - galleryIdx2WithPerformer: {performerIdxWithTwoGalleries}, - galleryIdxWithStudioPerformer: {performerIdxWithGalleryStudio}, + galleryIdxWithPerformer: {performerIdxWithGallery}, + galleryIdxWithTwoPerformers: {performerIdx1WithGallery, performerIdx2WithGallery}, + galleryIdxWithThreePerformers: {performerIdx1WithGallery, performerIdx2WithGallery, performerIdx3WithGallery}, + galleryIdxWithPerformerTag: {performerIdxWithTag}, + galleryIdxWithTwoPerformerTag: {performerIdxWithTag, performerIdx2WithTag}, + galleryIdxWithPerformerTwoTags: {performerIdxWithTwoTags}, + galleryIdx1WithPerformer: {performerIdxWithTwoGalleries}, + galleryIdx2WithPerformer: {performerIdxWithTwoGalleries}, + galleryIdxWithStudioPerformer: {performerIdxWithGalleryStudio}, + galleryIdxWithPerformerParentTag: {performerIdxWithParentTag}, } galleryStudios = map[int]int{ @@ -441,8 +474,9 @@ var ( } galleryTags = linkMap{ - galleryIdxWithTag: {tagIdxWithGallery}, - galleryIdxWithTwoTags: {tagIdx1WithGallery, tagIdx2WithGallery}, + galleryIdxWithTag: {tagIdxWithGallery}, + galleryIdxWithTwoTags: {tagIdx1WithGallery, tagIdx2WithGallery}, + galleryIdxWithThreeTags: {tagIdx1WithGallery, tagIdx2WithGallery, tagIdx3WithGallery}, } ) @@ -462,8 +496,10 @@ var ( var ( performerTags = linkMap{ - performerIdxWithTag: {tagIdxWithPerformer}, - performerIdxWithTwoTags: {tagIdx1WithPerformer, tagIdx2WithPerformer}, + performerIdxWithTag: {tagIdxWithPerformer}, + performerIdx2WithTag: {tagIdx2WithPerformer}, + performerIdxWithTwoTags: {tagIdx1WithPerformer, tagIdx2WithPerformer}, + performerIdxWithParentTag: {tagIdxWithParentAndChild}, } ) @@ -484,6 +520,16 @@ func indexesToIDs(ids []int, indexes []int) []int { return ret } +func indexFromID(ids []int, id int) int { + for i, v := range ids { + if v == id { + return i + } + } + + return -1 +} + var db *sqlite.Database func TestMain(m *testing.M) { @@ -1431,11 +1477,8 @@ func getTagStringValue(index int, field string) string { } func getTagSceneCount(id int) int { - if id == tagIDs[tagIdx1WithScene] || id == tagIDs[tagIdx2WithScene] || id == tagIDs[tagIdxWithScene] || id == tagIDs[tagIdx3WithScene] { - return 1 - } - - return 0 + idx := indexFromID(tagIDs, id) + return len(sceneTags.reverseLookup(idx)) } func getTagMarkerCount(id int) int { @@ -1451,27 +1494,18 @@ func getTagMarkerCount(id int) int { } func getTagImageCount(id int) int { - if id == tagIDs[tagIdx1WithImage] || id == tagIDs[tagIdx2WithImage] || id == tagIDs[tagIdxWithImage] { - return 1 - } - - return 0 + idx := indexFromID(tagIDs, id) + return len(imageTags.reverseLookup(idx)) } func getTagGalleryCount(id int) int { - if id == tagIDs[tagIdx1WithGallery] || id == tagIDs[tagIdx2WithGallery] || id == tagIDs[tagIdxWithGallery] { - return 1 - } - - return 0 + idx := indexFromID(tagIDs, id) + return len(galleryTags.reverseLookup(idx)) } func getTagPerformerCount(id int) int { - if id == tagIDs[tagIdx1WithPerformer] || id == tagIDs[tagIdx2WithPerformer] || id == tagIDs[tagIdxWithPerformer] { - return 1 - } - - return 0 + idx := indexFromID(tagIDs, id) + return len(performerTags.reverseLookup(idx)) } func getTagParentCount(id int) int { diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 22f7bde1c..0c9f7422e 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -474,9 +474,19 @@ func tagMarkerCountCriterionHandler(qb *tagQueryBuilder, markerCount *models.Int } } -func tagParentsCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { +func tagParentsCriterionHandler(qb *tagQueryBuilder, criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { - if tags != nil { + if criterion != nil { + tags := criterion.CombineExcludes() + + // validate the modifier + switch tags.Modifier { + case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: + // valid + default: + f.setError(fmt.Errorf("invalid modifier %s for tag parent/children", criterion.Modifier)) + } + if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { var notClause string if tags.Modifier == models.CriterionModifierNotNull { @@ -489,43 +499,88 @@ func tagParentsCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalMu return } - if len(tags.Value) == 0 { + if len(tags.Value) == 0 && len(tags.Excludes) == 0 { return } - var args []interface{} - for _, val := range tags.Value { - args = append(args, val) + if len(tags.Value) > 0 { + var args []interface{} + for _, val := range tags.Value { + args = append(args, val) + } + + depthVal := 0 + if tags.Depth != nil { + depthVal = *tags.Depth + } + + var depthCondition string + if depthVal != -1 { + depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) + } + + query := `parents AS ( + SELECT parent_id AS root_id, child_id AS item_id, 0 AS depth FROM tags_relations WHERE parent_id IN` + getInBinding(len(tags.Value)) + ` + UNION + SELECT root_id, child_id, depth + 1 FROM tags_relations INNER JOIN parents ON item_id = parent_id ` + depthCondition + ` + )` + + f.addRecursiveWith(query, args...) + + f.addLeftJoin("parents", "", "parents.item_id = tags.id") + + addHierarchicalConditionClauses(f, tags, "parents", "root_id") } - depthVal := 0 - if tags.Depth != nil { - depthVal = *tags.Depth + if len(tags.Excludes) > 0 { + var args []interface{} + for _, val := range tags.Excludes { + args = append(args, val) + } + + depthVal := 0 + if tags.Depth != nil { + depthVal = *tags.Depth + } + + var depthCondition string + if depthVal != -1 { + depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) + } + + query := `parents2 AS ( + SELECT parent_id AS root_id, child_id AS item_id, 0 AS depth FROM tags_relations WHERE parent_id IN` + getInBinding(len(tags.Excludes)) + ` + UNION + SELECT root_id, child_id, depth + 1 FROM tags_relations INNER JOIN parents2 ON item_id = parent_id ` + depthCondition + ` + )` + + f.addRecursiveWith(query, args...) + + f.addLeftJoin("parents2", "", "parents2.item_id = tags.id") + + addHierarchicalConditionClauses(f, models.HierarchicalMultiCriterionInput{ + Value: tags.Excludes, + Depth: tags.Depth, + Modifier: models.CriterionModifierExcludes, + }, "parents2", "root_id") } - - var depthCondition string - if depthVal != -1 { - depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) - } - - query := `parents AS ( - SELECT parent_id AS root_id, child_id AS item_id, 0 AS depth FROM tags_relations WHERE parent_id IN` + getInBinding(len(tags.Value)) + ` - UNION - SELECT root_id, child_id, depth + 1 FROM tags_relations INNER JOIN parents ON item_id = parent_id ` + depthCondition + ` -)` - - f.addRecursiveWith(query, args...) - - f.addLeftJoin("parents", "", "parents.item_id = tags.id") - - addHierarchicalConditionClauses(f, *tags, "parents", "root_id") } } } -func tagChildrenCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { +func tagChildrenCriterionHandler(qb *tagQueryBuilder, criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { - if tags != nil { + if criterion != nil { + tags := criterion.CombineExcludes() + + // validate the modifier + switch tags.Modifier { + case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: + // valid + default: + f.setError(fmt.Errorf("invalid modifier %s for tag parent/children", criterion.Modifier)) + } + if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { var notClause string if tags.Modifier == models.CriterionModifierNotNull { @@ -538,36 +593,71 @@ func tagChildrenCriterionHandler(qb *tagQueryBuilder, tags *models.HierarchicalM return } - if len(tags.Value) == 0 { + if len(tags.Value) == 0 && len(tags.Excludes) == 0 { return } - var args []interface{} - for _, val := range tags.Value { - args = append(args, val) + if len(tags.Value) > 0 { + var args []interface{} + for _, val := range tags.Value { + args = append(args, val) + } + + depthVal := 0 + if tags.Depth != nil { + depthVal = *tags.Depth + } + + var depthCondition string + if depthVal != -1 { + depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) + } + + query := `children AS ( + SELECT child_id AS root_id, parent_id AS item_id, 0 AS depth FROM tags_relations WHERE child_id IN` + getInBinding(len(tags.Value)) + ` + UNION + SELECT root_id, parent_id, depth + 1 FROM tags_relations INNER JOIN children ON item_id = child_id ` + depthCondition + ` + )` + + f.addRecursiveWith(query, args...) + + f.addLeftJoin("children", "", "children.item_id = tags.id") + + addHierarchicalConditionClauses(f, tags, "children", "root_id") } - depthVal := 0 - if tags.Depth != nil { - depthVal = *tags.Depth + if len(tags.Excludes) > 0 { + var args []interface{} + for _, val := range tags.Excludes { + args = append(args, val) + } + + depthVal := 0 + if tags.Depth != nil { + depthVal = *tags.Depth + } + + var depthCondition string + if depthVal != -1 { + depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) + } + + query := `children2 AS ( + SELECT child_id AS root_id, parent_id AS item_id, 0 AS depth FROM tags_relations WHERE child_id IN` + getInBinding(len(tags.Excludes)) + ` + UNION + SELECT root_id, parent_id, depth + 1 FROM tags_relations INNER JOIN children2 ON item_id = child_id ` + depthCondition + ` + )` + + f.addRecursiveWith(query, args...) + + f.addLeftJoin("children2", "", "children2.item_id = tags.id") + + addHierarchicalConditionClauses(f, models.HierarchicalMultiCriterionInput{ + Value: tags.Excludes, + Depth: tags.Depth, + Modifier: models.CriterionModifierExcludes, + }, "children2", "root_id") } - - var depthCondition string - if depthVal != -1 { - depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) - } - - query := `children AS ( - SELECT child_id AS root_id, parent_id AS item_id, 0 AS depth FROM tags_relations WHERE child_id IN` + getInBinding(len(tags.Value)) + ` - UNION - SELECT root_id, parent_id, depth + 1 FROM tags_relations INNER JOIN children ON item_id = child_id ` + depthCondition + ` -)` - - f.addRecursiveWith(query, args...) - - f.addLeftJoin("children", "", "children.item_id = tags.id") - - addHierarchicalConditionClauses(f, *tags, "children", "root_id") } } } diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index d3ff5459f..5c601ca80 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -187,7 +187,7 @@ func TestTagQuerySort(t *testing.T) { tags := queryTags(ctx, t, sqb, nil, findFilter) assert := assert.New(t) - assert.Equal(tagIDs[tagIdxWithScene], tags[0].ID) + assert.Equal(tagIDs[tagIdx2WithScene], tags[0].ID) sortBy = "scene_markers_count" tags = queryTags(ctx, t, sqb, nil, findFilter) @@ -195,15 +195,15 @@ func TestTagQuerySort(t *testing.T) { sortBy = "images_count" tags = queryTags(ctx, t, sqb, nil, findFilter) - assert.Equal(tagIDs[tagIdxWithImage], tags[0].ID) + assert.Equal(tagIDs[tagIdx1WithImage], tags[0].ID) sortBy = "galleries_count" tags = queryTags(ctx, t, sqb, nil, findFilter) - assert.Equal(tagIDs[tagIdxWithGallery], tags[0].ID) + assert.Equal(tagIDs[tagIdx1WithGallery], tags[0].ID) sortBy = "performers_count" tags = queryTags(ctx, t, sqb, nil, findFilter) - assert.Equal(tagIDs[tagIdxWithPerformer], tags[0].ID) + assert.Equal(tagIDs[tagIdx2WithPerformer], tags[0].ID) return nil }) diff --git a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx index d14997ef6..48caccb16 100644 --- a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx @@ -309,14 +309,18 @@ export const HierarchicalObjectsFilter = < return (
- - onDepthChanged(criterion.value.depth !== 0 ? 0 : -1)} - /> - + {criterion.modifier !== CriterionModifier.Equals && ( + + + onDepthChanged(criterion.value.depth !== 0 ? 0 : -1) + } + /> + + )} {criterion.value.depth !== 0 && ( diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index fdf12995b..b8572909b 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -567,6 +567,11 @@ export class IHierarchicalLabeledIdCriterion extends Criterion v.id); } @@ -574,7 +579,7 @@ export class IHierarchicalLabeledIdCriterion extends Criterion v.id), excludes: excludes, modifier: this.modifier, - depth: this.value.depth, + depth, }; } diff --git a/ui/v2.5/src/models/list-filter/criteria/tags.ts b/ui/v2.5/src/models/list-filter/criteria/tags.ts index 7266fcf3d..eae7386ec 100644 --- a/ui/v2.5/src/models/list-filter/criteria/tags.ts +++ b/ui/v2.5/src/models/list-filter/criteria/tags.ts @@ -4,14 +4,24 @@ import { CriterionOption, IHierarchicalLabeledIdCriterion } from "./criterion"; export class TagsCriterion extends IHierarchicalLabeledIdCriterion {} -class tagsCriterionOption extends CriterionOption { - constructor(messageID: string, value: CriterionType, parameterName: string) { - const modifierOptions = [ - CriterionModifier.Includes, - CriterionModifier.IncludesAll, - CriterionModifier.Equals, - ]; +const tagsModifierOptions = [ + CriterionModifier.Includes, + CriterionModifier.IncludesAll, + CriterionModifier.Equals, +]; +const withoutEqualsModifierOptions = [ + CriterionModifier.Includes, + CriterionModifier.IncludesAll, +]; + +class tagsCriterionOption extends CriterionOption { + constructor( + messageID: string, + value: CriterionType, + parameterName: string, + modifierOptions: CriterionModifier[] + ) { let defaultModifier = CriterionModifier.IncludesAll; super({ @@ -27,25 +37,30 @@ class tagsCriterionOption extends CriterionOption { export const TagsCriterionOption = new tagsCriterionOption( "tags", "tags", - "tags" + "tags", + tagsModifierOptions ); export const SceneTagsCriterionOption = new tagsCriterionOption( "sceneTags", "sceneTags", - "scene_tags" + "scene_tags", + tagsModifierOptions ); export const PerformerTagsCriterionOption = new tagsCriterionOption( "performerTags", "performerTags", - "performer_tags" + "performer_tags", + withoutEqualsModifierOptions ); export const ParentTagsCriterionOption = new tagsCriterionOption( "parent_tags", "parentTags", - "parents" + "parents", + withoutEqualsModifierOptions ); export const ChildTagsCriterionOption = new tagsCriterionOption( "sub_tags", "childTags", - "children" + "children", + withoutEqualsModifierOptions ); From e22291d91212bc19acd1069b1f496b41190ef424 Mon Sep 17 00:00:00 2001 From: NodudeWasTaken <75137537+NodudeWasTaken@users.noreply.github.com> Date: Tue, 6 Jun 2023 05:24:13 +0200 Subject: [PATCH 74/81] Fix iOS captions (#3729) * Fix iOS captions and fix sceneplayer exceeding container size --- ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx | 1 - ui/v2.5/src/components/ScenePlayer/styles.scss | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index b4699e454..249111b92 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -278,7 +278,6 @@ export const ScenePlayer: React.FC = ({ chaptersButton: false, }, html5: { - nativeTextTracks: false, dash: { updateSettings: [ { diff --git a/ui/v2.5/src/components/ScenePlayer/styles.scss b/ui/v2.5/src/components/ScenePlayer/styles.scss index 63cc0bc3c..7fcd6c27b 100644 --- a/ui/v2.5/src/components/ScenePlayer/styles.scss +++ b/ui/v2.5/src/components/ScenePlayer/styles.scss @@ -23,6 +23,7 @@ $sceneTabWidth: 450px; .video-wrapper { height: 56.25vw; + max-height: calc(100vh - #{$menuHeight}); overflow: hidden; width: 100%; From 0c999080c27a03769d034a190daa04a807986db2 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 6 Jun 2023 13:25:11 +1000 Subject: [PATCH 75/81] Update gallery when adding image via scan (#3802) --- pkg/image/scan.go | 49 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/pkg/image/scan.go b/pkg/image/scan.go index 55eafdd97..d28d94a86 100644 --- a/pkg/image/scan.go +++ b/pkg/image/scan.go @@ -36,6 +36,7 @@ type GalleryFinderCreator interface { FindByFileID(ctx context.Context, fileID file.ID) ([]*models.Gallery, error) FindByFolderID(ctx context.Context, folderID file.FolderID) ([]*models.Gallery, error) Create(ctx context.Context, newObject *models.Gallery, fileIDs []file.ID) error + UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error) } type ScanConfig interface { @@ -117,10 +118,16 @@ func (h *ScanHandler) Handle(ctx context.Context, f file.File, oldFile file.File logger.Infof("%s doesn't exist. Creating new image...", f.Base().Path) - if _, err := h.associateGallery(ctx, newImage, imageFile); err != nil { + g, err := h.getGalleryToAssociate(ctx, newImage, f) + if err != nil { return err } + if g != nil { + newImage.GalleryIDs.Add(g.ID) + logger.Infof("Adding %s to gallery %s", f.Base().Path, g.Path) + } + if err := h.CreatorUpdater.Create(ctx, &models.ImageCreateInput{ Image: newImage, FileIDs: []file.ID{imageFile.ID}, @@ -128,6 +135,15 @@ func (h *ScanHandler) Handle(ctx context.Context, f file.File, oldFile file.File return fmt.Errorf("creating new image: %w", err) } + // update the gallery updated at timestamp if applicable + if g != nil { + if _, err := h.GalleryFinder.UpdatePartial(ctx, g.ID, models.GalleryPartial{ + UpdatedAt: models.NewOptionalTime(time.Now()), + }); err != nil { + return fmt.Errorf("updating gallery updated at timestamp: %w", err) + } + } + h.PluginCache.RegisterPostHooks(ctx, newImage.ID, plugin.ImageCreatePost, nil, nil) existing = []*models.Image{newImage} @@ -172,16 +188,18 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. } // associate with gallery if applicable - changed, err := h.associateGallery(ctx, i, f) + g, err := h.getGalleryToAssociate(ctx, i, f) if err != nil { return err } var galleryIDs *models.UpdateIDs - if changed { + changed := false + if g != nil { + changed = true galleryIDs = &models.UpdateIDs{ - IDs: i.GalleryIDs.List(), - Mode: models.RelationshipUpdateModeSet, + IDs: []int{g.ID}, + Mode: models.RelationshipUpdateModeAdd, } } @@ -203,6 +221,14 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. }); err != nil { return fmt.Errorf("updating image: %w", err) } + + if g != nil { + if _, err := h.GalleryFinder.UpdatePartial(ctx, g.ID, models.GalleryPartial{ + UpdatedAt: models.NewOptionalTime(time.Now()), + }); err != nil { + return fmt.Errorf("updating gallery updated at timestamp: %w", err) + } + } } if changed || updateExisting { @@ -331,22 +357,19 @@ func (h *ScanHandler) getOrCreateGallery(ctx context.Context, f file.File) (*mod return nil, nil } -func (h *ScanHandler) associateGallery(ctx context.Context, newImage *models.Image, f file.File) (bool, error) { +func (h *ScanHandler) getGalleryToAssociate(ctx context.Context, newImage *models.Image, f file.File) (*models.Gallery, error) { g, err := h.getOrCreateGallery(ctx, f) if err != nil { - return false, err + return nil, err } if err := newImage.LoadGalleryIDs(ctx, h.CreatorUpdater); err != nil { - return false, err + return nil, err } - ret := false if g != nil && !intslice.IntInclude(newImage.GalleryIDs.List(), g.ID) { - ret = true - newImage.GalleryIDs.Add(g.ID) - logger.Infof("Adding %s to gallery %s", f.Base().Path, g.Path) + return g, nil } - return ret, nil + return nil, nil } From de4237e6266c88658c6ee1523ce16c84776a9274 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 6 Jun 2023 14:06:46 +1000 Subject: [PATCH 76/81] Update changelog --- ui/v2.5/src/docs/en/Changelog/v0210.md | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/ui/v2.5/src/docs/en/Changelog/v0210.md b/ui/v2.5/src/docs/en/Changelog/v0210.md index a44dd5f04..51f3ba277 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0210.md +++ b/ui/v2.5/src/docs/en/Changelog/v0210.md @@ -1,7 +1,25 @@ ### ✨ New Features +* Added VR button to the scene player when the scene tag includes a configurable tag. ([#3636](https://github.com/stashapp/stash/pull/3636)) +* Added ability to include and exclude performers, studios and tags in the same filter. ([#3619](https://github.com/stashapp/stash/pull/3619)) +* Added penis length and circumcision status for Performers. ([#3627](https://github.com/stashapp/stash/pull/3627)) +* Added text field to search criteria in the edit filter dialog. ([#3740](https://github.com/stashapp/stash/pull/3740)) +* Added ability to add (short) video files as images. ([#3583](https://github.com/stashapp/stash/pull/3583)) +* Added ability to force gallery creation by adding `.forcegallery` to directory. ([#3715](https://github.com/stashapp/stash/pull/3715)) +* Added ability to ignore gallery creation by adding `.nogallery` to directory. ([#3715](https://github.com/stashapp/stash/pull/3715)) +* Added Maximum Duration Difference option to the Duplicate Scene Checker. ([#3663](https://github.com/stashapp/stash/pull/3663)) +* Added ability to configure the default sort order for videos served by DLNA. ([#3645](https://github.com/stashapp/stash/pull/3645)) +* Support pinning filter criteria to the top of the edit filter page. ([#3675](https://github.com/stashapp/stash/pull/3675)) +* Added Appears With tab to Performer page showing other performers that appear in the same scenes. ([#3563](https://github.com/stashapp/stash/pull/3563)) +* Added derived Performer O-Counter field. ([#3588](https://github.com/stashapp/stash/pull/3588)) * Added distance parameter to phash filter. ([#3596](https://github.com/stashapp/stash/pull/3596)) ### 🎨 Improvements +* Gallery Updated At timestamp is now updated when its contents are changed. ([#3771](https://github.com/stashapp/stash/pull/3771)) +* Added male performer images that are consistent with the other performer images. ([#3770](https://github.com/stashapp/stash/pull/3770)) +* Improved the UX when navigating the edit filter dialog using keyboard. ([#3739](https://github.com/stashapp/stash/pull/3739)) +* Changed modifier selector to a set of clickable pills. ([#3598](https://github.com/stashapp/stash/pull/3598)) +* Movie covers can now be shown in the Lightbox when clicking on them. ([#3705](https://github.com/stashapp/stash/pull/3705)) +* Scrapers are now sorted by name in the Scraper UI. ([#3691](https://github.com/stashapp/stash/pull/3691)) * Changed source selector menu to require click instead of mouseover. ([#3578](https://github.com/stashapp/stash/pull/3578)) * Updated default studio icon to be consistent with other icons. ([#3577](https://github.com/stashapp/stash/pull/3577)) * Make cards use up the full width of the screen on mobile. ([#3576](https://github.com/stashapp/stash/pull/3576)) @@ -10,4 +28,15 @@ * Default date sorting in descending order. ([#3560](https://github.com/stashapp/stash/pull/3560)) ### 🐛 Bug fixes +* Fixed captions not appearing on iOS devices. ([#3729](https://github.com/stashapp/stash/pull/3729)) +* Fixed folder selector appearing for name criterion. ([#3788](https://github.com/stashapp/stash/pull/3788)) +* Fixed generation of interactive heatmaps to match scene duration. ([#3758](https://github.com/stashapp/stash/pull/3758)) +* Fixed incorrect plugin hook being triggered during bulk performer update. ([#3754](https://github.com/stashapp/stash/pull/3754)) +* Fixed error when removing file over network on Windows. ([#3714](https://github.com/stashapp/stash/pull/3714)) +* Fixed scene cards being sized incorrectly on the front page. ([#3724](https://github.com/stashapp/stash/pull/3724)) +* Fixed hair colour not being populated during Batch Update Performers. ([#3718](https://github.com/stashapp/stash/pull/3718)) +* Fixed Create Missing checkbox not appearing in the Identify dialog. ([#3260](https://github.com/stashapp/stash/issues/3260)) +* Fixed override option not being honoured when generating scene covers. ([#3661](https://github.com/stashapp/stash/pull/3661)) +* Fixed error when creating a movie in the scrape scene dialog. ([#3633](https://github.com/stashapp/stash/pull/3633)) +* Fixed issues when scanning a renamed zip file. ([#3610](https://github.com/stashapp/stash/pull/3579)) * Fixed incorrect Twitter/Instagram URLs sent to stash-box. ([#3579](https://github.com/stashapp/stash/pull/3579)) From 09df203bcfb39780ca6fd702955bac4efd82397d Mon Sep 17 00:00:00 2001 From: DingDongSoLong4 <99329275+DingDongSoLong4@users.noreply.github.com> Date: Tue, 6 Jun 2023 06:10:14 +0200 Subject: [PATCH 77/81] Filter tweaks (#3772) * Use debounce hook * Wait until search request complete before refreshing results * Add back null modifiers * Convert old excludes criterion to includes criterion * Display criteria with only excludes items as excludes * Fix depth display * Reset search after selection * Add back is modifier to tag filter * Focus the input dialog after select/unselect * Update unsupported modifiers --- .../src/components/List/CriterionEditor.tsx | 12 +- .../List/Filters/PerformersFilter.tsx | 24 ++-- .../List/Filters/SelectableFilter.tsx | 133 +++++++++++------- .../components/List/Filters/StudiosFilter.tsx | 24 ++-- .../components/List/Filters/TagsFilter.tsx | 26 ++-- .../src/components/Shared/ClearableInput.tsx | 4 +- ui/v2.5/src/locales/en-GB.json | 2 + .../models/list-filter/criteria/country.ts | 2 +- .../models/list-filter/criteria/criterion.ts | 132 ++++++++++++----- .../models/list-filter/criteria/is-missing.ts | 2 +- .../src/models/list-filter/criteria/none.ts | 3 +- .../models/list-filter/criteria/performers.ts | 82 ++++++++--- .../src/models/list-filter/criteria/phash.ts | 2 +- .../src/models/list-filter/criteria/rating.ts | 2 +- .../models/list-filter/criteria/stash-ids.ts | 2 +- .../models/list-filter/criteria/studios.ts | 6 +- .../src/models/list-filter/criteria/tags.ts | 97 ++++++------- ui/v2.5/src/models/list-filter/filter.ts | 5 +- 18 files changed, 344 insertions(+), 216 deletions(-) diff --git a/ui/v2.5/src/components/List/CriterionEditor.tsx b/ui/v2.5/src/components/List/CriterionEditor.tsx index fdf5bcad7..e35ba50c2 100644 --- a/ui/v2.5/src/components/List/CriterionEditor.tsx +++ b/ui/v2.5/src/components/List/CriterionEditor.tsx @@ -77,17 +77,15 @@ const GenericCriterionEditor: React.FC = ({ return ( - {modifierOptions.map((c) => ( + {modifierOptions.map((m) => ( ))} diff --git a/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx b/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx index 8d056af0d..483cb7400 100644 --- a/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useMemo } from "react"; import { PerformersCriterion } from "src/models/list-filter/criteria/performers"; import { useFindPerformersQuery } from "src/core/generated-graphql"; import { ObjectsFilter } from "./SelectableFilter"; @@ -9,7 +9,7 @@ interface IPerformersFilter { } function usePerformerQuery(query: string) { - const results = useFindPerformersQuery({ + const { data, loading } = useFindPerformersQuery({ variables: { filter: { q: query, @@ -18,14 +18,18 @@ function usePerformerQuery(query: string) { }, }); - return ( - results.data?.findPerformers.performers.map((p) => { - return { - id: p.id, - label: p.name, - }; - }) ?? [] + const results = useMemo( + () => + data?.findPerformers.performers.map((p) => { + return { + id: p.id, + label: p.name, + }; + }) ?? [], + [data] ); + + return { results, loading }; } const PerformersFilter: React.FC = ({ @@ -36,7 +40,7 @@ const PerformersFilter: React.FC = ({ ); }; diff --git a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx index 48caccb16..2c13eb57e 100644 --- a/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/SelectableFilter.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Button, Form } from "react-bootstrap"; import { Icon } from "src/components/Shared/Icon"; import { @@ -14,7 +14,7 @@ import { ILabeledId, ILabeledValueListValue, } from "src/models/list-filter/types"; -import { cloneDeep, debounce } from "lodash-es"; +import { cloneDeep } from "lodash-es"; import { Criterion, IHierarchicalLabeledIdCriterion, @@ -22,6 +22,8 @@ import { import { defineMessages, MessageDescriptor, useIntl } from "react-intl"; import { CriterionModifier } from "src/core/generated-graphql"; import { keyboardClickHandler } from "src/utils/keyboard"; +import { useDebouncedSetState } from "src/hooks/debounce"; +import useFocus from "src/utils/focus"; interface ISelectedItem { item: ILabeledId; @@ -77,40 +79,29 @@ const SelectedItem: React.FC = ({ interface ISelectableFilter { query: string; - setQuery: (query: string) => void; - single: boolean; - includeOnly: boolean; + onQueryChange: (query: string) => void; + modifier: CriterionModifier; + inputFocus: ReturnType; + canExclude: boolean; queryResults: ILabeledId[]; selected: ILabeledId[]; excluded: ILabeledId[]; - onSelect: (value: ILabeledId, include: boolean) => void; + onSelect: (value: ILabeledId, exclude: boolean) => void; onUnselect: (value: ILabeledId) => void; } const SelectableFilter: React.FC = ({ query, - setQuery, - single, + onQueryChange, + modifier, + inputFocus, + canExclude, queryResults, selected, excluded, - includeOnly, onSelect, onUnselect, }) => { - const [internalQuery, setInternalQuery] = useState(query); - - const onInputChange = useMemo(() => { - return debounce((input: string) => { - setQuery(input); - }, 250); - }, [setQuery]); - - function onInternalInputChange(input: string) { - setInternalQuery(input); - onInputChange(input); - } - const objects = useMemo(() => { return queryResults.filter( (p) => @@ -119,8 +110,10 @@ const SelectableFilter: React.FC = ({ ); }, [queryResults, selected, excluded]); - const includingOnly = includeOnly || (selected.length > 0 && single); - const excludingOnly = excluded.length > 0 && single; + const includingOnly = modifier == CriterionModifier.Equals; + const excludingOnly = + modifier == CriterionModifier.Excludes || + modifier == CriterionModifier.NotEquals; const includeIcon = ; const excludeIcon = ; @@ -128,13 +121,18 @@ const SelectableFilter: React.FC = ({ return (
onInternalInputChange(v)} + focus={inputFocus} + value={query} + setValue={(v) => onQueryChange(v)} />