Compare commits

..

14 Commits

Author SHA1 Message Date
DogmaDragon
6cc5349fd1 Clarify caption file naming conventions in documentation 2026-04-13 03:52:27 +03:00
WithoutPants
968a97aa45 Update changelog 2026-04-10 16:06:29 +10:00
dev-null-life
f920bd8b8e Fix WebSocket UTF-8 error for non-UTF-8 file paths in subscriptions (#6810)
* Fix WebSocket UTF-8 error for non-UTF-8 file paths in subscriptions

Sanitize log messages and job fields (description, subtasks, error)
before sending over WebSocket. File paths with non-UTF-8 characters
caused the browser to close the connection with "Could not decode a
text frame as UTF-8." Invalid bytes are replaced with U+FFFD.

Only the API response layer is affected — underlying stored data is
unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Replace direct ToValidUTF8 calls to new sanitiseWebsocketString function
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-04-10 13:42:42 +10:00
WithoutPants
9b5c0b0e48 Match tag names/aliases exactly when testing uniqueness (#6809)
* Add tagStore.FindByAlias method
* Change tag.ByName and ByAlias to use exact queries instead of fuzzy matching
2026-04-08 13:11:12 +10:00
WithoutPants
034ae1a141 Try to create backup directory during migrate. Log warning on failure (#6808) 2026-04-08 11:30:32 +10:00
smith113-p
3af546db92 Let the stash ID pill shrink in tagger (#6807)
* Let the stash ID pill shrink in tagger

On very narrow viewports (e.g. mobile), the stash ID pill will
overflow its container. With this PR, it will instead limit itself
to the width of the container and display with an ellipsis if
necessary.

Fixes #6786
2026-04-08 10:17:57 +10:00
WithoutPants
60ce007c02 Show warning when creating parent tag without remote_site_id (#6805) 2026-04-07 16:34:43 +10:00
WithoutPants
f81053ae7d Reset page when setting filter criteria (#6804)
Fixes sidebar folder filter not resetting page when selecting folders
2026-04-07 16:33:50 +10:00
WithoutPants
98074e3b57 Fix clicking on scene/marker wall item pushing to history twice (#6803) 2026-04-07 16:33:33 +10:00
Gykes
57ddec93e0 Fix: Update Postmigration 84 to Handle De-Duplicate of Folders. (#6792)
* update postmigration to handle deduplicate folders.
* Split post-migration to perform some tasks before the schema migration
* Reparent files and delete duplicate folder if possible
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-04-07 16:28:01 +10:00
DogmaDragon
5edd299b10 Clarify scene fingerprint submission details (#6784) 2026-04-07 15:32:53 +10:00
feederbox826
672147deaf fix memory leak (#6796)
* allow channels to passively drain, empty fileQueue, scanner after scanning
* Prevent job executor retention in subscription channels
---------
Co-authored-by: feederbox826 <feederbox826@users.noreply.github.com>
Co-authored-by: Gykes <Gykes@pm.me>
2026-04-07 09:39:30 +10:00
DogmaDragon
0ed2992a72 Fix typo in the manual (#6771) 2026-03-31 18:23:40 +11:00
DogmaDragon
e6e87d64d6 Add troubleshooting mode confirmation to bug report
Added a checkbox to confirm troubleshooting mode is enabled before filing a bug report.
2026-03-30 11:53:30 +03:00
32 changed files with 430 additions and 132 deletions

View File

@@ -6,6 +6,15 @@ body:
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: checkboxes
id: confirm-troubleshooting
attributes:
label: Have you enabled troubleshooting mode?
description: |
To ensure the bug is not caused by custom modifications or plugins make sure to enable troubleshooting mode before filing the report. In Stash go to Settings and click **Troubleshooting mode** and then retest to see if the bug still occurs.
options:
- label: I confirm that the troubleshooting mode is enabled.
required: true
- type: textarea
id: description
attributes:
@@ -61,4 +70,4 @@ body:
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output from Settings > Logs. This will be automatically formatted into code, so no need for backticks.
render: shell
render: shell

View File

@@ -47,6 +47,10 @@ 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,15 +33,26 @@ 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: j.Description,
SubTasks: j.Details,
Description: sanitiseWebsocketString(j.Description),
SubTasks: subTasks,
StartTime: j.StartTime,
EndTime: j.EndTime,
AddTime: j.AddTime,
Error: j.Error,
Error: jobError,
}
if j.Progress != -1 {

View File

@@ -2,11 +2,19 @@ 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":
@@ -33,7 +41,7 @@ func logEntriesFromLogItems(logItems []log.LogItem) []*LogEntry {
ret[i] = &LogEntry{
Time: entry.Time,
Level: getLogLevel(entry.Type),
Message: entry.Message,
Message: sanitiseWebsocketString(entry.Message),
}
}

View File

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

View File

@@ -66,6 +66,23 @@ 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:
case s.newJob <- j.statusCopy():
default:
}
}
@@ -232,7 +232,9 @@ func (m *Manager) removeJob(job *Job) {
return
}
// clear any subtasks
// 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
job.Details = nil
m.queue = append(m.queue[:index], m.queue[index+1:]...)
@@ -246,7 +248,7 @@ func (m *Manager) removeJob(job *Job) {
for _, s := range m.subscriptions {
// don't block if channel is full
select {
case s.removedJob <- *job:
case s.removedJob <- job.statusCopy():
default:
}
}
@@ -310,8 +312,7 @@ 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 {
// make a copy of the job and return the pointer
jCopy := *j
jCopy := j.statusCopy()
return &jCopy
}
@@ -326,8 +327,7 @@ func (m *Manager) GetQueue() []Job {
var ret []Job
for _, j := range m.queue {
jCopy := *j
ret = append(ret, jCopy)
ret = append(ret, j.statusCopy())
}
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:
case s.updatedJob <- j.statusCopy():
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) {
return
continue // allow channel to continue draining until Close()
}
tt := task

View File

@@ -22,7 +22,7 @@ type GroupNamesFinder interface {
type SceneRelationships struct {
PerformerFinder PerformerFinder
TagFinder models.TagQueryer
TagFinder models.TagNameFinder
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.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error {
func ScrapedTagHierarchy(ctx context.Context, qb models.TagNameFinder, 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.TagQueryer, s *models.Sc
// 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.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error {
func ScrapedTag(ctx context.Context, qb models.TagNameFinder, s *models.ScrapedTag, stashBoxEndpoint string) error {
if s.StoredID != nil {
return nil
}

View File

@@ -197,6 +197,29 @@ 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,9 +9,16 @@ 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)
@@ -23,8 +30,6 @@ 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.TagQueryer
Tag models.TagNameFinder
}
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.TagQueryer, tagName string) *models.Tag {
func (p *FilenameParser) queryTag(ctx context.Context, qb models.TagNameFinder, 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.TagQueryer, h sceneHolder, result *models.SceneParserResult) {
func (p *FilenameParser) setTags(ctx context.Context, qb models.TagNameFinder, h sceneHolder, result *models.SceneParserResult) {
// query for each performer
tagsSet := make(map[int]bool)
for _, tagName := range h.tags {

View File

@@ -70,6 +70,7 @@ 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.TagQueryer, scrapedTags []*models.ScrapedTag) (ret []*models.ScrapedTag, err error) {
func postProcessTags(ctx context.Context, tqb models.TagNameFinder, 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 post84(ctx context.Context, db *sqlx.DB) error {
logger.Info("Running post-migration for schema version 84")
func pre84(ctx context.Context, db *sqlx.DB) error {
logger.Info("Running pre-migration for schema version 84")
m := schema84Migrator{
migrator: migrator{
@@ -36,6 +36,23 @@ func post84(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)
}
@@ -188,7 +205,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`,`basename`,`parent_folder_id`,`mod_time`,`created_at`,`updated_at`) VALUES (?,?,?,?,?,?)"
const insertSQL = "INSERT INTO `folders` (`path`,`parent_folder_id`,`mod_time`,`created_at`,`updated_at`) VALUES (?,?,?,?,?)"
var parentFolderID null.Int
if parentID != nil {
@@ -196,7 +213,7 @@ func (m *schema84Migrator) getOrCreateFolderHierarchy(tx *sqlx.Tx, path string,
}
now := time.Now()
result, err := tx.Exec(insertSQL, path, path, parentFolderID, time.Time{}, now, now)
result, err := tx.Exec(insertSQL, path, parentFolderID, time.Time{}, now, now)
if err != nil {
return nil, fmt.Errorf("creating folder %s: %w", path, err)
}
@@ -264,11 +281,6 @@ 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)
@@ -278,6 +290,11 @@ 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)
@@ -309,6 +326,136 @@ 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
@@ -381,5 +528,6 @@ func (m *schema84Migrator) migrateFolders(ctx context.Context) error {
}
func init() {
sqlite.RegisterPreMigration(84, pre84)
sqlite.RegisterPostMigration(84, post84)
}

View File

@@ -416,6 +416,18 @@ 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)
@@ -579,6 +591,27 @@ 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,6 +100,24 @@ 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,50 +6,24 @@ import (
"github.com/stashapp/stash/pkg/models"
)
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,
})
func ByName(ctx context.Context, qb models.TagNameFinder, name string) (*models.Tag, error) {
const nocase = true
ret, err := qb.FindByName(ctx, name, nocase)
if err != nil {
return nil, err
}
if count > 0 {
return ret[0], nil
}
return nil, nil
return ret, nil
}
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,
})
func ByAlias(ctx context.Context, qb models.TagNameFinder, alias string) (*models.Tag, error) {
const nocase = true
ret, err := qb.FindByAlias(ctx, alias, nocase)
if err != nil {
return nil, err
}
if count > 0 {
return ret[0], nil
}
return nil, nil
return ret, 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.TagQueryer) error {
func EnsureTagNameUnique(ctx context.Context, id int, name string, qb models.TagNameFinder) 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.TagQueryer) error {
func EnsureAliasesUnique(ctx context.Context, id int, aliases []string, qb models.TagNameFinder) error {
for _, a := range aliases {
if err := EnsureTagNameUnique(ctx, id, a, qb); err != nil {
return err

View File

@@ -1,71 +1,60 @@
package tag
import (
"context"
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stretchr/testify/assert"
)
func nameFilter(n string) *models.TagFilterType {
return &models.TagFilterType{
Name: &models.StringCriterionInput{
Value: n,
Modifier: models.CriterionModifierEquals,
},
}
type tagNameFinderMock struct {
existingTags []*models.Tag
}
func aliasFilter(n string) *models.TagFilterType {
return &models.TagFilterType{
Aliases: &models.StringCriterionInput{
Value: n,
Modifier: models.CriterionModifierEquals,
},
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
}
}
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
}
}
}
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"
)
existing2 := models.Tag{
ID: 2,
Name: name2,
tagMock := tagNameFinderMock{
existingTags: []*models.Tag{
{Name: name1, Aliases: models.NewRelatedStrings([]string{})},
{Name: name2, Aliases: models.NewRelatedStrings([]string{})},
{Name: name3, Aliases: models.NewRelatedStrings([]string{newAlias})},
},
}
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
@@ -74,12 +63,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, existing2.Name}},
{"alias duplicates other alias", 1, []string{newAlias}, &NameUsedByAliasError{newAlias, tagMock.existingTags[2].Name}},
}
for _, tt := range tests {
t.Run(tt.tName, func(t *testing.T) {
got := EnsureAliasesUnique(testCtx, tt.id, tt.aliases, db.Tag)
got := EnsureAliasesUnique(testCtx, tt.id, tt.aliases, tagMock)
assert.Equal(t, tt.want, got)
})
}

View File

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

View File

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

View File

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

View File

@@ -5,14 +5,18 @@ 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, faTimes } from "@fortawesome/free-solid-svg-icons";
import {
faCheck,
faExclamationTriangle,
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.ScrapedSceneTagDataFragment;
tag: GQL.ScrapedTag;
modalVisible: boolean;
closeModal: () => void;
onSave: (input: GQL.TagCreateInput, parentInput?: GQL.TagCreateInput) => void;
@@ -178,6 +182,15 @@ 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">
@@ -192,6 +205,7 @@ 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.ScrapedSceneTagDataFragment;
scrapedTag: GQL.ScrapedTag;
}
| undefined
>();

View File

@@ -14,6 +14,7 @@
### 🎨 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)).
@@ -63,6 +64,14 @@
### 🐛 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

@@ -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, that video previews and sprites are generated. You can do this in [`Settings -> Tasks`](/settings?tab=tasks).
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).
> **⚠️ 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.
> **⚠️ 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.
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,9 +8,10 @@ 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 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.
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.
## 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.
@@ -18,4 +19,7 @@ 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 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.
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.

View File

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