mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
Performers, Tags and Studio from scene filename (#174)
* Make regex matching case-insensitive * Port filename parser code to backend * Add performers to scene filename parser UI * Finish porting parser to backend * Add performer, studio and tag parsing * Hide fields not being parsed * Don't query for empty performer/studio/tag * Use exact matches * Fix panic * Fix arrays changed false positive. Fix layout
This commit is contained in:
@@ -31,3 +31,23 @@ query FindScene($id: ID!, $checksum: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query ParseSceneFilenames($filter: FindFilterType!, $config: SceneParserInput!) {
|
||||||
|
parseSceneFilenames(filter: $filter, config: $config) {
|
||||||
|
count
|
||||||
|
results {
|
||||||
|
scene {
|
||||||
|
...SlimSceneData
|
||||||
|
}
|
||||||
|
title
|
||||||
|
details
|
||||||
|
url
|
||||||
|
date
|
||||||
|
rating
|
||||||
|
studio_id
|
||||||
|
gallery_id
|
||||||
|
performer_ids
|
||||||
|
tag_ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ type Query {
|
|||||||
|
|
||||||
findScenesByPathRegex(filter: FindFilterType): FindScenesResultType!
|
findScenesByPathRegex(filter: FindFilterType): FindScenesResultType!
|
||||||
|
|
||||||
|
parseSceneFilenames(filter: FindFilterType, config: SceneParserInput!): SceneParserResultType!
|
||||||
|
|
||||||
"""A function which queries SceneMarker objects"""
|
"""A function which queries SceneMarker objects"""
|
||||||
findSceneMarkers(scene_marker_filter: SceneMarkerFilterType filter: FindFilterType): FindSceneMarkersResultType!
|
findSceneMarkers(scene_marker_filter: SceneMarkerFilterType filter: FindFilterType): FindSceneMarkersResultType!
|
||||||
|
|
||||||
|
|||||||
@@ -77,3 +77,27 @@ type FindScenesResultType {
|
|||||||
count: Int!
|
count: Int!
|
||||||
scenes: [Scene!]!
|
scenes: [Scene!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input SceneParserInput {
|
||||||
|
ignoreWords: [String!],
|
||||||
|
whitespaceCharacters: String,
|
||||||
|
capitalizeTitle: Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type SceneParserResult {
|
||||||
|
scene: Scene!
|
||||||
|
title: String
|
||||||
|
details: String
|
||||||
|
url: String
|
||||||
|
date: String
|
||||||
|
rating: Int
|
||||||
|
studio_id: ID
|
||||||
|
gallery_id: ID
|
||||||
|
performer_ids: [ID!]
|
||||||
|
tag_ids: [ID!]
|
||||||
|
}
|
||||||
|
|
||||||
|
type SceneParserResultType {
|
||||||
|
count: Int!
|
||||||
|
results: [SceneParserResult!]!
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"github.com/stashapp/stash/pkg/models"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/manager"
|
||||||
|
"github.com/stashapp/stash/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *queryResolver) FindScene(ctx context.Context, id *string, checksum *string) (*models.Scene, error) {
|
func (r *queryResolver) FindScene(ctx context.Context, id *string, checksum *string) (*models.Scene, error) {
|
||||||
@@ -37,3 +39,18 @@ func (r *queryResolver) FindScenesByPathRegex(ctx context.Context, filter *model
|
|||||||
Scenes: scenes,
|
Scenes: scenes,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *queryResolver) ParseSceneFilenames(ctx context.Context, filter *models.FindFilterType, config models.SceneParserInput) (*models.SceneParserResultType, error) {
|
||||||
|
parser := manager.NewSceneFilenameParser(filter, config)
|
||||||
|
|
||||||
|
result, count, err := parser.Parse()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.SceneParserResultType{
|
||||||
|
Count: count,
|
||||||
|
Results: result,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|||||||
616
pkg/manager/filename_parser.go
Normal file
616
pkg/manager/filename_parser.go
Normal file
@@ -0,0 +1,616 @@
|
|||||||
|
package manager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/models"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type parserField struct {
|
||||||
|
field string
|
||||||
|
fieldRegex *regexp.Regexp
|
||||||
|
regex string
|
||||||
|
isFullDateField bool
|
||||||
|
isCaptured bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newParserField(field string, regex string, captured bool) parserField {
|
||||||
|
ret := parserField{
|
||||||
|
field: field,
|
||||||
|
isFullDateField: false,
|
||||||
|
isCaptured: captured,
|
||||||
|
}
|
||||||
|
|
||||||
|
ret.fieldRegex, _ = regexp.Compile(`\{` + ret.field + `\}`)
|
||||||
|
|
||||||
|
regexStr := regex
|
||||||
|
|
||||||
|
if captured {
|
||||||
|
regexStr = "(" + regexStr + ")"
|
||||||
|
}
|
||||||
|
ret.regex = regexStr
|
||||||
|
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFullDateParserField(field string, regex string) parserField {
|
||||||
|
ret := newParserField(field, regex, true)
|
||||||
|
ret.isFullDateField = true
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f parserField) replaceInPattern(pattern string) string {
|
||||||
|
return string(f.fieldRegex.ReplaceAllString(pattern, f.regex))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f parserField) getFieldPattern() string {
|
||||||
|
return "{" + f.field + "}"
|
||||||
|
}
|
||||||
|
|
||||||
|
var validFields map[string]parserField
|
||||||
|
var escapeCharRE *regexp.Regexp
|
||||||
|
var capitalizeTitleRE *regexp.Regexp
|
||||||
|
var multiWSRE *regexp.Regexp
|
||||||
|
var delimiterRE *regexp.Regexp
|
||||||
|
|
||||||
|
func compileREs() {
|
||||||
|
const escapeCharPattern = `([\-\.\(\)\[\]])`
|
||||||
|
escapeCharRE = regexp.MustCompile(escapeCharPattern)
|
||||||
|
|
||||||
|
const capitaliseTitlePattern = `(?:^| )\w`
|
||||||
|
capitalizeTitleRE = regexp.MustCompile(capitaliseTitlePattern)
|
||||||
|
|
||||||
|
const multiWSPattern = ` {2,}`
|
||||||
|
multiWSRE = regexp.MustCompile(multiWSPattern)
|
||||||
|
|
||||||
|
const delimiterPattern = `(?:\.|-|_)`
|
||||||
|
delimiterRE = regexp.MustCompile(delimiterPattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
func initParserFields() {
|
||||||
|
if validFields != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ret := make(map[string]parserField)
|
||||||
|
|
||||||
|
ret["title"] = newParserField("title", ".*", true)
|
||||||
|
ret["ext"] = newParserField("ext", ".*$", false)
|
||||||
|
|
||||||
|
//I = new ParserField("i", undefined, "Matches any ignored word", false);
|
||||||
|
|
||||||
|
ret["d"] = newParserField("d", `(?:\.|-|_)`, false)
|
||||||
|
ret["performer"] = newParserField("performer", ".*", true)
|
||||||
|
ret["studio"] = newParserField("studio", ".*", true)
|
||||||
|
ret["tag"] = newParserField("tag", ".*", true)
|
||||||
|
|
||||||
|
// date fields
|
||||||
|
ret["date"] = newParserField("date", `\d{4}-\d{2}-\d{2}`, true)
|
||||||
|
ret["yyyy"] = newParserField("yyyy", `\d{4}`, true)
|
||||||
|
ret["yy"] = newParserField("yy", `\d{2}`, true)
|
||||||
|
ret["mm"] = newParserField("mm", `\d{2}`, true)
|
||||||
|
ret["dd"] = newParserField("dd", `\d{2}`, true)
|
||||||
|
ret["yyyymmdd"] = newFullDateParserField("yyyymmdd", `\d{8}`)
|
||||||
|
ret["yymmdd"] = newFullDateParserField("yymmdd", `\d{6}`)
|
||||||
|
ret["ddmmyyyy"] = newFullDateParserField("ddmmyyyy", `\d{8}`)
|
||||||
|
ret["ddmmyy"] = newFullDateParserField("ddmmyy", `\d{6}`)
|
||||||
|
ret["mmddyyyy"] = newFullDateParserField("mmddyyyy", `\d{8}`)
|
||||||
|
ret["mmddyy"] = newFullDateParserField("mmddyy", `\d{6}`)
|
||||||
|
|
||||||
|
validFields = ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func replacePatternWithRegex(pattern string, ignoreWords []string) string {
|
||||||
|
initParserFields()
|
||||||
|
|
||||||
|
for _, field := range validFields {
|
||||||
|
pattern = field.replaceInPattern(pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreClause := getIgnoreClause(ignoreWords)
|
||||||
|
ignoreField := newParserField("i", ignoreClause, false)
|
||||||
|
pattern = ignoreField.replaceInPattern(pattern)
|
||||||
|
|
||||||
|
return pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
type parseMapper struct {
|
||||||
|
fields []string
|
||||||
|
regexString string
|
||||||
|
regex *regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIgnoreClause(ignoreFields []string) string {
|
||||||
|
if len(ignoreFields) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var ignoreClauses []string
|
||||||
|
|
||||||
|
for _, v := range ignoreFields {
|
||||||
|
newVal := string(escapeCharRE.ReplaceAllString(v, `\$1`))
|
||||||
|
newVal = strings.TrimSpace(newVal)
|
||||||
|
newVal = "(?:" + newVal + ")"
|
||||||
|
ignoreClauses = append(ignoreClauses, newVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "(?:" + strings.Join(ignoreClauses, "|") + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
func newParseMapper(pattern string, ignoreFields []string) (*parseMapper, error) {
|
||||||
|
ret := &parseMapper{}
|
||||||
|
|
||||||
|
// escape control characters
|
||||||
|
regex := escapeCharRE.ReplaceAllString(pattern, `\$1`)
|
||||||
|
|
||||||
|
// replace {} with wildcard
|
||||||
|
braceRE := regexp.MustCompile(`\{\}`)
|
||||||
|
regex = braceRE.ReplaceAllString(regex, ".*")
|
||||||
|
|
||||||
|
// replace all known fields with applicable regexes
|
||||||
|
regex = replacePatternWithRegex(regex, ignoreFields)
|
||||||
|
|
||||||
|
ret.regexString = regex
|
||||||
|
|
||||||
|
// make case insensitive
|
||||||
|
regex = "(?i)" + regex
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
ret.regex, err = regexp.Compile(regex)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// find invalid fields
|
||||||
|
invalidRE := regexp.MustCompile(`\{[A-Za-z]+\}`)
|
||||||
|
foundInvalid := invalidRE.FindAllString(regex, -1)
|
||||||
|
if len(foundInvalid) > 0 {
|
||||||
|
return nil, errors.New("Invalid fields: " + strings.Join(foundInvalid, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldExtractor := regexp.MustCompile(`\{([A-Za-z]+)\}`)
|
||||||
|
|
||||||
|
result := fieldExtractor.FindAllStringSubmatch(pattern, -1)
|
||||||
|
|
||||||
|
var fields []string
|
||||||
|
for _, v := range result {
|
||||||
|
field := v[1]
|
||||||
|
|
||||||
|
// only add to fields if it is captured
|
||||||
|
parserField, found := validFields[field]
|
||||||
|
if found && parserField.isCaptured {
|
||||||
|
fields = append(fields, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ret.fields = fields
|
||||||
|
|
||||||
|
return ret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type sceneHolder struct {
|
||||||
|
scene *models.Scene
|
||||||
|
result *models.Scene
|
||||||
|
yyyy string
|
||||||
|
mm string
|
||||||
|
dd string
|
||||||
|
performers []string
|
||||||
|
studio string
|
||||||
|
tags []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSceneHolder(scene *models.Scene) *sceneHolder {
|
||||||
|
sceneCopy := models.Scene{
|
||||||
|
ID: scene.ID,
|
||||||
|
Checksum: scene.Checksum,
|
||||||
|
Path: scene.Path,
|
||||||
|
}
|
||||||
|
ret := sceneHolder{
|
||||||
|
scene: scene,
|
||||||
|
result: &sceneCopy,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDate(dateStr string) bool {
|
||||||
|
splits := strings.Split(dateStr, "-")
|
||||||
|
if len(splits) != 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
year, _ := strconv.Atoi(splits[0])
|
||||||
|
month, _ := strconv.Atoi(splits[1])
|
||||||
|
d, _ := strconv.Atoi(splits[2])
|
||||||
|
|
||||||
|
// assume year must be between 1900 and 2100
|
||||||
|
if year < 1900 || year > 2100 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if month < 1 || month > 12 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// not checking individual months to ensure date is in the correct range
|
||||||
|
if d < 1 || d > 31 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *sceneHolder) setDate(field *parserField, value string) {
|
||||||
|
yearIndex := 0
|
||||||
|
yearLength := len(strings.Split(field.field, "y")) - 1
|
||||||
|
dateIndex := 0
|
||||||
|
monthIndex := 0
|
||||||
|
|
||||||
|
switch field.field {
|
||||||
|
case "yyyymmdd", "yymmdd":
|
||||||
|
monthIndex = yearLength
|
||||||
|
dateIndex = monthIndex + 2
|
||||||
|
case "ddmmyyyy", "ddmmyy":
|
||||||
|
monthIndex = 2
|
||||||
|
yearIndex = monthIndex + 2
|
||||||
|
case "mmddyyyy", "mmddyy":
|
||||||
|
dateIndex = monthIndex + 2
|
||||||
|
yearIndex = dateIndex + 2
|
||||||
|
}
|
||||||
|
|
||||||
|
yearValue := value[yearIndex : yearIndex+yearLength]
|
||||||
|
monthValue := value[monthIndex : monthIndex+2]
|
||||||
|
dateValue := value[dateIndex : dateIndex+2]
|
||||||
|
|
||||||
|
fullDate := yearValue + "-" + monthValue + "-" + dateValue
|
||||||
|
|
||||||
|
// ensure the date is valid
|
||||||
|
// only set if new value is different from the old
|
||||||
|
if validateDate(fullDate) && h.scene.Date.String != fullDate {
|
||||||
|
h.result.Date = models.SQLiteDate{
|
||||||
|
String: fullDate,
|
||||||
|
Valid: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *sceneHolder) setField(field parserField, value interface{}) {
|
||||||
|
if field.isFullDateField {
|
||||||
|
h.setDate(&field, value.(string))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch field.field {
|
||||||
|
case "title":
|
||||||
|
h.result.Title = sql.NullString{
|
||||||
|
String: value.(string),
|
||||||
|
Valid: true,
|
||||||
|
}
|
||||||
|
case "date":
|
||||||
|
if validateDate(value.(string)) {
|
||||||
|
h.result.Date = models.SQLiteDate{
|
||||||
|
String: value.(string),
|
||||||
|
Valid: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "performer":
|
||||||
|
// add performer to list
|
||||||
|
h.performers = append(h.performers, value.(string))
|
||||||
|
case "studio":
|
||||||
|
h.studio = value.(string)
|
||||||
|
case "tag":
|
||||||
|
h.tags = append(h.tags, value.(string))
|
||||||
|
case "yyyy":
|
||||||
|
h.yyyy = value.(string)
|
||||||
|
break
|
||||||
|
case "yy":
|
||||||
|
v := value.(string)
|
||||||
|
v = "20" + v
|
||||||
|
h.yyyy = v
|
||||||
|
break
|
||||||
|
case "mm":
|
||||||
|
h.mm = value.(string)
|
||||||
|
break
|
||||||
|
case "dd":
|
||||||
|
h.dd = value.(string)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *sceneHolder) postParse() {
|
||||||
|
// set the date if the components are set
|
||||||
|
if h.yyyy != "" && h.mm != "" && h.dd != "" {
|
||||||
|
fullDate := h.yyyy + "-" + h.mm + "-" + h.dd
|
||||||
|
h.setField(validFields["date"], fullDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m parseMapper) parse(scene *models.Scene) *sceneHolder {
|
||||||
|
// perform matching on basename
|
||||||
|
// TODO - may want to handle full path at some point
|
||||||
|
filename := filepath.Base(scene.Path)
|
||||||
|
|
||||||
|
result := m.regex.FindStringSubmatch(filename)
|
||||||
|
|
||||||
|
if len(result) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
initParserFields()
|
||||||
|
|
||||||
|
sceneHolder := newSceneHolder(scene)
|
||||||
|
|
||||||
|
for index, match := range result {
|
||||||
|
if index == 0 {
|
||||||
|
// skip entire match
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
field := m.fields[index-1]
|
||||||
|
parserField, found := validFields[field]
|
||||||
|
if found {
|
||||||
|
sceneHolder.setField(parserField, match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sceneHolder.postParse()
|
||||||
|
|
||||||
|
return sceneHolder
|
||||||
|
}
|
||||||
|
|
||||||
|
type performerQueryer interface {
|
||||||
|
FindByNames(names []string, tx *sqlx.Tx) ([]*models.Performer, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type sceneQueryer interface {
|
||||||
|
QueryByPathRegex(findFilter *models.FindFilterType) ([]*models.Scene, int)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tagQueryer interface {
|
||||||
|
FindByName(name string, tx *sqlx.Tx) (*models.Tag, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type studioQueryer interface {
|
||||||
|
FindByName(name string, tx *sqlx.Tx) (*models.Studio, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SceneFilenameParser struct {
|
||||||
|
Pattern string
|
||||||
|
ParserInput models.SceneParserInput
|
||||||
|
Filter *models.FindFilterType
|
||||||
|
whitespaceRE *regexp.Regexp
|
||||||
|
performerCache map[string]*models.Performer
|
||||||
|
studioCache map[string]*models.Studio
|
||||||
|
tagCache map[string]*models.Tag
|
||||||
|
|
||||||
|
performerQuery performerQueryer
|
||||||
|
sceneQuery sceneQueryer
|
||||||
|
tagQuery tagQueryer
|
||||||
|
studioQuery studioQueryer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSceneFilenameParser(filter *models.FindFilterType, config models.SceneParserInput) *SceneFilenameParser {
|
||||||
|
p := &SceneFilenameParser{
|
||||||
|
Pattern: *filter.Q,
|
||||||
|
ParserInput: config,
|
||||||
|
Filter: filter,
|
||||||
|
}
|
||||||
|
|
||||||
|
p.performerCache = make(map[string]*models.Performer)
|
||||||
|
p.studioCache = make(map[string]*models.Studio)
|
||||||
|
p.tagCache = make(map[string]*models.Tag)
|
||||||
|
|
||||||
|
p.initWhiteSpaceRegex()
|
||||||
|
|
||||||
|
performerQuery := models.NewPerformerQueryBuilder()
|
||||||
|
p.performerQuery = &performerQuery
|
||||||
|
|
||||||
|
sceneQuery := models.NewSceneQueryBuilder()
|
||||||
|
p.sceneQuery = &sceneQuery
|
||||||
|
|
||||||
|
tagQuery := models.NewTagQueryBuilder()
|
||||||
|
p.tagQuery = &tagQuery
|
||||||
|
|
||||||
|
studioQuery := models.NewStudioQueryBuilder()
|
||||||
|
p.studioQuery = &studioQuery
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SceneFilenameParser) initWhiteSpaceRegex() {
|
||||||
|
compileREs()
|
||||||
|
|
||||||
|
wsChars := ""
|
||||||
|
if p.ParserInput.WhitespaceCharacters != nil {
|
||||||
|
wsChars = *p.ParserInput.WhitespaceCharacters
|
||||||
|
wsChars = strings.TrimSpace(wsChars)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(wsChars) > 0 {
|
||||||
|
wsRegExp := escapeCharRE.ReplaceAllString(wsChars, `\$1`)
|
||||||
|
wsRegExp = "[" + wsRegExp + "]"
|
||||||
|
p.whitespaceRE = regexp.MustCompile(wsRegExp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SceneFilenameParser) Parse() ([]*models.SceneParserResult, int, error) {
|
||||||
|
// perform the query to find the scenes
|
||||||
|
mapper, err := newParseMapper(p.Pattern, p.ParserInput.IgnoreWords)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Filter.Q = &mapper.regexString
|
||||||
|
|
||||||
|
scenes, total := p.sceneQuery.QueryByPathRegex(p.Filter)
|
||||||
|
|
||||||
|
ret := p.parseScenes(scenes, mapper)
|
||||||
|
|
||||||
|
return ret, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SceneFilenameParser) parseScenes(scenes []*models.Scene, mapper *parseMapper) []*models.SceneParserResult {
|
||||||
|
var ret []*models.SceneParserResult
|
||||||
|
for _, scene := range scenes {
|
||||||
|
sceneHolder := mapper.parse(scene)
|
||||||
|
|
||||||
|
if sceneHolder != nil {
|
||||||
|
r := &models.SceneParserResult{
|
||||||
|
Scene: scene,
|
||||||
|
}
|
||||||
|
p.setParserResult(*sceneHolder, r)
|
||||||
|
|
||||||
|
if r != nil {
|
||||||
|
ret = append(ret, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p SceneFilenameParser) replaceWhitespaceCharacters(value string) string {
|
||||||
|
if p.whitespaceRE != nil {
|
||||||
|
value = p.whitespaceRE.ReplaceAllString(value, " ")
|
||||||
|
// remove consecutive spaces
|
||||||
|
value = multiWSRE.ReplaceAllString(value, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SceneFilenameParser) queryPerformer(performerName string) *models.Performer {
|
||||||
|
// massage the performer name
|
||||||
|
performerName = delimiterRE.ReplaceAllString(performerName, " ")
|
||||||
|
|
||||||
|
// check cache first
|
||||||
|
if ret, found := p.performerCache[performerName]; found {
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
// perform an exact match and grab the first
|
||||||
|
performers, _ := p.performerQuery.FindByNames([]string{performerName}, nil)
|
||||||
|
|
||||||
|
var ret *models.Performer
|
||||||
|
if len(performers) > 0 {
|
||||||
|
ret = performers[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// add result to cache
|
||||||
|
p.performerCache[performerName] = ret
|
||||||
|
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SceneFilenameParser) queryStudio(studioName string) *models.Studio {
|
||||||
|
// massage the performer name
|
||||||
|
studioName = delimiterRE.ReplaceAllString(studioName, " ")
|
||||||
|
|
||||||
|
// check cache first
|
||||||
|
if ret, found := p.studioCache[studioName]; found {
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
ret, _ := p.studioQuery.FindByName(studioName, nil)
|
||||||
|
|
||||||
|
// add result to cache
|
||||||
|
p.studioCache[studioName] = ret
|
||||||
|
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SceneFilenameParser) queryTag(tagName string) *models.Tag {
|
||||||
|
// massage the performer name
|
||||||
|
tagName = delimiterRE.ReplaceAllString(tagName, " ")
|
||||||
|
|
||||||
|
// check cache first
|
||||||
|
if ret, found := p.tagCache[tagName]; found {
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
// match tag name exactly
|
||||||
|
ret, _ := p.tagQuery.FindByName(tagName, nil)
|
||||||
|
|
||||||
|
// add result to cache
|
||||||
|
p.tagCache[tagName] = ret
|
||||||
|
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SceneFilenameParser) setPerformers(h sceneHolder, result *models.SceneParserResult) {
|
||||||
|
// query for each performer
|
||||||
|
performersSet := make(map[int]bool)
|
||||||
|
for _, performerName := range h.performers {
|
||||||
|
if performerName != "" {
|
||||||
|
performer := p.queryPerformer(performerName)
|
||||||
|
if performer != nil {
|
||||||
|
if _, found := performersSet[performer.ID]; !found {
|
||||||
|
result.PerformerIds = append(result.PerformerIds, strconv.Itoa(performer.ID))
|
||||||
|
performersSet[performer.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SceneFilenameParser) setTags(h sceneHolder, result *models.SceneParserResult) {
|
||||||
|
// query for each performer
|
||||||
|
tagsSet := make(map[int]bool)
|
||||||
|
for _, tagName := range h.tags {
|
||||||
|
if tagName != "" {
|
||||||
|
tag := p.queryTag(tagName)
|
||||||
|
if tag != nil {
|
||||||
|
if _, found := tagsSet[tag.ID]; !found {
|
||||||
|
result.TagIds = append(result.TagIds, strconv.Itoa(tag.ID))
|
||||||
|
tagsSet[tag.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SceneFilenameParser) setStudio(h sceneHolder, result *models.SceneParserResult) {
|
||||||
|
// query for each performer
|
||||||
|
if h.studio != "" {
|
||||||
|
studio := p.queryStudio(h.studio)
|
||||||
|
if studio != nil {
|
||||||
|
studioId := strconv.Itoa(studio.ID)
|
||||||
|
result.StudioID = &studioId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SceneFilenameParser) setParserResult(h sceneHolder, result *models.SceneParserResult) {
|
||||||
|
if h.result.Title.Valid {
|
||||||
|
title := h.result.Title.String
|
||||||
|
title = p.replaceWhitespaceCharacters(title)
|
||||||
|
|
||||||
|
if p.ParserInput.CapitalizeTitle != nil && *p.ParserInput.CapitalizeTitle {
|
||||||
|
title = capitalizeTitleRE.ReplaceAllStringFunc(title, strings.ToUpper)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Title = &title
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.result.Date.Valid {
|
||||||
|
result.Date = &h.result.Date.String
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(h.performers) > 0 {
|
||||||
|
p.setPerformers(h, result)
|
||||||
|
}
|
||||||
|
if len(h.tags) > 0 {
|
||||||
|
p.setTags(h, result)
|
||||||
|
}
|
||||||
|
p.setStudio(h, result)
|
||||||
|
}
|
||||||
@@ -281,7 +281,7 @@ func getMultiCriterionClause(table string, joinTable string, joinTableField stri
|
|||||||
havingClause = "count(distinct " + table + ".id) IS " + strconv.Itoa(len(criterion.Value))
|
havingClause = "count(distinct " + table + ".id) IS " + strconv.Itoa(len(criterion.Value))
|
||||||
} else if criterion.Modifier == CriterionModifierExcludes {
|
} else if criterion.Modifier == CriterionModifierExcludes {
|
||||||
// excludes all of the provided ids
|
// excludes all of the provided ids
|
||||||
if (joinTable != "") {
|
if joinTable != "" {
|
||||||
whereClause = "not exists (select " + joinTable + ".scene_id from " + joinTable + " where " + joinTable + ".scene_id = scenes.id and " + joinTable + "." + joinTableField + " in " + getInBinding(len(criterion.Value)) + ")"
|
whereClause = "not exists (select " + joinTable + ".scene_id from " + joinTable + " where " + joinTable + ".scene_id = scenes.id and " + joinTable + "." + joinTableField + " in " + getInBinding(len(criterion.Value)) + ")"
|
||||||
} else {
|
} else {
|
||||||
whereClause = "not exists (select s.id from scenes as s where s.id = scenes.id and s." + joinTableField + " in " + getInBinding(len(criterion.Value)) + ")"
|
whereClause = "not exists (select s.id from scenes as s where s.id = scenes.id and s." + joinTableField + " in " + getInBinding(len(criterion.Value)) + ")"
|
||||||
@@ -302,7 +302,7 @@ func (qb *SceneQueryBuilder) QueryByPathRegex(findFilter *FindFilterType) ([]*Sc
|
|||||||
body := selectDistinctIDs("scenes")
|
body := selectDistinctIDs("scenes")
|
||||||
|
|
||||||
if q := findFilter.Q; q != nil && *q != "" {
|
if q := findFilter.Q; q != nil && *q != "" {
|
||||||
whereClauses = append(whereClauses, "scenes.path regexp '" + *q + "'")
|
whereClauses = append(whereClauses, "scenes.path regexp '(?i)"+*q+"'")
|
||||||
}
|
}
|
||||||
|
|
||||||
sortAndPagination := qb.getSceneSort(findFilter) + getPagination(findFilter)
|
sortAndPagination := qb.getSceneSort(findFilter) + getPagination(findFilter)
|
||||||
@@ -363,4 +363,3 @@ func (qb *SceneQueryBuilder) queryScenes(query string, args []interface{}, tx *s
|
|||||||
|
|
||||||
return scenes, nil
|
return scenes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/stashapp/stash/pkg/database"
|
"github.com/stashapp/stash/pkg/database"
|
||||||
)
|
)
|
||||||
@@ -134,6 +135,33 @@ func (qb *TagQueryBuilder) All() ([]*Tag, error) {
|
|||||||
return qb.queryTags(selectAll("tags")+qb.getTagSort(nil), nil, nil)
|
return qb.queryTags(selectAll("tags")+qb.getTagSort(nil), nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (qb *TagQueryBuilder) Query(findFilter *FindFilterType) ([]*Tag, int) {
|
||||||
|
if findFilter == nil {
|
||||||
|
findFilter = &FindFilterType{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var whereClauses []string
|
||||||
|
var havingClauses []string
|
||||||
|
var args []interface{}
|
||||||
|
body := selectDistinctIDs("tags")
|
||||||
|
|
||||||
|
if q := findFilter.Q; q != nil && *q != "" {
|
||||||
|
searchColumns := []string{"tags.name"}
|
||||||
|
whereClauses = append(whereClauses, getSearch(searchColumns, *q))
|
||||||
|
}
|
||||||
|
|
||||||
|
sortAndPagination := qb.getTagSort(findFilter) + getPagination(findFilter)
|
||||||
|
idsResult, countResult := executeFindQuery("tags", body, args, sortAndPagination, whereClauses, havingClauses)
|
||||||
|
|
||||||
|
var tags []*Tag
|
||||||
|
for _, id := range idsResult {
|
||||||
|
tag, _ := qb.Find(id, nil)
|
||||||
|
tags = append(tags, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags, countResult
|
||||||
|
}
|
||||||
|
|
||||||
func (qb *TagQueryBuilder) getTagSort(findFilter *FindFilterType) string {
|
func (qb *TagQueryBuilder) getTagSort(findFilter *FindFilterType) string {
|
||||||
var sort string
|
var sort string
|
||||||
var direction string
|
var direction string
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ type ValidTypes =
|
|||||||
interface IProps extends HTMLInputProps {
|
interface IProps extends HTMLInputProps {
|
||||||
type: "performers" | "studios" | "tags";
|
type: "performers" | "studios" | "tags";
|
||||||
initialId?: string;
|
initialId?: string;
|
||||||
|
noSelectionString?: string;
|
||||||
onSelectItem: (item: ValidTypes | undefined) => void;
|
onSelectItem: (item: ValidTypes | undefined) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +99,8 @@ export const FilterSelect: React.FunctionComponent<IProps> = (props: IProps) =>
|
|||||||
setSelectedItem(item);
|
setSelectedItem(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttonText = selectedItem ? selectedItem.name : "(No selection)";
|
const noSelection = props.noSelectionString !== undefined ? props.noSelectionString : "(No selection)"
|
||||||
|
const buttonText = selectedItem ? selectedItem.name : noSelection;
|
||||||
return (
|
return (
|
||||||
<InternalSelect
|
<InternalSelect
|
||||||
items={items}
|
items={items}
|
||||||
|
|||||||
@@ -412,6 +412,14 @@ export class StashService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static queryParseSceneFilenames(filter: GQL.FindFilterType, config: GQL.SceneParserInput) {
|
||||||
|
return StashService.client.query<GQL.ParseSceneFilenamesQuery>({
|
||||||
|
query: GQL.ParseSceneFilenamesDocument,
|
||||||
|
variables: {filter: filter, config: config},
|
||||||
|
fetchPolicy: "network-only",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public static nullToUndefined(value: any): any {
|
public static nullToUndefined(value: any): any {
|
||||||
if (_.isPlainObject(value)) {
|
if (_.isPlainObject(value)) {
|
||||||
return _.mapValues(value, StashService.nullToUndefined);
|
return _.mapValues(value, StashService.nullToUndefined);
|
||||||
|
|||||||
@@ -264,32 +264,53 @@ span.block {
|
|||||||
#parser-container {
|
#parser-container {
|
||||||
margin: 10px auto;
|
margin: 10px auto;
|
||||||
width: 75%;
|
width: 75%;
|
||||||
}
|
|
||||||
|
|
||||||
#parser-container .inputs label {
|
& .inputs label {
|
||||||
width: 12em;
|
width: 12em;
|
||||||
}
|
}
|
||||||
|
|
||||||
#parser-container .inputs .bp3-input-group {
|
& .inputs .bp3-input-group {
|
||||||
width: 80ch;
|
width: 80ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-parser-row .bp3-checkbox {
|
& .scene-parser-results {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .scene-parser-row .bp3-checkbox {
|
||||||
margin: 0px -20px 0px 0px;
|
margin: 0px -20px 0px 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-parser-row .title input {
|
& .scene-parser-row .parser-field-title input {
|
||||||
width: 50ch;
|
width: 50ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-parser-row input {
|
& .scene-parser-row .parser-field-date input {
|
||||||
|
width: 13ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .scene-parser-row .parser-field-performers input {
|
||||||
|
width: 20ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .scene-parser-row .parser-field-tags input {
|
||||||
|
width: 20ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .scene-parser-row .parser-field-studio input {
|
||||||
|
width: 15ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .scene-parser-row input {
|
||||||
min-width: 10ch;
|
min-width: 10ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-parser-row .bp3-form-group {
|
& .scene-parser-row .bp3-form-group {
|
||||||
margin-bottom: 0px;
|
margin-bottom: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-parser-row div:first-child > input {
|
& .scene-parser-row div:first-child > input {
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user