Compare commits

..

1 Commits

Author SHA1 Message Date
DogmaDragon
3c06df402b Document changes from https://github.com/stashapp/stash/pull/6673 2026-03-30 15:33:19 +03:00
32 changed files with 131 additions and 420 deletions

View File

@@ -47,10 +47,6 @@ func (r *mutationResolver) Migrate(ctx context.Context, input manager.MigrateInp
Database: mgr.Database,
}
if err := t.PreExecute(); err != nil {
return "", err
}
jobID := mgr.JobManager.Add(ctx, "Migrating database...", t)
return strconv.Itoa(jobID), nil

View File

@@ -33,26 +33,15 @@ func (r *queryResolver) FindJob(ctx context.Context, input FindJobInput) (*Job,
}
func jobToJobModel(j job.Job) *Job {
subTasks := make([]string, len(j.Details))
for i, t := range j.Details {
subTasks[i] = sanitiseWebsocketString(t)
}
var jobError *string
if j.Error != nil {
s := sanitiseWebsocketString(*j.Error)
jobError = &s
}
ret := &Job{
ID: strconv.Itoa(j.ID),
Status: JobStatus(j.Status),
Description: sanitiseWebsocketString(j.Description),
SubTasks: subTasks,
Description: j.Description,
SubTasks: j.Details,
StartTime: j.StartTime,
EndTime: j.EndTime,
AddTime: j.AddTime,
Error: jobError,
Error: j.Error,
}
if j.Progress != -1 {

View File

@@ -2,19 +2,11 @@ package api
import (
"context"
"strings"
"github.com/stashapp/stash/internal/log"
"github.com/stashapp/stash/internal/manager"
)
// sanitiseWebsocketString is used to ensure that any strings sent over the websocket are valid UTF-8.
// Any invalid UTF-8 sequences will be replaced with the Unicode replacement character (U+FFFD).
// Invalid UTF-8 sequences can cause the websocket connection to be closed.
func sanitiseWebsocketString(s string) string {
return strings.ToValidUTF8(s, "\uFFFD")
}
func getLogLevel(logType string) LogLevel {
switch logType {
case "progress":
@@ -41,7 +33,7 @@ func logEntriesFromLogItems(logItems []log.LogItem) []*LogEntry {
ret[i] = &LogEntry{
Time: entry.Time,
Level: getLogLevel(entry.Type),
Message: sanitiseWebsocketString(entry.Message),
Message: entry.Message,
}
}

View File

@@ -7,8 +7,6 @@ import (
"os"
"path/filepath"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/job"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/sqlite"
@@ -31,21 +29,6 @@ type databaseSchemaInfo struct {
StepsRequired uint
}
// PreExecute validates the environment before executing the migration.
// It returns an error if the migration cannot be performed.
func (s *MigrateJob) PreExecute() error {
// ensure backup directory exists and is writable
backupDir := s.Config.GetBackupDirectoryPathOrDefault()
if backupDir != "" {
if err := fsutil.EnsureDir(backupDir); err != nil {
logger.Errorf("error ensuring backup directory exists: %s", err)
logger.Warnf("Backup directory (%s) must be modified to a valid directory or removed from the config file", config.BackupDirectoryPath)
return fmt.Errorf("error creating backup directory: %w", err)
}
}
return nil
}
func (s *MigrateJob) Execute(ctx context.Context, progress *job.Progress) error {
schemaInfo, err := s.required()
if err != nil {

View File

@@ -283,10 +283,8 @@ func (j *ScanJob) processQueue(ctx context.Context, parallelTasks int, progress
for f := range j.fileQueue {
logger.Tracef("Processing queued file %s", f.Path)
if ctx.Err() != nil {
// Keep receiving until queueFiles closes the channel; otherwise
// the walker can block on send (full buffer) and never finish.
continue
if err := ctx.Err(); err != nil {
return
}
wg.Add()

View File

@@ -66,23 +66,6 @@ type Job struct {
cancelFunc context.CancelFunc
}
// statusCopy returns a copy of the Job with only the fields needed for
// status reporting. Internal fields (exec, cancelFunc, outerCtx) are
// excluded so that subscription channels don't retain heavy resources.
func (j *Job) statusCopy() Job {
return Job{
ID: j.ID,
Status: j.Status,
Details: j.Details,
Description: j.Description,
Progress: j.Progress,
StartTime: j.StartTime,
EndTime: j.EndTime,
AddTime: j.AddTime,
Error: j.Error,
}
}
// TimeElapsed returns the total time elapsed for the job.
// If the EndTime is set, then it uses this to calculate the elapsed time, otherwise it uses time.Now.
func (j *Job) TimeElapsed() time.Duration {

View File

@@ -105,7 +105,7 @@ func (m *Manager) notifyNewJob(j *Job) {
for _, s := range m.subscriptions {
// don't block if channel is full
select {
case s.newJob <- j.statusCopy():
case s.newJob <- *j:
default:
}
}
@@ -232,9 +232,7 @@ func (m *Manager) removeJob(job *Job) {
return
}
// release the executor and subtask details so they can be GC'd
// while the job remains in the graveyard for status reporting
job.exec = nil
// clear any subtasks
job.Details = nil
m.queue = append(m.queue[:index], m.queue[index+1:]...)
@@ -248,7 +246,7 @@ func (m *Manager) removeJob(job *Job) {
for _, s := range m.subscriptions {
// don't block if channel is full
select {
case s.removedJob <- job.statusCopy():
case s.removedJob <- *job:
default:
}
}
@@ -312,7 +310,8 @@ func (m *Manager) GetJob(id int) *Job {
// get from the queue or graveyard
_, j := m.getJob(append(m.queue, m.graveyard...), id)
if j != nil {
jCopy := j.statusCopy()
// make a copy of the job and return the pointer
jCopy := *j
return &jCopy
}
@@ -327,7 +326,8 @@ func (m *Manager) GetQueue() []Job {
var ret []Job
for _, j := range m.queue {
ret = append(ret, j.statusCopy())
jCopy := *j
ret = append(ret, jCopy)
}
return ret
@@ -372,7 +372,7 @@ func (m *Manager) notifyJobUpdate(j *Job) {
for _, s := range m.subscriptions {
// don't block if channel is full
select {
case s.updatedJob <- j.statusCopy():
case s.updatedJob <- *j:
default:
}
}

View File

@@ -51,7 +51,7 @@ func (tq *TaskQueue) executer(ctx context.Context) {
defer tq.wg.Wait()
for task := range tq.tasks {
if IsCancelled(ctx) {
continue // allow channel to continue draining until Close()
return
}
tt := task

View File

@@ -22,7 +22,7 @@ type GroupNamesFinder interface {
type SceneRelationships struct {
PerformerFinder PerformerFinder
TagFinder models.TagNameFinder
TagFinder models.TagQueryer
StudioFinder StudioFinder
}
@@ -189,7 +189,7 @@ func ScrapedGroup(ctx context.Context, qb GroupNamesFinder, storedID *string, na
}
// ScrapedTagHierarchy executes ScrapedTag for the provided tag and its parent.
func ScrapedTagHierarchy(ctx context.Context, qb models.TagNameFinder, s *models.ScrapedTag, stashBoxEndpoint string) error {
func ScrapedTagHierarchy(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error {
if err := ScrapedTag(ctx, qb, s, stashBoxEndpoint); err != nil {
return err
}
@@ -204,7 +204,7 @@ func ScrapedTagHierarchy(ctx context.Context, qb models.TagNameFinder, s *models
// ScrapedTag matches the provided tag with the tags
// in the database and sets the ID field if one is found.
func ScrapedTag(ctx context.Context, qb models.TagNameFinder, s *models.ScrapedTag, stashBoxEndpoint string) error {
func ScrapedTag(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error {
if s.StoredID != nil {
return nil
}

View File

@@ -197,29 +197,6 @@ func (_m *TagReaderWriter) FindAllDescendants(ctx context.Context, tagID int, ex
return r0, r1
}
// FindByAlias provides a mock function with given fields: ctx, alias, nocase
func (_m *TagReaderWriter) FindByAlias(ctx context.Context, alias string, nocase bool) (*models.Tag, error) {
ret := _m.Called(ctx, alias, nocase)
var r0 *models.Tag
if rf, ok := ret.Get(0).(func(context.Context, string, bool) *models.Tag); ok {
r0 = rf(ctx, alias, nocase)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*models.Tag)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {
r1 = rf(ctx, alias, nocase)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindByChildTagID provides a mock function with given fields: ctx, childID
func (_m *TagReaderWriter) FindByChildTagID(ctx context.Context, childID int) ([]*models.Tag, error) {
ret := _m.Called(ctx, childID)

View File

@@ -9,16 +9,9 @@ type TagGetter interface {
Find(ctx context.Context, id int) (*Tag, error)
}
type TagNameFinder interface {
FindByName(ctx context.Context, name string, nocase bool) (*Tag, error)
FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error)
FindByAlias(ctx context.Context, alias string, nocase bool) (*Tag, error)
}
// TagFinder provides methods to find tags.
type TagFinder interface {
TagGetter
TagNameFinder
FindAllAncestors(ctx context.Context, tagID int, excludeIDs []int) ([]*TagPath, error)
FindAllDescendants(ctx context.Context, tagID int, excludeIDs []int) ([]*TagPath, error)
FindByParentTagID(ctx context.Context, parentID int) ([]*Tag, error)
@@ -30,6 +23,8 @@ type TagFinder interface {
FindByGroupID(ctx context.Context, groupID int) ([]*Tag, error)
FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*Tag, error)
FindByStudioID(ctx context.Context, studioID int) ([]*Tag, error)
FindByName(ctx context.Context, name string, nocase bool) (*Tag, error)
FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error)
FindByStashID(ctx context.Context, stashID StashID) ([]*Tag, error)
FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*Tag, error)
}

View File

@@ -456,7 +456,7 @@ type FilenameParserRepository struct {
Performer PerformerNamesFinder
Studio models.StudioQueryer
Group GroupNameFinder
Tag models.TagNameFinder
Tag models.TagQueryer
}
func NewFilenameParserRepository(repo models.Repository) FilenameParserRepository {
@@ -599,7 +599,7 @@ func (p *FilenameParser) queryGroup(ctx context.Context, qb GroupNameFinder, gro
return ret
}
func (p *FilenameParser) queryTag(ctx context.Context, qb models.TagNameFinder, tagName string) *models.Tag {
func (p *FilenameParser) queryTag(ctx context.Context, qb models.TagQueryer, tagName string) *models.Tag {
// massage the tag name
tagName = delimiterRE.ReplaceAllString(tagName, " ")
@@ -638,7 +638,7 @@ func (p *FilenameParser) setPerformers(ctx context.Context, qb PerformerNamesFin
}
}
func (p *FilenameParser) setTags(ctx context.Context, qb models.TagNameFinder, h sceneHolder, result *models.SceneParserResult) {
func (p *FilenameParser) setTags(ctx context.Context, qb models.TagQueryer, h sceneHolder, result *models.SceneParserResult) {
// query for each performer
tagsSet := make(map[int]bool)
for _, tagName := range h.tags {

View File

@@ -70,7 +70,6 @@ type StudioFinder interface {
type TagFinder interface {
models.TagGetter
models.TagNameFinder
models.TagAutoTagQueryer
}

View File

@@ -11,7 +11,7 @@ import (
"github.com/stashapp/stash/pkg/sliceutil"
)
func postProcessTags(ctx context.Context, tqb models.TagNameFinder, scrapedTags []*models.ScrapedTag) (ret []*models.ScrapedTag, err error) {
func postProcessTags(ctx context.Context, tqb models.TagQueryer, scrapedTags []*models.ScrapedTag) (ret []*models.ScrapedTag, err error) {
ret = make([]*models.ScrapedTag, 0, len(scrapedTags))
for _, t := range scrapedTags {

View File

@@ -16,8 +16,8 @@ import (
"gopkg.in/guregu/null.v4"
)
func pre84(ctx context.Context, db *sqlx.DB) error {
logger.Info("Running pre-migration for schema version 84")
func post84(ctx context.Context, db *sqlx.DB) error {
logger.Info("Running post-migration for schema version 84")
m := schema84Migrator{
migrator: migrator{
@@ -36,23 +36,6 @@ func pre84(ctx context.Context, db *sqlx.DB) error {
return fmt.Errorf("fixing incorrect parent folders: %w", err)
}
if err := m.deduplicateFolders(ctx); err != nil {
return fmt.Errorf("deduplicating folders: %w", err)
}
return nil
}
func post84(ctx context.Context, db *sqlx.DB) error {
logger.Info("Running post-migration for schema version 84")
m := schema84Migrator{
migrator: migrator{
db: db,
},
folderCache: make(map[string]folderInfo),
}
if err := m.migrateFolders(ctx); err != nil {
return fmt.Errorf("migrating folders: %w", err)
}
@@ -205,7 +188,7 @@ func (m *schema84Migrator) getOrCreateFolderHierarchy(tx *sqlx.Tx, path string,
logger.Debugf("%s doesn't exist. Creating new folder entry...", path)
// we need to set basename to path, which will be addressed in the next step
const insertSQL = "INSERT INTO `folders` (`path`,`parent_folder_id`,`mod_time`,`created_at`,`updated_at`) VALUES (?,?,?,?,?)"
const insertSQL = "INSERT INTO `folders` (`path`,`basename`,`parent_folder_id`,`mod_time`,`created_at`,`updated_at`) VALUES (?,?,?,?,?,?)"
var parentFolderID null.Int
if parentID != nil {
@@ -213,7 +196,7 @@ func (m *schema84Migrator) getOrCreateFolderHierarchy(tx *sqlx.Tx, path string,
}
now := time.Now()
result, err := tx.Exec(insertSQL, path, parentFolderID, time.Time{}, now, now)
result, err := tx.Exec(insertSQL, path, path, parentFolderID, time.Time{}, now, now)
if err != nil {
return nil, fmt.Errorf("creating folder %s: %w", path, err)
}
@@ -281,6 +264,11 @@ func (m *schema84Migrator) fixIncorrectParents(ctx context.Context, rootPaths []
continue
}
if !logged {
logger.Info("Fixing folders with incorrect parent folder assignments...")
logged = true
}
correctParentID, err := m.getOrCreateFolderHierarchy(tx, expectedParent, rootPaths)
if err != nil {
return fmt.Errorf("error getting/creating correct parent for folder %d %q: %w", id, p, err)
@@ -290,11 +278,6 @@ func (m *schema84Migrator) fixIncorrectParents(ctx context.Context, rootPaths []
continue
}
if !logged {
logger.Info("Fixing folders with incorrect parent folder assignments...")
logged = true
}
logger.Debugf("Fixing folder %d %q: changing parent_folder_id from %d to %d", id, p, parentFolderID, *correctParentID)
_, err = tx.Exec("UPDATE `folders` SET `parent_folder_id` = ? WHERE `id` = ?", *correctParentID, id)
@@ -326,136 +309,6 @@ func (m *schema84Migrator) fixIncorrectParents(ctx context.Context, rootPaths []
return nil
}
// deduplicateFolders finds folders that would have the same (parent_folder_id, basename) after
// migrateFolders sets basename = filepath.Base(path), and merges the duplicates.
// This can happen when the database contains entries for the same physical folder with different
// path representations (e.g., mixed separators like "\data/movies" vs "\data\movies" on Windows).
func (m *schema84Migrator) deduplicateFolders(ctx context.Context) error {
for {
n, err := m.deduplicateFoldersPass(ctx)
if err != nil {
return err
}
// repeat until no more duplicates are found, since merging child folders
// from a duplicate parent into the canonical parent may create new conflicts
if n == 0 {
break
}
}
return nil
}
func (m *schema84Migrator) deduplicateFoldersPass(ctx context.Context) (int, error) {
type folderRow struct {
ID int `db:"id"`
Path string `db:"path"`
ParentFolderID int `db:"parent_folder_id"`
}
var folders []folderRow
if err := m.db.SelectContext(ctx, &folders,
"SELECT id, path, parent_folder_id FROM folders WHERE parent_folder_id IS NOT NULL ORDER BY id"); err != nil {
return 0, fmt.Errorf("loading folders: %w", err)
}
// group by (parent_folder_id, computed basename)
type groupKey struct {
parentID int
basename string
}
groups := make(map[groupKey][]folderRow)
for _, f := range folders {
key := groupKey{
parentID: f.ParentFolderID,
basename: filepath.Base(f.Path),
}
groups[key] = append(groups[key], f)
}
deduped := 0
for _, group := range groups {
if len(group) <= 1 {
continue
}
if deduped == 0 {
logger.Info("Deduplicating folders with conflicting basenames...")
}
// prefer the folder whose path is already normalized for the current OS,
// falling back to the newest entry (highest ID) since it's most likely
// from the current filesystem
keep := group[len(group)-1]
for _, f := range group {
if f.Path == filepath.Clean(f.Path) {
keep = f
break
}
}
for _, dup := range group {
if dup.ID == keep.ID {
continue
}
logger.Infof("Merging duplicate folder %d %q into folder %d %q", dup.ID, dup.Path, keep.ID, keep.Path)
if err := m.withTxn(ctx, func(tx *sqlx.Tx) error {
return m.mergeFolder(tx, keep.ID, dup.ID)
}); err != nil {
return 0, fmt.Errorf("merging folder %d into %d: %w", dup.ID, keep.ID, err)
}
deduped++
}
}
if deduped > 0 {
logger.Infof("Deduplicated %d folder entries", deduped)
}
return deduped, nil
}
func (m *schema84Migrator) mergeFolder(tx *sqlx.Tx, keepID, dupID int) error {
// Re-parent child folders from the duplicate to the canonical folder.
// At this point basenames are still full paths (unique), so this won't cause
// UNIQUE constraint violations on (parent_folder_id, basename).
if _, err := tx.Exec("UPDATE folders SET parent_folder_id = ? WHERE parent_folder_id = ?", keepID, dupID); err != nil {
return fmt.Errorf("re-parenting child folders: %w", err)
}
// re-parent any files under the duplicate folder to the canonical folder.
if _, err := tx.Exec("UPDATE files SET parent_folder_id = ? WHERE parent_folder_id = ?", keepID, dupID); err != nil {
return fmt.Errorf("re-parenting files: %w", err)
}
// delete the duplicate folder entry only if it is not referenced by any galleries
var count int
if err := tx.Get(&count, "SELECT COUNT(*) FROM galleries WHERE folder_id = ?", dupID); err != nil {
return fmt.Errorf("checking for gallery references: %w", err)
}
if count > 0 {
logger.Warnf("Duplicate folder %d is still referenced by %d galleries. Orphaning instead of deleting.", dupID, count)
// Orphan the stale duplicate folder by clearing its parent so the UNIQUE
// constraint on (parent_folder_id, basename) won't be violated when
// migrateFolders sets basenames. Any stale file entries under it are left
// untouched — the clean task will handle them on the next scan.
if _, err := tx.Exec("UPDATE folders SET parent_folder_id = NULL WHERE id = ?", dupID); err != nil {
return fmt.Errorf("orphaning duplicate folder: %w", err)
}
} else {
// delete the duplicate folder entry
if _, err := tx.Exec("DELETE FROM folders WHERE id = ?", dupID); err != nil {
return fmt.Errorf("deleting duplicate folder: %w", err)
}
}
return nil
}
func (m *schema84Migrator) migrateFolders(ctx context.Context) error {
const (
limit = 1000
@@ -528,6 +381,5 @@ func (m *schema84Migrator) migrateFolders(ctx context.Context) error {
}
func init() {
sqlite.RegisterPreMigration(84, pre84)
sqlite.RegisterPostMigration(84, post84)
}

View File

@@ -416,18 +416,6 @@ func (qb *TagStore) find(ctx context.Context, id int) (*models.Tag, error) {
return ret, nil
}
func (qb *TagStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]*models.Tag, error) {
table := qb.table()
q := qb.selectDataset().Prepared(true).Where(
table.Col(idColumn).Eq(
sq,
),
)
return qb.getMany(ctx, q)
}
// returns nil, sql.ErrNoRows if not found
func (qb *TagStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Tag, error) {
ret, err := qb.getMany(ctx, q)
@@ -591,27 +579,6 @@ func (qb *TagStore) FindByNames(ctx context.Context, names []string, nocase bool
return ret, nil
}
func (qb *TagStore) FindByAlias(ctx context.Context, alias string, nocase bool) (*models.Tag, error) {
where := fmt.Sprintf("%s = ?", tagAliasColumn)
if nocase {
where += " COLLATE NOCASE"
}
sq := dialect.From(tagsAliasesJoinTable).Select(
tagsAliasesJoinTable.Col(tagIDColumn),
).Prepared(true).Where(goqu.L(where, alias)).Limit(1)
ret, err := qb.findBySubquery(ctx, sq)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
if len(ret) == 0 {
return nil, nil
}
return ret[0], nil
}
func (qb *TagStore) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Tag, error) {
sq := dialect.From(tagsStashIDsJoinTable).Select(tagsStashIDsJoinTable.Col(tagIDColumn)).Where(
tagsStashIDsJoinTable.Col("stash_id").Eq(stashID.StashID),

View File

@@ -100,24 +100,6 @@ func TestTagFindByName(t *testing.T) {
})
}
func TestTagFindByAlias(t *testing.T) {
withTxn(func(ctx context.Context) error {
tqb := db.Tag
alias := getTagStringValue(tagIdxWithScene, "Alias")
tag, err := tqb.FindByAlias(ctx, alias, false)
if err != nil {
t.Errorf("Error finding tags: %s", err.Error())
}
assert.Equal(t, tagIDs[tagIdxWithScene], tag.ID)
return nil
})
}
func TestTagQueryIgnoreAutoTag(t *testing.T) {
withTxn(func(ctx context.Context) error {
ignoreAutoTag := true

View File

@@ -6,24 +6,50 @@ import (
"github.com/stashapp/stash/pkg/models"
)
func ByName(ctx context.Context, qb models.TagNameFinder, name string) (*models.Tag, error) {
const nocase = true
ret, err := qb.FindByName(ctx, name, nocase)
func ByName(ctx context.Context, qb models.TagQueryer, name string) (*models.Tag, error) {
f := &models.TagFilterType{
Name: &models.StringCriterionInput{
Value: name,
Modifier: models.CriterionModifierEquals,
},
}
pp := 1
ret, count, err := qb.Query(ctx, f, &models.FindFilterType{
PerPage: &pp,
})
if err != nil {
return nil, err
}
return ret, nil
if count > 0 {
return ret[0], nil
}
return nil, nil
}
func ByAlias(ctx context.Context, qb models.TagNameFinder, alias string) (*models.Tag, error) {
const nocase = true
ret, err := qb.FindByAlias(ctx, alias, nocase)
func ByAlias(ctx context.Context, qb models.TagQueryer, alias string) (*models.Tag, error) {
f := &models.TagFilterType{
Aliases: &models.StringCriterionInput{
Value: alias,
Modifier: models.CriterionModifierEquals,
},
}
pp := 1
ret, count, err := qb.Query(ctx, f, &models.FindFilterType{
PerPage: &pp,
})
if err != nil {
return nil, err
}
return ret, nil
if count > 0 {
return ret[0], nil
}
return nil, nil
}

View File

@@ -42,7 +42,7 @@ func (e *InvalidTagHierarchyError) Error() string {
// EnsureTagNameUnique returns an error if the tag name provided
// is used as a name or alias of another existing tag.
func EnsureTagNameUnique(ctx context.Context, id int, name string, qb models.TagNameFinder) error {
func EnsureTagNameUnique(ctx context.Context, id int, name string, qb models.TagQueryer) error {
// ensure name is unique
sameNameTag, err := ByName(ctx, qb, name)
if err != nil {
@@ -71,7 +71,7 @@ func EnsureTagNameUnique(ctx context.Context, id int, name string, qb models.Tag
return nil
}
func EnsureAliasesUnique(ctx context.Context, id int, aliases []string, qb models.TagNameFinder) error {
func EnsureAliasesUnique(ctx context.Context, id int, aliases []string, qb models.TagQueryer) error {
for _, a := range aliases {
if err := EnsureTagNameUnique(ctx, id, a, qb); err != nil {
return err

View File

@@ -1,60 +1,71 @@
package tag
import (
"context"
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stretchr/testify/assert"
)
type tagNameFinderMock struct {
existingTags []*models.Tag
}
func (m tagNameFinderMock) FindByName(ctx context.Context, name string, nocase bool) (*models.Tag, error) {
for _, n := range m.existingTags {
if n.Name == name {
return n, nil
}
func nameFilter(n string) *models.TagFilterType {
return &models.TagFilterType{
Name: &models.StringCriterionInput{
Value: n,
Modifier: models.CriterionModifierEquals,
},
}
return nil, nil
}
func (m tagNameFinderMock) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Tag, error) {
panic("not implemented")
}
func (m tagNameFinderMock) FindByAlias(ctx context.Context, alias string, nocase bool) (*models.Tag, error) {
for _, n := range m.existingTags {
for _, a := range n.Aliases.List() {
if a == alias {
return n, nil
}
}
func aliasFilter(n string) *models.TagFilterType {
return &models.TagFilterType{
Aliases: &models.StringCriterionInput{
Value: n,
Modifier: models.CriterionModifierEquals,
},
}
return nil, nil
}
func TestEnsureAliasesUnique(t *testing.T) {
db := mocks.NewDatabase()
const (
name1 = "name 1"
name2 = "name 2"
name3 = "name 3"
alias1 = "alias 1"
newAlias = "new alias"
)
tagMock := tagNameFinderMock{
existingTags: []*models.Tag{
{Name: name1, Aliases: models.NewRelatedStrings([]string{})},
{Name: name2, Aliases: models.NewRelatedStrings([]string{})},
{Name: name3, Aliases: models.NewRelatedStrings([]string{newAlias})},
},
existing2 := models.Tag{
ID: 2,
Name: name2,
}
pp := 1
findFilter := &models.FindFilterType{
PerPage: &pp,
}
// name1 matches existing1 name - ok
// EnsureAliasesUnique calls EnsureTagNameUnique.
// EnsureTagNameUnique calls ByName then ByAlias.
// Case 1: valid alias
// ByName "alias 1" -> nil
// ByAlias "alias 1" -> nil
db.Tag.On("Query", testCtx, nameFilter(alias1), findFilter).Return(nil, 0, nil)
db.Tag.On("Query", testCtx, aliasFilter(alias1), findFilter).Return(nil, 0, nil)
// Case 2: alias duplicates existing2 name
// ByName "name 2" -> existing2
db.Tag.On("Query", testCtx, nameFilter(name2), findFilter).Return([]*models.Tag{&existing2}, 1, nil)
// Case 3: alias duplicates existing2 alias
// ByName "new alias" -> nil
// ByAlias "new alias" -> existing2
db.Tag.On("Query", testCtx, nameFilter(newAlias), findFilter).Return(nil, 0, nil)
db.Tag.On("Query", testCtx, aliasFilter(newAlias), findFilter).Return([]*models.Tag{&existing2}, 1, nil)
tests := []struct {
tName string
id int
@@ -63,12 +74,12 @@ func TestEnsureAliasesUnique(t *testing.T) {
}{
{"valid alias", 1, []string{alias1}, nil},
{"alias duplicates other name", 1, []string{name2}, &NameExistsError{name2}},
{"alias duplicates other alias", 1, []string{newAlias}, &NameUsedByAliasError{newAlias, tagMock.existingTags[2].Name}},
{"alias duplicates other alias", 1, []string{newAlias}, &NameUsedByAliasError{newAlias, existing2.Name}},
}
for _, tt := range tests {
t.Run(tt.tName, func(t *testing.T) {
got := EnsureAliasesUnique(testCtx, tt.id, tt.aliases, tagMock)
got := EnsureAliasesUnique(testCtx, tt.id, tt.aliases, db.Tag)
assert.Equal(t, tt.want, got)
})
}

View File

@@ -73,7 +73,7 @@ export const MarkerWallItem: React.FC<
divStyle.top = props.top;
}
const handleClick = function (event: React.MouseEvent) {
var handleClick = function handleClick(event: React.MouseEvent) {
if (props.selecting && props.onSelectedChanged) {
props.onSelectedChanged(!props.selected, event.shiftKey);
event.preventDefault();
@@ -131,8 +131,7 @@ export const MarkerWallItem: React.FC<
alt={props.photo.alt}
onMouseEnter={() => setActive(true)}
onMouseLeave={() => setActive(false)}
// having a click handler here results in multiple calls to handleClick
// due to having the same click handler on the parent div
onClick={handleClick}
onError={() => {
props.photo.onError?.(props.photo);
}}

View File

@@ -70,7 +70,7 @@ export const SceneWallItem: React.FC<
divStyle.top = props.top;
}
const handleClick = function (event: React.MouseEvent) {
var handleClick = function handleClick(event: React.MouseEvent) {
if (props.selecting && props.onSelectedChanged) {
props.onSelectedChanged(!props.selected, event.shiftKey);
event.preventDefault();
@@ -96,8 +96,7 @@ export const SceneWallItem: React.FC<
alt: props.photo.alt,
onMouseEnter: () => setActive(true),
onMouseLeave: () => setActive(false),
// having a click handler here results in multiple calls to handleClick
// due to having the same click handler on the parent div
onClick: handleClick,
onError: () => {
props.photo.onError?.(props.photo);
},

View File

@@ -51,10 +51,6 @@
flex-direction: column;
overflow-wrap: anywhere;
width: 100%;
.optional-field-content {
min-width: 0;
}
}
.original-scene-details {
@@ -282,10 +278,6 @@
.form-check {
font-size: 1rem;
}
p.lead {
margin-top: 1rem;
}
}
.StudioTagger,

View File

@@ -5,18 +5,14 @@ import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import * as GQL from "src/core/generated-graphql";
import { Icon } from "src/components/Shared/Icon";
import { ModalComponent } from "src/components/Shared/Modal";
import {
faCheck,
faExclamationTriangle,
faTimes,
} from "@fortawesome/free-solid-svg-icons";
import { faCheck, faTimes } from "@fortawesome/free-solid-svg-icons";
import { Button, Form } from "react-bootstrap";
import { TruncatedText } from "src/components/Shared/TruncatedText";
import { excludeFields } from "src/utils/data";
import { StashIDPill } from "src/components/Shared/StashID";
interface ITagModalProps {
tag: GQL.ScrapedTag;
tag: GQL.ScrapedSceneTagDataFragment;
modalVisible: boolean;
closeModal: () => void;
onSave: (input: GQL.TagCreateInput, parentInput?: GQL.TagCreateInput) => void;
@@ -182,15 +178,6 @@ const TagModal: React.FC<ITagModalProps> = ({
// force create if there is no current parent tag and parent tag is not excluded
const mustCreateParent = true;
// warn the user if the parent tag does not have a remote_site_id,
// which means it won't be automatically linked to the source tag
const missingStashIDWarning = !tag.parent.remote_site_id && (
<p className="lead">
<Icon icon={faExclamationTriangle} className="text-warning" />
<FormattedMessage id="tag_tagger.parent_tag_no_remote_site_id_warning" />
</p>
);
return (
<div>
<div className="mb-4 mt-4">
@@ -205,7 +192,6 @@ const TagModal: React.FC<ITagModalProps> = ({
/>
</div>
{maybeRenderParentTagDetails()}
{missingStashIDWarning}
</div>
);
}

View File

@@ -103,7 +103,7 @@ const TagTaggerList: React.FC<ITagTaggerListProps> = ({
const [modalTag, setModalTag] = useState<
| {
existingTag: GQL.TagListDataFragment;
scrapedTag: GQL.ScrapedTag;
scrapedTag: GQL.ScrapedSceneTagDataFragment;
}
| undefined
>();

View File

@@ -14,7 +14,6 @@
### 🎨 Improvements
* **[0.31.1]** Added warning when creating a parent tag using the tag tagger where the parent tag has no remote site id. ([#6805](https://github.com/stashapp/stash/pull/6805))
* Sidebars are now used for lists of galleries ([#6157](https://github.com/stashapp/stash/pull/6157)), images ([#6607](https://github.com/stashapp/stash/pull/6607)), groups ([#6573](https://github.com/stashapp/stash/pull/6573)), performers ([#6547](https://github.com/stashapp/stash/pull/6547)), studios ([#6549](https://github.com/stashapp/stash/pull/6549)), tags ([#6610](https://github.com/stashapp/stash/pull/6610)), and scene markers ([#6603](https://github.com/stashapp/stash/pull/6603)).
* Added folder sidebar criterion option for scenes, images and galleries. ([#6636](https://github.com/stashapp/stash/pull/6636))
* Custom field support has been added to scenes ([#6584](https://github.com/stashapp/stash/pull/6584)), galleries ([#6592](https://github.com/stashapp/stash/pull/6592)), images ([#6598](https://github.com/stashapp/stash/pull/6598)), groups ([#6596](https://github.com/stashapp/stash/pull/6596)) studios ([#6156](https://github.com/stashapp/stash/pull/6156)) and tags ([#6546](https://github.com/stashapp/stash/pull/6546)).
@@ -64,14 +63,6 @@
### 🐛 Bug fixes
* **[0.31.1]** Fixed memory leak in scanning process. ([#6796](https://github.com/stashapp/stash/pull/6796))
* **[0.31.1]** Schema migration 84 now attempts to de-duplicate folder entries to prevent unique constraint violations. ([#6792](https://github.com/stashapp/stash/pull/6792))
* **[0.31.1]** Fixed issue where navigating to a scene from the wall view on the scene or marker list page would require clicking Back twice to return to the previous page. ([#6803](https://github.com/stashapp/stash/pull/6803))
* **[0.31.1]** Page is now reset when changing the selected folder in the folder sidebar filter. ([#6804](https://github.com/stashapp/stash/pull/6804))
* **[0.31.1]** Fixed stash ID pill overflowing on mobile viewports. ([#6807](https://github.com/stashapp/stash/pull/6807))
* **[0.31.1]** Migration process now attempts to create the backup directory if it does not exist. ([#6808](https://github.com/stashapp/stash/pull/6808))
* **[0.31.1]** Fixed tag uniqueness check incorrectly interpreting `_` as a wildcard. ([#6809](https://github.com/stashapp/stash/pull/6809))
* **[0.31.1]** Fixed websocket connection error when sending messages containing certain unicode sequences. ([#6810](https://github.com/stashapp/stash/pull/6810))
* Fixed certain unicode characters in library path causing panic in scan task. ([#6431](https://github.com/stashapp/stash/pull/6431), [#6589](https://github.com/stashapp/stash/pull/6589), [#6635](https://github.com/stashapp/stash/pull/6635))
* Fixed bad network path error preventing rename detection during scanning. ([#6680](https://github.com/stashapp/stash/pull/6680))
* Fixed duplicate files in zips being incorrectly reported as renames. ([#6493](https://github.com/stashapp/stash/pull/6493))

View File

@@ -9,7 +9,7 @@ The text field allows you to search using keywords. Keyword searching matches on
| Type | Fields searched |
|------|-----------------|
| Scene | Title, Details, Path, OSHash, Checksum, Marker titles |
| Image | Title, Path, Checksum |
| Image | Title, Details, Path, Checksum |
| Group | Title |
| Marker | Title, Scene title |
| Gallery | Title, Path, Checksum |

View File

@@ -8,10 +8,10 @@ Ensure the caption files follow these naming conventions:
## Scene
- {scene_file_name}.{language_code}.{ext}
- {scene_file_name}.{ext}
- {scene_file_name}.{language_code}.ext
- {scene_file_name}.ext
Where `{language_code}` is defined by the [ISO-6399-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) (2 letters) standard and `{ext}` is the file extension. Captions files without a language code will be labeled as Unknown in the video player but will work fine.
Where `{language_code}` is defined by the [ISO-6399-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) (2 letters) standard and `ext` is the file extension. Captions files without a language code will be labeled as Unknown in the video player but will work fine.
Scenes with captions can be filtered with the `captions` criterion.

View File

@@ -1,9 +1,9 @@
# Introduction
Stash works by cataloging your media using the paths that you provide. Once you have [configured](/settings?tab=library) the locations where your media is stored, you can click the Scan button in [`Settings -> Tasks`](/settings?tab=tasks) and Stash will begin scanning and importing your media into its library.
Stash works by cataloging your media using the paths that you provide. Once you have [configured](/settings?tab=library) the locations where your media is stored, you can click the Scan button in [`Settings -> Tasks`](/settings?tab=tasks) and stash will begin scanning and importing your media into its library.
For the best experience, it is recommended that after a scan is finished, you also generate video previews and sprites. You can do this in [`Settings -> Tasks`](/settings?tab=tasks).
For the best experience, it is recommended that after a scan is finished, that video previews and sprites are generated. You can do this in [`Settings -> Tasks`](/settings?tab=tasks).
> **⚠️ Note:** Currently, it is only possible to perform one task at a time. However, there is a task queue, so you can queue generation tasks to be performed immediately after the scan is complete.
> **⚠️ Note:** Currently it is only possible to perform one task at a time and but there is a task queue, so the generate tasks should be performed after scan is complete.
Once your media is imported, you are ready to begin creating Performers, Studios, and Tags, and curating your content!
Once your media is imported, you are ready to begin creating Performers, Studios and Tags, and curating your content!

View File

@@ -8,10 +8,9 @@ The fingerprint search matches your current selection of files against the remot
If no fingerprint match is found it's possible to search by keywords. The search works by matching the query against a scene's _title_, _release date_, _studio name_, and _performer names_. By default the tagger uses metadata set on the file, or parses the filename, this can be changed in the config.
An important thing to note is that it only returns a match *if all query terms are a match*. As an example, if a scene is titled `"A Trip to the Mall"` with the performer `"Jane Doe"`, a search for `"Trip to the Mall 1080p"` will *not* match, however `"trip mall doe"` would. Usually a few pieces of info are enough, for instance performer name + release date or studio name. To avoid common non-related keywords you can add them to the blacklist in the tagger config. Any items in the blacklist are stripped out of the query.
An important thing to note is that it only returns a match *if all query terms are a match*. As an example, if a scene is titled `"A Trip to the Mall"` with the performer `"Jane Doe"`, a search for `"Trip to the Mall 1080p"` will *not* match, however `"trip mall doe"` would. Usually a few pieces of info is enough, for instance performer name + release date or studio name. To avoid common non-related keywords you can add them to the blacklist in the tagger config. Any items in the blacklist are stripped out of the query.
## Saving
When a scene is matched stash will try to match the studio and performers against your local studios and performers. If you have previously matched them, they will automatically be selected. If not you either have to select the correct performer/studio from the dropdown, choose create to create a new entity, or skip to ignore it.
Once a scene is saved the scene and the matched studio/performers will have the `stash_id` saved which will then be used for future tagging.
@@ -19,7 +18,4 @@ Once a scene is saved the scene and the matched studio/performers will have the
By default male performers are not shown, this can be enabled in the tagger config. Likewise scene tags are by default not saved. They can be set to either merge with existing tags on the scene, or overwrite them. It is not recommended to set tags currently since they are hard to deduplicate and can litter your data.
## Submitting fingerprints
After a scene is saved you will be prompted to submit the fingerprint back to the stash-box instance. This is optional, but can be helpful for other users who have an identical or similar copy which will allow them to be able to match via the fingerprint search. Stash only sends `stash_id` and file fingerprint.
Submitted fingerprints are linked to your account via your stash-box API key and can be managed on the stash-box website. Stash does not store any additional information about submitted fingerprints. If you delete a fingerprint on the stash-box website, it will also be removed from the instance and will no longer be available for matching.
After a scene is saved you will prompted to submit the fingerprint back to the stash-box instance. This is optional, but can be helpful for other users who have an identical copy who will then be able to match via the fingerprint search. No other information than the `stash_id` and file fingerprint is submitted.

View File

@@ -1638,7 +1638,6 @@
"network_error": "Network Error",
"no_results_found": "No results found.",
"number_of_tags_will_be_processed": "{tag_count} tags will be processed",
"parent_tag_no_remote_site_id_warning": "Parent tag does not have a remote site ID, and will not be linked to the stash-box instance.",
"query_all_tags_in_the_database": "All tags in the database",
"refresh_tagged_tags": "Refresh tagged tags",
"refreshing_will_update_the_data": "Refreshing will update the data of any tagged tags from the stash-box instance.",

View File

@@ -510,7 +510,6 @@ export class ListFilterModel {
public setCriteria(criteria: Criterion[]) {
const ret = this.clone();
ret.criteria = criteria;
ret.currentPage = 1; // reset to first page
return ret;
}