Studio aliases (#1660)

* Add migration to create studio aliases table
* Refactor studioQueryBuilder.Query to use filterBuilder
* Expand GraphQL API with aliases support for studio
* Add aliases support for studios to the UI
* List aliases in details panel
* Allow editing aliases in edit panel
* Add 'aliases' filter when searching
* Find studios by alias in filter / select
* Add auto-tagging based on studio aliases
* Support studio aliases for filename parsing
* Support importing and exporting of studio aliases
* Search for studio alias as well during scraping
This commit is contained in:
gitgiggety
2021-09-09 10:13:42 +02:00
committed by GitHub
parent c91ffe1e58
commit 04e5ac9c2f
34 changed files with 909 additions and 164 deletions

View File

@@ -963,6 +963,12 @@ func createStudios(sqb models.StudioReaderWriter, n int, o int) error {
return err
}
// add alias
alias := getStudioStringValue(i, "Alias")
if err := sqb.UpdateAliases(created.ID, []string{alias}); err != nil {
return fmt.Errorf("error setting studio alias: %s", err.Error())
}
studioIDs = append(studioIDs, created.ID)
studioNames = append(studioNames, created.Name.String)
}

View File

@@ -10,6 +10,8 @@ import (
const studioTable = "studios"
const studioIDColumn = "studio_id"
const studioAliasesTable = "studio_aliases"
const studioAliasColumn = "alias"
type studioQueryBuilder struct {
repository
@@ -126,19 +128,50 @@ func (qb *studioQueryBuilder) QueryForAutoTag(words []string) ([]*models.Studio,
// TODO - Query needs to be changed to support queries of this type, and
// this method should be removed
query := selectAll(studioTable)
query += " LEFT JOIN studio_aliases ON studio_aliases.studio_id = studios.id"
var whereClauses []string
var args []interface{}
for _, w := range words {
whereClauses = append(whereClauses, "name like ?")
args = append(args, w+"%")
ww := w + "%"
whereClauses = append(whereClauses, "studios.name like ?")
args = append(args, ww)
// include aliases
whereClauses = append(whereClauses, "studio_aliases.alias like ?")
args = append(args, ww)
}
where := strings.Join(whereClauses, " OR ")
return qb.queryStudios(query+" WHERE "+where, args)
}
func (qb *studioQueryBuilder) makeFilter(studioFilter *models.StudioFilterType) *filterBuilder {
query := &filterBuilder{}
query.handleCriterion(stringCriterionHandler(studioFilter.Name, studioTable+".name"))
query.handleCriterion(stringCriterionHandler(studioFilter.Details, studioTable+".details"))
query.handleCriterion(stringCriterionHandler(studioFilter.URL, studioTable+".url"))
query.handleCriterion(intCriterionHandler(studioFilter.Rating, studioTable+".rating"))
query.handleCriterion(criterionHandlerFunc(func(f *filterBuilder) {
if studioFilter.StashID != nil {
qb.stashIDRepository().join(f, "studio_stash_ids", "studios.id")
stringCriterionHandler(studioFilter.StashID, "scene_stash_ids.stash_id")(f)
}
}))
query.handleCriterion(studioIsMissingCriterionHandler(qb, studioFilter.IsMissing))
query.handleCriterion(studioSceneCountCriterionHandler(qb, studioFilter.SceneCount))
query.handleCriterion(studioImageCountCriterionHandler(qb, studioFilter.ImageCount))
query.handleCriterion(studioGalleryCountCriterionHandler(qb, studioFilter.GalleryCount))
query.handleCriterion(studioParentCriterionHandler(qb, studioFilter.Parents))
query.handleCriterion(studioAliasCriterionHandler(qb, studioFilter.Aliases))
return query
}
func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) ([]*models.Studio, int, error) {
if studioFilter == nil {
studioFilter = &models.StudioFilterType{}
@@ -150,57 +183,19 @@ func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findF
query := qb.newQuery()
query.body = selectDistinctIDs("studios")
query.body += `
left join scenes on studios.id = scenes.studio_id
left join studio_stash_ids on studio_stash_ids.studio_id = studios.id
`
if q := findFilter.Q; q != nil && *q != "" {
searchColumns := []string{"studios.name"}
query.join(studioAliasesTable, "", "studio_aliases.studio_id = studios.id")
searchColumns := []string{"studios.name", "studio_aliases.alias"}
clause, thisArgs := getSearchBinding(searchColumns, *q, false)
query.addWhere(clause)
query.addArg(thisArgs...)
}
if parentsFilter := studioFilter.Parents; parentsFilter != nil && len(parentsFilter.Value) > 0 {
query.body += `
left join studios as parent_studio on parent_studio.id = studios.parent_id
`
filter := qb.makeFilter(studioFilter)
for _, studioID := range parentsFilter.Value {
query.addArg(studioID)
}
whereClause, havingClause := getMultiCriterionClause("studios", "parent_studio", "", "", "parent_id", parentsFilter)
query.addWhere(whereClause)
query.addHaving(havingClause)
}
if rating := studioFilter.Rating; rating != nil {
query.handleIntCriterionInput(studioFilter.Rating, "studios.rating")
}
query.handleCountCriterion(studioFilter.SceneCount, studioTable, sceneTable, studioIDColumn)
query.handleCountCriterion(studioFilter.ImageCount, studioTable, imageTable, studioIDColumn)
query.handleCountCriterion(studioFilter.GalleryCount, studioTable, galleryTable, studioIDColumn)
query.handleStringCriterionInput(studioFilter.Name, "studios.name")
query.handleStringCriterionInput(studioFilter.Details, "studios.details")
query.handleStringCriterionInput(studioFilter.URL, "studios.url")
query.handleStringCriterionInput(studioFilter.StashID, "studio_stash_ids.stash_id")
if isMissingFilter := studioFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" {
switch *isMissingFilter {
case "image":
query.body += `left join studios_image on studios_image.studio_id = studios.id
`
query.addWhere("studios_image.studio_id IS NULL")
case "stash_id":
query.addWhere("studio_stash_ids.studio_id IS NULL")
default:
query.addWhere("studios." + *isMissingFilter + " IS NULL")
}
}
query.addFilter(filter)
query.sortAndPagination = qb.getStudioSort(findFilter) + getPagination(findFilter)
idsResult, countResult, err := query.executeFind()
@@ -221,6 +216,83 @@ func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findF
return studios, countResult, nil
}
func studioIsMissingCriterionHandler(qb *studioQueryBuilder, isMissing *string) criterionHandlerFunc {
return func(f *filterBuilder) {
if isMissing != nil && *isMissing != "" {
switch *isMissing {
case "image":
f.addJoin("studios_image", "", "studios_image.studio_id = studios.id")
f.addWhere("studios_image.studio_id IS NULL")
case "stash_id":
qb.stashIDRepository().join(f, "studio_stash_ids", "studios.id")
f.addWhere("studio_stash_ids.studio_id IS NULL")
default:
f.addWhere("(studios." + *isMissing + " IS NULL OR TRIM(studios." + *isMissing + ") = '')")
}
}
}
}
func studioSceneCountCriterionHandler(qb *studioQueryBuilder, sceneCount *models.IntCriterionInput) criterionHandlerFunc {
return func(f *filterBuilder) {
if sceneCount != nil {
f.addJoin("scenes", "", "scenes.studio_id = studios.id")
clause, args := getIntCriterionWhereClause("count(distinct scenes.id)", *sceneCount)
f.addHaving(clause, args...)
}
}
}
func studioImageCountCriterionHandler(qb *studioQueryBuilder, imageCount *models.IntCriterionInput) criterionHandlerFunc {
return func(f *filterBuilder) {
if imageCount != nil {
f.addJoin("images", "", "images.studio_id = studios.id")
clause, args := getIntCriterionWhereClause("count(distinct images.id)", *imageCount)
f.addHaving(clause, args...)
}
}
}
func studioGalleryCountCriterionHandler(qb *studioQueryBuilder, galleryCount *models.IntCriterionInput) criterionHandlerFunc {
return func(f *filterBuilder) {
if galleryCount != nil {
f.addJoin("galleries", "", "galleries.studio_id = studios.id")
clause, args := getIntCriterionWhereClause("count(distinct galleries.id)", *galleryCount)
f.addHaving(clause, args...)
}
}
}
func studioParentCriterionHandler(qb *studioQueryBuilder, parents *models.MultiCriterionInput) criterionHandlerFunc {
addJoinsFunc := func(f *filterBuilder) {
f.addJoin("studios", "parent_studio", "parent_studio.id = studios.parent_id")
}
h := multiCriterionHandlerBuilder{
primaryTable: studioTable,
foreignTable: "parent_studio",
joinTable: "",
primaryFK: studioIDColumn,
foreignFK: "parent_id",
addJoinsFunc: addJoinsFunc,
}
return h.handler(parents)
}
func studioAliasCriterionHandler(qb *studioQueryBuilder, alias *models.StringCriterionInput) criterionHandlerFunc {
h := stringListCriterionHandlerBuilder{
joinTable: studioAliasesTable,
stringColumn: studioAliasColumn,
addJoinTable: func(f *filterBuilder) {
qb.aliasRepository().join(f, "", "studios.id")
},
}
return h.handler(alias)
}
func (qb *studioQueryBuilder) getStudioSort(findFilter *models.FindFilterType) string {
var sort string
var direction string
@@ -303,3 +375,22 @@ func (qb *studioQueryBuilder) GetStashIDs(studioID int) ([]*models.StashID, erro
func (qb *studioQueryBuilder) UpdateStashIDs(studioID int, stashIDs []models.StashID) error {
return qb.stashIDRepository().replace(studioID, stashIDs)
}
func (qb *studioQueryBuilder) aliasRepository() *stringRepository {
return &stringRepository{
repository: repository{
tx: qb.tx,
tableName: studioAliasesTable,
idColumn: studioIDColumn,
},
stringColumn: studioAliasColumn,
}
}
func (qb *studioQueryBuilder) GetAliases(studioID int) ([]string, error) {
return qb.aliasRepository().get(studioID)
}
func (qb *studioQueryBuilder) UpdateAliases(studioID int, aliases []string) error {
return qb.aliasRepository().replace(studioID, aliases)
}

View File

@@ -62,6 +62,17 @@ func TestStudioQueryForAutoTag(t *testing.T) {
assert.Equal(t, strings.ToLower(studioNames[studioIdxWithScene]), strings.ToLower(studios[0].Name.String))
assert.Equal(t, strings.ToLower(studioNames[studioIdxWithScene]), strings.ToLower(studios[1].Name.String))
// find by alias
name = getStudioStringValue(studioIdxWithScene, "Alias")
studios, err = tqb.QueryForAutoTag([]string{name})
if err != nil {
t.Errorf("Error finding studios: %s", err.Error())
}
assert.Len(t, studios, 1)
assert.Equal(t, studioIDs[studioIdxWithScene], studios[0].ID)
return nil
})
}
@@ -460,7 +471,7 @@ func TestStudioQueryURL(t *testing.T) {
URL: &urlCriterion,
}
verifyFn := func(g *models.Studio) {
verifyFn := func(g *models.Studio, r models.Repository) {
t.Helper()
verifyNullString(t, g.URL, urlCriterion)
}
@@ -510,7 +521,7 @@ func TestStudioQueryRating(t *testing.T) {
verifyStudiosRating(t, ratingCriterion)
}
func verifyStudioQuery(t *testing.T, filter models.StudioFilterType, verifyFn func(s *models.Studio)) {
func verifyStudioQuery(t *testing.T, filter models.StudioFilterType, verifyFn func(s *models.Studio, r models.Repository)) {
withTxn(func(r models.Repository) error {
t.Helper()
sqb := r.Studio()
@@ -521,7 +532,7 @@ func verifyStudioQuery(t *testing.T, filter models.StudioFilterType, verifyFn fu
assert.Greater(t, len(studios), 0)
for _, studio := range studios {
verifyFn(studio)
verifyFn(studio, r)
}
return nil
@@ -582,6 +593,106 @@ func queryStudio(t *testing.T, sqb models.StudioReader, studioFilter *models.Stu
return studios
}
func TestStudioQueryName(t *testing.T) {
const studioIdx = 1
studioName := getStudioStringValue(studioIdx, "Name")
nameCriterion := &models.StringCriterionInput{
Value: studioName,
Modifier: models.CriterionModifierEquals,
}
studioFilter := models.StudioFilterType{
Name: nameCriterion,
}
verifyFn := func(studio *models.Studio, r models.Repository) {
verifyNullString(t, studio.Name, *nameCriterion)
}
verifyStudioQuery(t, studioFilter, verifyFn)
nameCriterion.Modifier = models.CriterionModifierNotEquals
verifyStudioQuery(t, studioFilter, verifyFn)
nameCriterion.Modifier = models.CriterionModifierMatchesRegex
nameCriterion.Value = "studio_.*1_Name"
verifyStudioQuery(t, studioFilter, verifyFn)
nameCriterion.Modifier = models.CriterionModifierNotMatchesRegex
verifyStudioQuery(t, studioFilter, verifyFn)
}
func TestStudioQueryAlias(t *testing.T) {
const studioIdx = 1
studioName := getStudioStringValue(studioIdx, "Alias")
aliasCriterion := &models.StringCriterionInput{
Value: studioName,
Modifier: models.CriterionModifierEquals,
}
studioFilter := models.StudioFilterType{
Aliases: aliasCriterion,
}
verifyFn := func(studio *models.Studio, r models.Repository) {
aliases, err := r.Studio().GetAliases(studio.ID)
if err != nil {
t.Errorf("Error querying studios: %s", err.Error())
}
var alias string
if len(aliases) > 0 {
alias = aliases[0]
}
verifyString(t, alias, *aliasCriterion)
}
verifyStudioQuery(t, studioFilter, verifyFn)
aliasCriterion.Modifier = models.CriterionModifierNotEquals
verifyStudioQuery(t, studioFilter, verifyFn)
aliasCriterion.Modifier = models.CriterionModifierMatchesRegex
aliasCriterion.Value = "studio_.*1_Alias"
verifyStudioQuery(t, studioFilter, verifyFn)
aliasCriterion.Modifier = models.CriterionModifierNotMatchesRegex
verifyStudioQuery(t, studioFilter, verifyFn)
}
func TestStudioUpdateAlias(t *testing.T) {
if err := withTxn(func(r models.Repository) error {
qb := r.Studio()
// create studio to test against
const name = "TestStudioUpdateAlias"
created, err := createStudio(qb, name, nil)
if err != nil {
return fmt.Errorf("Error creating studio: %s", err.Error())
}
aliases := []string{"alias1", "alias2"}
err = qb.UpdateAliases(created.ID, aliases)
if err != nil {
return fmt.Errorf("Error updating studio aliases: %s", err.Error())
}
// ensure aliases set
storedAliases, err := qb.GetAliases(created.ID)
if err != nil {
return fmt.Errorf("Error getting aliases: %s", err.Error())
}
assert.Equal(t, aliases, storedAliases)
return nil
}); err != nil {
t.Error(err.Error())
}
}
// TODO Create
// TODO Update
// TODO Destroy