mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 04:14:39 +03:00
Feature: Support Multiple URLs in Studios (#6223)
* Backend support for studio URLs * FrontEnd addition * Support URLs in BulkStudioUpdate * Update tagger modal for URLs --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
@@ -619,7 +619,6 @@ func (db *Anonymiser) anonymiseStudios(ctx context.Context) error {
|
||||
query := dialect.From(table).Select(
|
||||
table.Col(idColumn),
|
||||
table.Col("name"),
|
||||
table.Col("url"),
|
||||
table.Col("details"),
|
||||
).Where(table.Col(idColumn).Gt(lastID)).Limit(1000)
|
||||
|
||||
@@ -630,14 +629,12 @@ func (db *Anonymiser) anonymiseStudios(ctx context.Context) error {
|
||||
var (
|
||||
id int
|
||||
name sql.NullString
|
||||
url sql.NullString
|
||||
details sql.NullString
|
||||
)
|
||||
|
||||
if err := rows.Scan(
|
||||
&id,
|
||||
&name,
|
||||
&url,
|
||||
&details,
|
||||
); err != nil {
|
||||
return err
|
||||
@@ -645,7 +642,6 @@ func (db *Anonymiser) anonymiseStudios(ctx context.Context) error {
|
||||
|
||||
set := goqu.Record{}
|
||||
db.obfuscateNullString(set, "name", name)
|
||||
db.obfuscateNullString(set, "url", url)
|
||||
db.obfuscateNullString(set, "details", details)
|
||||
|
||||
if len(set) > 0 {
|
||||
@@ -677,6 +673,10 @@ func (db *Anonymiser) anonymiseStudios(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.anonymiseURLs(ctx, goqu.T(studioURLsTable), "studio_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ const (
|
||||
cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE"
|
||||
)
|
||||
|
||||
var appSchemaVersion uint = 72
|
||||
var appSchemaVersion uint = 73
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsBox embed.FS
|
||||
|
||||
24
pkg/sqlite/migrations/73_studio_urls.up.sql
Normal file
24
pkg/sqlite/migrations/73_studio_urls.up.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
CREATE TABLE `studio_urls` (
|
||||
`studio_id` integer NOT NULL,
|
||||
`position` integer NOT NULL,
|
||||
`url` varchar(255) NOT NULL,
|
||||
foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE,
|
||||
PRIMARY KEY(`studio_id`, `position`, `url`)
|
||||
);
|
||||
|
||||
CREATE INDEX `studio_urls_url` on `studio_urls` (`url`);
|
||||
|
||||
INSERT INTO `studio_urls`
|
||||
(
|
||||
`studio_id`,
|
||||
`position`,
|
||||
`url`
|
||||
)
|
||||
SELECT
|
||||
`id`,
|
||||
'0',
|
||||
`url`
|
||||
FROM `studios`
|
||||
WHERE `studios`.`url` IS NOT NULL AND `studios`.`url` != '';
|
||||
|
||||
ALTER TABLE `studios` DROP COLUMN `url`;
|
||||
7
pkg/sqlite/migrations/README.md
Normal file
7
pkg/sqlite/migrations/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Creating a migration
|
||||
|
||||
1. Create new migration file in the migrations directory with the format `NN_description.up.sql`, where `NN` is the next sequential number.
|
||||
|
||||
2. Update `pkg/sqlite/database.go` to update the `appSchemaVersion` value to the new migration number.
|
||||
|
||||
For migrations requiring complex logic or config file changes, see existing custom migrations for examples.
|
||||
@@ -2659,6 +2659,21 @@ func verifyString(t *testing.T, value string, criterion models.StringCriterionIn
|
||||
}
|
||||
}
|
||||
|
||||
func verifyStringList(t *testing.T, values []string, criterion models.StringCriterionInput) {
|
||||
t.Helper()
|
||||
assert := assert.New(t)
|
||||
switch criterion.Modifier {
|
||||
case models.CriterionModifierIsNull:
|
||||
assert.Empty(values)
|
||||
case models.CriterionModifierNotNull:
|
||||
assert.NotEmpty(values)
|
||||
default:
|
||||
for _, v := range values {
|
||||
verifyString(t, v, criterion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSceneQueryRating100(t *testing.T) {
|
||||
const rating = 60
|
||||
ratingCriterion := models.IntCriterionInput{
|
||||
|
||||
@@ -1770,6 +1770,24 @@ func getStudioBoolValue(index int) bool {
|
||||
return index == 1
|
||||
}
|
||||
|
||||
func getStudioEmptyString(index int, field string) string {
|
||||
v := getPrefixedNullStringValue("studio", index, field)
|
||||
if !v.Valid {
|
||||
return ""
|
||||
}
|
||||
|
||||
return v.String
|
||||
}
|
||||
|
||||
func getStudioStringList(index int, field string) []string {
|
||||
v := getStudioEmptyString(index, field)
|
||||
if v == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
return []string{v}
|
||||
}
|
||||
|
||||
// createStudios creates n studios with plain Name and o studios with camel cased NaMe included
|
||||
func createStudios(ctx context.Context, n int, o int) error {
|
||||
sqb := db.Studio
|
||||
@@ -1790,7 +1808,7 @@ func createStudios(ctx context.Context, n int, o int) error {
|
||||
tids := indexesToIDs(tagIDs, studioTags[i])
|
||||
studio := models.Studio{
|
||||
Name: name,
|
||||
URL: getStudioStringValue(index, urlField),
|
||||
URLs: models.NewRelatedStrings(getStudioStringList(i, urlField)),
|
||||
Favorite: getStudioBoolValue(index),
|
||||
IgnoreAutoTag: getIgnoreAutoTag(i),
|
||||
TagIDs: models.NewRelatedIDs(tids),
|
||||
|
||||
@@ -18,8 +18,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
studioTable = "studios"
|
||||
studioIDColumn = "studio_id"
|
||||
studioTable = "studios"
|
||||
studioIDColumn = "studio_id"
|
||||
|
||||
studioURLsTable = "studio_urls"
|
||||
studioURLColumn = "url"
|
||||
|
||||
studioAliasesTable = "studio_aliases"
|
||||
studioAliasColumn = "alias"
|
||||
studioParentIDColumn = "parent_id"
|
||||
@@ -31,7 +35,6 @@ const (
|
||||
type studioRow struct {
|
||||
ID int `db:"id" goqu:"skipinsert"`
|
||||
Name zero.String `db:"name"`
|
||||
URL zero.String `db:"url"`
|
||||
ParentID null.Int `db:"parent_id,omitempty"`
|
||||
CreatedAt Timestamp `db:"created_at"`
|
||||
UpdatedAt Timestamp `db:"updated_at"`
|
||||
@@ -48,7 +51,6 @@ type studioRow struct {
|
||||
func (r *studioRow) fromStudio(o models.Studio) {
|
||||
r.ID = o.ID
|
||||
r.Name = zero.StringFrom(o.Name)
|
||||
r.URL = zero.StringFrom(o.URL)
|
||||
r.ParentID = intFromPtr(o.ParentID)
|
||||
r.CreatedAt = Timestamp{Timestamp: o.CreatedAt}
|
||||
r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt}
|
||||
@@ -62,7 +64,6 @@ func (r *studioRow) resolve() *models.Studio {
|
||||
ret := &models.Studio{
|
||||
ID: r.ID,
|
||||
Name: r.Name.String,
|
||||
URL: r.URL.String,
|
||||
ParentID: nullIntPtr(r.ParentID),
|
||||
CreatedAt: r.CreatedAt.Timestamp,
|
||||
UpdatedAt: r.UpdatedAt.Timestamp,
|
||||
@@ -81,7 +82,6 @@ type studioRowRecord struct {
|
||||
|
||||
func (r *studioRowRecord) fromPartial(o models.StudioPartial) {
|
||||
r.setNullString("name", o.Name)
|
||||
r.setNullString("url", o.URL)
|
||||
r.setNullInt("parent_id", o.ParentID)
|
||||
r.setTimestamp("created_at", o.CreatedAt)
|
||||
r.setTimestamp("updated_at", o.UpdatedAt)
|
||||
@@ -190,6 +190,13 @@ func (qb *StudioStore) Create(ctx context.Context, newObject *models.Studio) err
|
||||
}
|
||||
}
|
||||
|
||||
if newObject.URLs.Loaded() {
|
||||
const startPos = 0
|
||||
if err := studiosURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := qb.tagRelationshipStore.createRelationships(ctx, id, newObject.TagIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -234,6 +241,12 @@ func (qb *StudioStore) UpdatePartial(ctx context.Context, input models.StudioPar
|
||||
}
|
||||
}
|
||||
|
||||
if input.URLs != nil {
|
||||
if err := studiosURLsTableMgr.modifyJoins(ctx, input.ID, input.URLs.Values, input.URLs.Mode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := qb.tagRelationshipStore.modifyRelationships(ctx, input.ID, input.TagIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -262,6 +275,12 @@ func (qb *StudioStore) Update(ctx context.Context, updatedObject *models.Studio)
|
||||
}
|
||||
}
|
||||
|
||||
if updatedObject.URLs.Loaded() {
|
||||
if err := studiosURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := qb.tagRelationshipStore.replaceRelationships(ctx, updatedObject.ID, updatedObject.TagIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -507,7 +526,7 @@ func (qb *StudioStore) QueryForAutoTag(ctx context.Context, words []string) ([]*
|
||||
ret, err := qb.findBySubquery(ctx, sq)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting performers for autotag: %w", err)
|
||||
return nil, fmt.Errorf("getting studios for autotag: %w", err)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
@@ -663,3 +682,7 @@ func (qb *StudioStore) GetStashIDs(ctx context.Context, studioID int) ([]models.
|
||||
func (qb *StudioStore) GetAliases(ctx context.Context, studioID int) ([]string, error) {
|
||||
return studiosAliasesTableMgr.get(ctx, studioID)
|
||||
}
|
||||
|
||||
func (qb *StudioStore) GetURLs(ctx context.Context, studioID int) ([]string, error) {
|
||||
return studiosURLsTableMgr.get(ctx, studioID)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler {
|
||||
return compoundHandler{
|
||||
stringCriterionHandler(studioFilter.Name, studioTable+".name"),
|
||||
stringCriterionHandler(studioFilter.Details, studioTable+".details"),
|
||||
stringCriterionHandler(studioFilter.URL, studioTable+".url"),
|
||||
qb.urlsCriterionHandler(studioFilter.URL),
|
||||
intCriterionHandler(studioFilter.Rating100, studioTable+".rating", nil),
|
||||
boolCriterionHandler(studioFilter.Favorite, studioTable+".favorite", nil),
|
||||
boolCriterionHandler(studioFilter.IgnoreAutoTag, studioTable+".ignore_auto_tag", nil),
|
||||
@@ -118,6 +118,9 @@ func (qb *studioFilterHandler) isMissingCriterionHandler(isMissing *string) crit
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if isMissing != nil && *isMissing != "" {
|
||||
switch *isMissing {
|
||||
case "url":
|
||||
studiosURLsTableMgr.join(f, "", "studios.id")
|
||||
f.addWhere("studio_urls.url IS NULL")
|
||||
case "image":
|
||||
f.addWhere("studios.image_blob IS NULL")
|
||||
case "stash_id":
|
||||
@@ -202,6 +205,20 @@ func (qb *studioFilterHandler) aliasCriterionHandler(alias *models.StringCriteri
|
||||
return h.handler(alias)
|
||||
}
|
||||
|
||||
func (qb *studioFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc {
|
||||
h := stringListCriterionHandlerBuilder{
|
||||
primaryTable: studioTable,
|
||||
primaryFK: studioIDColumn,
|
||||
joinTable: studioURLsTable,
|
||||
stringColumn: studioURLColumn,
|
||||
addJoinTable: func(f *filterBuilder) {
|
||||
studiosURLsTableMgr.join(f, "", "studios.id")
|
||||
},
|
||||
}
|
||||
|
||||
return h.handler(url)
|
||||
}
|
||||
|
||||
func (qb *studioFilterHandler) childCountCriterionHandler(childCount *models.IntCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if childCount != nil {
|
||||
|
||||
@@ -82,6 +82,14 @@ func TestStudioQueryNameOr(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func loadStudioRelationships(ctx context.Context, t *testing.T, s *models.Studio) error {
|
||||
if err := s.LoadURLs(ctx, db.Studio); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestStudioQueryNameAndUrl(t *testing.T) {
|
||||
const studioIdx = 1
|
||||
studioName := getStudioStringValue(studioIdx, "Name")
|
||||
@@ -107,9 +115,16 @@ func TestStudioQueryNameAndUrl(t *testing.T) {
|
||||
|
||||
studios := queryStudio(ctx, t, sqb, &studioFilter, nil)
|
||||
|
||||
assert.Len(t, studios, 1)
|
||||
if !assert.Len(t, studios, 1) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := studios[0].LoadURLs(ctx, db.Studio); err != nil {
|
||||
t.Errorf("Error loading studio relationships: %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, studioName, studios[0].Name)
|
||||
assert.Equal(t, studioUrl, studios[0].URL)
|
||||
assert.Equal(t, []string{studioUrl}, studios[0].URLs.List())
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -145,9 +160,13 @@ func TestStudioQueryNameNotUrl(t *testing.T) {
|
||||
studios := queryStudio(ctx, t, sqb, &studioFilter, nil)
|
||||
|
||||
for _, studio := range studios {
|
||||
if err := studio.LoadURLs(ctx, db.Studio); err != nil {
|
||||
t.Errorf("Error loading studio relationships: %v", err)
|
||||
}
|
||||
|
||||
verifyString(t, studio.Name, nameCriterion)
|
||||
urlCriterion.Modifier = models.CriterionModifierNotEquals
|
||||
verifyString(t, studio.URL, urlCriterion)
|
||||
verifyStringList(t, studio.URLs.List(), urlCriterion)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -659,7 +678,11 @@ func TestStudioQueryURL(t *testing.T) {
|
||||
|
||||
verifyFn := func(ctx context.Context, g *models.Studio) {
|
||||
t.Helper()
|
||||
verifyString(t, g.URL, urlCriterion)
|
||||
if err := g.LoadURLs(ctx, db.Studio); err != nil {
|
||||
t.Errorf("Error loading studio relationships: %v", err)
|
||||
return
|
||||
}
|
||||
verifyStringList(t, g.URLs.List(), urlCriterion)
|
||||
}
|
||||
|
||||
verifyStudioQuery(t, filter, verifyFn)
|
||||
|
||||
@@ -37,6 +37,7 @@ var (
|
||||
performersCustomFieldsTable = goqu.T("performer_custom_fields")
|
||||
|
||||
studiosAliasesJoinTable = goqu.T(studioAliasesTable)
|
||||
studiosURLsJoinTable = goqu.T(studioURLsTable)
|
||||
studiosTagsJoinTable = goqu.T(studiosTagsTable)
|
||||
studiosStashIDsJoinTable = goqu.T("studio_stash_ids")
|
||||
|
||||
@@ -319,6 +320,14 @@ var (
|
||||
stringColumn: studiosAliasesJoinTable.Col(studioAliasColumn),
|
||||
}
|
||||
|
||||
studiosURLsTableMgr = &orderedValueTable[string]{
|
||||
table: table{
|
||||
table: studiosURLsJoinTable,
|
||||
idColumn: studiosURLsJoinTable.Col(studioIDColumn),
|
||||
},
|
||||
valueColumn: studiosURLsJoinTable.Col(studioURLColumn),
|
||||
}
|
||||
|
||||
studiosTagsTableMgr = &joinTable{
|
||||
table: table{
|
||||
table: studiosTagsJoinTable,
|
||||
|
||||
Reference in New Issue
Block a user