mirror of https://github.com/stashapp/stash.git
460 lines
11 KiB
Go
460 lines
11 KiB
Go
// Package match provides functions for matching paths to models.
|
|
package match
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/stashapp/stash/pkg/gallery"
|
|
"github.com/stashapp/stash/pkg/image"
|
|
"github.com/stashapp/stash/pkg/models"
|
|
"github.com/stashapp/stash/pkg/scene"
|
|
"github.com/stashapp/stash/pkg/sliceutil"
|
|
)
|
|
|
|
const (
|
|
separatorChars = `.\-_ `
|
|
separatorPattern = `(?:_|[^\p{L}\w\d])+`
|
|
|
|
reNotLetterWordUnicode = `[^\p{L}\w\d]`
|
|
reNotLetterWord = `[^\w\d]`
|
|
)
|
|
|
|
var separatorRE = regexp.MustCompile(separatorPattern)
|
|
|
|
func getPathQueryRegex(name string) string {
|
|
// escape specific regex characters
|
|
name = regexp.QuoteMeta(name)
|
|
|
|
// handle path separators
|
|
const separator = `[` + separatorChars + `]`
|
|
|
|
ret := strings.ReplaceAll(name, " ", separator+"*")
|
|
|
|
ret = `(?:^|_|[^\p{L}\d])` + ret + `(?:$|_|[^\p{L}\d])`
|
|
return ret
|
|
}
|
|
|
|
func getPathWords(path string, trimExt bool) []string {
|
|
retStr := path
|
|
|
|
if trimExt {
|
|
// remove the extension
|
|
ext := filepath.Ext(retStr)
|
|
if ext != "" {
|
|
retStr = strings.TrimSuffix(retStr, ext)
|
|
}
|
|
}
|
|
|
|
// handle path separators
|
|
retStr = separatorRE.ReplaceAllString(retStr, " ")
|
|
|
|
words := strings.Split(retStr, " ")
|
|
|
|
// remove any single letter words
|
|
var ret []string
|
|
for _, w := range words {
|
|
if utf8.RuneCountInString(w) > 1 {
|
|
// #1450 - we need to open up the criteria for matching so that we
|
|
// can match where path has no space between subject names -
|
|
// ie name = "foo bar" - path = "foobar"
|
|
// we post-match afterwards, so we can afford to be a little loose
|
|
// with the query
|
|
// just use the first two characters
|
|
// #2293 - need to convert to unicode runes for the substring, otherwise
|
|
// the resulting string is corrupted.
|
|
ret = sliceutil.AppendUnique(ret, string([]rune(w)[0:2]))
|
|
}
|
|
}
|
|
|
|
return ret
|
|
}
|
|
|
|
// https://stackoverflow.com/a/53069799
|
|
func allASCII(s string) bool {
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] > unicode.MaxASCII {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// nameMatchesPath returns the index in the path for the right-most match.
|
|
// Returns -1 if not found.
|
|
func nameMatchesPath(name, path string) int {
|
|
// #2363 - optimisation: only use unicode character regexp if path contains
|
|
// unicode characters
|
|
re := nameToRegexp(name, !allASCII(path))
|
|
return regexpMatchesPath(re, path)
|
|
}
|
|
|
|
// nameToRegexp compiles a regexp pattern to match paths from the given name.
|
|
// Set useUnicode to true if this regexp is to be used on any strings with unicode characters.
|
|
func nameToRegexp(name string, useUnicode bool) *regexp.Regexp {
|
|
// escape specific regex characters
|
|
name = regexp.QuoteMeta(name)
|
|
|
|
name = strings.ToLower(name)
|
|
|
|
// handle path separators
|
|
const separator = `[` + separatorChars + `]`
|
|
|
|
// performance optimisation: only use \p{L} is useUnicode is true
|
|
notWord := reNotLetterWord
|
|
if useUnicode {
|
|
notWord = reNotLetterWordUnicode
|
|
}
|
|
|
|
reStr := strings.ReplaceAll(name, " ", separator+"*")
|
|
reStr = `(?:^|_|` + notWord + `)` + reStr + `(?:$|_|` + notWord + `)`
|
|
|
|
re := regexp.MustCompile(reStr)
|
|
return re
|
|
}
|
|
|
|
func regexpMatchesPath(r *regexp.Regexp, path string) int {
|
|
path = strings.ToLower(path)
|
|
found := r.FindAllStringIndex(path, -1)
|
|
if found == nil {
|
|
return -1
|
|
}
|
|
return found[len(found)-1][0]
|
|
}
|
|
|
|
func getPerformers(ctx context.Context, words []string, performerReader models.PerformerAutoTagQueryer, cache *Cache) ([]*models.Performer, error) {
|
|
performers, err := performerReader.QueryForAutoTag(ctx, words)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
swPerformers, err := getSingleLetterPerformers(ctx, cache, performerReader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return append(performers, swPerformers...), nil
|
|
}
|
|
|
|
func PathToPerformers(ctx context.Context, path string, reader models.PerformerAutoTagQueryer, cache *Cache, trimExt bool) ([]*models.Performer, error) {
|
|
words := getPathWords(path, trimExt)
|
|
|
|
performers, err := getPerformers(ctx, words, reader, cache)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var ret []*models.Performer
|
|
for _, p := range performers {
|
|
matches := false
|
|
if nameMatchesPath(p.Name, path) != -1 {
|
|
matches = true
|
|
}
|
|
|
|
// TODO - disabled alias matching until we can get finer
|
|
// control over the matching
|
|
// if !matches {
|
|
// if err := p.LoadAliases(ctx, reader); err != nil {
|
|
// return nil, err
|
|
// }
|
|
|
|
// for _, alias := range p.Aliases.List() {
|
|
// if nameMatchesPath(alias, path) != -1 {
|
|
// matches = true
|
|
// break
|
|
// }
|
|
// }
|
|
// }
|
|
|
|
if matches {
|
|
ret = append(ret, p)
|
|
}
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
func getStudios(ctx context.Context, words []string, reader models.StudioAutoTagQueryer, cache *Cache) ([]*models.Studio, error) {
|
|
studios, err := reader.QueryForAutoTag(ctx, words)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
swStudios, err := getSingleLetterStudios(ctx, cache, reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return append(studios, swStudios...), nil
|
|
}
|
|
|
|
// PathToStudio returns the Studio that matches the given path.
|
|
// Where multiple matching studios are found, the one that matches the latest
|
|
// position in the path is returned.
|
|
func PathToStudio(ctx context.Context, path string, reader models.StudioAutoTagQueryer, cache *Cache, trimExt bool) (*models.Studio, error) {
|
|
words := getPathWords(path, trimExt)
|
|
candidates, err := getStudios(ctx, words, reader, cache)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var ret *models.Studio
|
|
index := -1
|
|
for _, c := range candidates {
|
|
matchIndex := nameMatchesPath(c.Name, path)
|
|
if matchIndex != -1 && matchIndex > index {
|
|
ret = c
|
|
index = matchIndex
|
|
}
|
|
|
|
aliases, err := reader.GetAliases(ctx, c.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, alias := range aliases {
|
|
matchIndex = nameMatchesPath(alias, path)
|
|
if matchIndex != -1 && matchIndex > index {
|
|
ret = c
|
|
index = matchIndex
|
|
}
|
|
}
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
func getTags(ctx context.Context, words []string, reader models.TagAutoTagQueryer, cache *Cache) ([]*models.Tag, error) {
|
|
tags, err := reader.QueryForAutoTag(ctx, words)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
swTags, err := getSingleLetterTags(ctx, cache, reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return append(tags, swTags...), nil
|
|
}
|
|
|
|
func PathToTags(ctx context.Context, path string, reader models.TagAutoTagQueryer, cache *Cache, trimExt bool) ([]*models.Tag, error) {
|
|
words := getPathWords(path, trimExt)
|
|
tags, err := getTags(ctx, words, reader, cache)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var ret []*models.Tag
|
|
for _, t := range tags {
|
|
matches := false
|
|
if nameMatchesPath(t.Name, path) != -1 {
|
|
matches = true
|
|
}
|
|
|
|
if !matches {
|
|
aliases, err := reader.GetAliases(ctx, t.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, alias := range aliases {
|
|
if nameMatchesPath(alias, path) != -1 {
|
|
matches = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if matches {
|
|
ret = append(ret, t)
|
|
}
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
func PathToScenesFn(ctx context.Context, name string, paths []string, sceneReader models.SceneQueryer, fn func(ctx context.Context, scene *models.Scene) error) error {
|
|
regex := getPathQueryRegex(name)
|
|
organized := false
|
|
filter := models.SceneFilterType{
|
|
Path: &models.StringCriterionInput{
|
|
Value: "(?i)" + regex,
|
|
Modifier: models.CriterionModifierMatchesRegex,
|
|
},
|
|
Organized: &organized,
|
|
}
|
|
|
|
filter.And = scene.PathsFilter(paths)
|
|
|
|
// do in batches
|
|
pp := 1000
|
|
sort := "id"
|
|
sortDir := models.SortDirectionEnumAsc
|
|
lastID := 0
|
|
|
|
for {
|
|
if lastID != 0 {
|
|
filter.ID = &models.IntCriterionInput{
|
|
Value: lastID,
|
|
Modifier: models.CriterionModifierGreaterThan,
|
|
}
|
|
}
|
|
|
|
scenes, err := scene.Query(ctx, sceneReader, &filter, &models.FindFilterType{
|
|
PerPage: &pp,
|
|
Sort: &sort,
|
|
Direction: &sortDir,
|
|
})
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("error querying scenes with regex '%s': %s", regex, err.Error())
|
|
}
|
|
|
|
// paths may have unicode characters
|
|
const useUnicode = true
|
|
|
|
r := nameToRegexp(name, useUnicode)
|
|
for _, p := range scenes {
|
|
if regexpMatchesPath(r, p.Path) != -1 {
|
|
if err := fn(ctx, p); err != nil {
|
|
return fmt.Errorf("processing scene %s: %w", p.GetTitle(), err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(scenes) < pp {
|
|
break
|
|
}
|
|
|
|
lastID = scenes[len(scenes)-1].ID
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func PathToImagesFn(ctx context.Context, name string, paths []string, imageReader models.ImageQueryer, fn func(ctx context.Context, scene *models.Image) error) error {
|
|
regex := getPathQueryRegex(name)
|
|
organized := false
|
|
filter := models.ImageFilterType{
|
|
Path: &models.StringCriterionInput{
|
|
Value: "(?i)" + regex,
|
|
Modifier: models.CriterionModifierMatchesRegex,
|
|
},
|
|
Organized: &organized,
|
|
}
|
|
|
|
filter.And = image.PathsFilter(paths)
|
|
|
|
// do in batches
|
|
pp := 1000
|
|
sort := "id"
|
|
sortDir := models.SortDirectionEnumAsc
|
|
lastID := 0
|
|
|
|
for {
|
|
if lastID != 0 {
|
|
filter.ID = &models.IntCriterionInput{
|
|
Value: lastID,
|
|
Modifier: models.CriterionModifierGreaterThan,
|
|
}
|
|
}
|
|
|
|
images, err := image.Query(ctx, imageReader, &filter, &models.FindFilterType{
|
|
PerPage: &pp,
|
|
Sort: &sort,
|
|
Direction: &sortDir,
|
|
})
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("error querying images with regex '%s': %s", regex, err.Error())
|
|
}
|
|
|
|
// paths may have unicode characters
|
|
const useUnicode = true
|
|
|
|
r := nameToRegexp(name, useUnicode)
|
|
for _, p := range images {
|
|
if regexpMatchesPath(r, p.Path) != -1 {
|
|
if err := fn(ctx, p); err != nil {
|
|
return fmt.Errorf("processing image %s: %w", p.GetTitle(), err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(images) < pp {
|
|
break
|
|
}
|
|
|
|
lastID = images[len(images)-1].ID
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func PathToGalleriesFn(ctx context.Context, name string, paths []string, galleryReader models.GalleryQueryer, fn func(ctx context.Context, scene *models.Gallery) error) error {
|
|
regex := getPathQueryRegex(name)
|
|
organized := false
|
|
filter := models.GalleryFilterType{
|
|
Path: &models.StringCriterionInput{
|
|
Value: "(?i)" + regex,
|
|
Modifier: models.CriterionModifierMatchesRegex,
|
|
},
|
|
Organized: &organized,
|
|
}
|
|
|
|
filter.And = gallery.PathsFilter(paths)
|
|
|
|
// do in batches
|
|
pp := 1000
|
|
sort := "id"
|
|
sortDir := models.SortDirectionEnumAsc
|
|
lastID := 0
|
|
|
|
for {
|
|
if lastID != 0 {
|
|
filter.ID = &models.IntCriterionInput{
|
|
Value: lastID,
|
|
Modifier: models.CriterionModifierGreaterThan,
|
|
}
|
|
}
|
|
|
|
galleries, _, err := galleryReader.Query(ctx, &filter, &models.FindFilterType{
|
|
PerPage: &pp,
|
|
Sort: &sort,
|
|
Direction: &sortDir,
|
|
})
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("error querying galleries with regex '%s': %s", regex, err.Error())
|
|
}
|
|
|
|
// paths may have unicode characters
|
|
const useUnicode = true
|
|
|
|
r := nameToRegexp(name, useUnicode)
|
|
for _, p := range galleries {
|
|
path := p.Path
|
|
if path != "" && regexpMatchesPath(r, path) != -1 {
|
|
if err := fn(ctx, p); err != nil {
|
|
return fmt.Errorf("processing gallery %s: %w", p.GetTitle(), err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(galleries) < pp {
|
|
break
|
|
}
|
|
|
|
lastID = galleries[len(galleries)-1].ID
|
|
}
|
|
|
|
return nil
|
|
}
|