mirror of
https://github.com/stashapp/stash.git
synced 2026-06-10 21:42:03 -05:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4de2351e7c | ||
|
|
82d12145cc | ||
|
|
968a97aa45 | ||
|
|
f920bd8b8e | ||
|
|
9b5c0b0e48 | ||
|
|
034ae1a141 | ||
|
|
3af546db92 | ||
|
|
60ce007c02 | ||
|
|
f81053ae7d | ||
|
|
98074e3b57 | ||
|
|
57ddec93e0 | ||
|
|
5edd299b10 | ||
|
|
672147deaf | ||
|
|
0ed2992a72 |
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -70,6 +70,7 @@ type StudioFinder interface {
|
||||
|
||||
type TagFinder interface {
|
||||
models.TagGetter
|
||||
models.TagNameFinder
|
||||
models.TagAutoTagQueryer
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ const TagTaggerList: React.FC<ITagTaggerListProps> = ({
|
||||
const [modalTag, setModalTag] = useState<
|
||||
| {
|
||||
existingTag: GQL.TagListDataFragment;
|
||||
scrapedTag: GQL.ScrapedSceneTagDataFragment;
|
||||
scrapedTag: GQL.ScrapedTag;
|
||||
}
|
||||
| undefined
|
||||
>();
|
||||
|
||||
@@ -290,7 +290,7 @@ export const FilteredTagList = PatchComponent(
|
||||
showModal(
|
||||
<ExportDialog
|
||||
exportInput={{
|
||||
studios: {
|
||||
tags: {
|
||||
ids: Array.from(selectedIds.values()),
|
||||
all: all,
|
||||
},
|
||||
|
||||
@@ -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,15 @@
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
* **[0.31.1]** Fixed tag export outputting studios instead of tags. ([#6819](https://github.com/stashapp/stash/pull/6819))
|
||||
* **[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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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!
|
||||
@@ -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.
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user