From e69238307c18ddadd90665b808885e4bd2eb897e Mon Sep 17 00:00:00 2001 From: dogwithakeyboard <128322708+dogwithakeyboard@users.noreply.github.com> Date: Mon, 23 Jun 2025 22:59:27 +0100 Subject: [PATCH 001/157] add missing property to death date item (#5962) --- .../Performers/PerformerDetails/PerformerDetailsPanel.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index e96d464be..d01709287 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -94,7 +94,11 @@ export const PerformerDetailsPanel: React.FC = } fullWidth={fullWidth} /> - + {performer.country ? ( Date: Tue, 24 Jun 2025 08:27:41 +1000 Subject: [PATCH 002/157] Update manual with new patchable components --- ui/v2.5/src/docs/en/Manual/UIPluginApi.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md index 4453dfb10..37c9993ba 100644 --- a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md +++ b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md @@ -181,6 +181,8 @@ Returns `void`. - `GroupSelect` - `GroupSelect.sort` - `NumberSetting` +- `Pagination` +- `PaginationIndex` - `PerformerAppearsWithPanel` - `PerformerCard` - `PerformerCard.Details` From 81c3988777264dc922f0b9bc517b3b995090632b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 24 Jun 2025 13:01:28 +1000 Subject: [PATCH 003/157] Give bottom pagination bar transparent background (#5958) --- ui/v2.5/src/components/List/styles.scss | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index cc43bbecd..9a719b4b2 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -952,7 +952,7 @@ input[type="range"].zoom-slider { } .pagination-footer { - background-color: $body-bg; + background-color: transparent; bottom: $navbar-height; padding: 0.5rem 1rem; position: sticky; @@ -964,5 +964,10 @@ input[type="range"].zoom-slider { .pagination { margin-bottom: 0; + + .btn:disabled { + color: #888; + opacity: 1; + } } } From 8d78fd682d250f9ce6ab33b728226d99bb64279f Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Mon, 23 Jun 2025 23:02:19 -0400 Subject: [PATCH 004/157] Include searching by tag sort name (#5963) --- pkg/sqlite/tag.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index f690220a7..08337616e 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -607,7 +607,7 @@ func (qb *TagStore) Query(ctx context.Context, tagFilter *models.TagFilterType, if q := findFilter.Q; q != nil && *q != "" { query.join(tagAliasesTable, "", "tag_aliases.tag_id = tags.id") - searchColumns := []string{"tags.name", "tag_aliases.alias"} + searchColumns := []string{"tags.name", "tag_aliases.alias", "tags.sort_name"} query.parseQueryString(searchColumns, *q) } From 704041d5e05be3fa2f04dd867f45b77cf35b212e Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 24 Jun 2025 13:05:17 +1000 Subject: [PATCH 005/157] Add findFiles and findFile graphql queries (#5941) * Add findFile and findFiles * Add parent folder and zip file fields to file graphql types * Add parent_folder, zip_file fields to Folder graphql type * Add format to ImageFile type * Add format filter fields to image/video file filters --- gqlgen.yml | 2 + graphql/schema/schema.graphql | 10 + graphql/schema/types/file.graphql | 71 +++++- graphql/schema/types/filters.graphql | 71 ++++++ internal/api/fields.go | 23 ++ internal/api/loaders/dataloaders.go | 18 ++ internal/api/loaders/folderloader_gen.go | 224 +++++++++++++++++ internal/api/models.go | 36 +++ internal/api/resolver.go | 8 + internal/api/resolver_model_file.go | 86 +++++-- internal/api/resolver_model_folder.go | 20 ++ internal/api/resolver_query_find_file.go | 120 +++++++++ pkg/models/file.go | 33 ++- pkg/models/filter.go | 28 +++ pkg/models/image.go | 2 +- pkg/models/mocks/FolderReaderWriter.go | 23 ++ pkg/models/model_file.go | 12 + pkg/models/query.go | 4 +- pkg/models/repository_folder.go | 1 + pkg/models/scene.go | 2 +- pkg/sqlite/batch.go | 2 +- pkg/sqlite/criterion_handlers.go | 11 + pkg/sqlite/file.go | 81 +++++- pkg/sqlite/file_filter.go | 302 +++++++++++++++++++++++ pkg/sqlite/file_filter_test.go | 101 ++++++++ pkg/sqlite/folder.go | 47 ++++ 26 files changed, 1293 insertions(+), 45 deletions(-) create mode 100644 internal/api/fields.go create mode 100644 internal/api/loaders/folderloader_gen.go create mode 100644 internal/api/resolver_model_folder.go create mode 100644 internal/api/resolver_query_find_file.go create mode 100644 pkg/sqlite/file_filter.go create mode 100644 pkg/sqlite/file_filter_test.go diff --git a/gqlgen.yml b/gqlgen.yml index d3b8fc67f..b949d44dc 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -35,6 +35,8 @@ models: model: github.com/stashapp/stash/internal/api.BoolMap PluginConfigMap: model: github.com/stashapp/stash/internal/api.PluginConfigMap + File: + model: github.com/stashapp/stash/internal/api.File VideoFile: fields: # override float fields - #1572 diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 51718aee3..1ca653403 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -6,6 +6,16 @@ type Query { findDefaultFilter(mode: FilterMode!): SavedFilter @deprecated(reason: "default filter now stored in UI config") + "Find a file by its id or path" + findFile(id: ID, path: String): BaseFile! + + "Queries for Files" + findFiles( + file_filter: FileFilterType + filter: FindFilterType + ids: [ID!] + ): FindFilesResultType! + "Find a scene by ID or Checksum" findScene(id: ID, checksum: String): Scene findSceneByHash(input: SceneHashInput!): Scene diff --git a/graphql/schema/types/file.graphql b/graphql/schema/types/file.graphql index 8dea777bd..c967c38f2 100644 --- a/graphql/schema/types/file.graphql +++ b/graphql/schema/types/file.graphql @@ -7,8 +7,11 @@ type Folder { id: ID! path: String! - parent_folder_id: ID - zip_file_id: ID + parent_folder_id: ID @deprecated(reason: "Use parent_folder instead") + zip_file_id: ID @deprecated(reason: "Use zip_file instead") + + parent_folder: Folder! + zip_file: BasicFile mod_time: Time! @@ -21,8 +24,32 @@ interface BaseFile { path: String! basename: String! - parent_folder_id: ID! - zip_file_id: ID + parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead") + zip_file_id: ID @deprecated(reason: "Use zip_file instead") + + parent_folder: Folder! + zip_file: BasicFile + + mod_time: Time! + size: Int64! + + fingerprint(type: String!): String + fingerprints: [Fingerprint!]! + + created_at: Time! + updated_at: Time! +} + +type BasicFile implements BaseFile { + id: ID! + path: String! + basename: String! + + parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead") + zip_file_id: ID @deprecated(reason: "Use zip_file instead") + + parent_folder: Folder! + zip_file: BasicFile mod_time: Time! size: Int64! @@ -39,8 +66,11 @@ type VideoFile implements BaseFile { path: String! basename: String! - parent_folder_id: ID! - zip_file_id: ID + parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead") + zip_file_id: ID @deprecated(reason: "Use zip_file instead") + + parent_folder: Folder! + zip_file: BasicFile mod_time: Time! size: Int64! @@ -66,8 +96,11 @@ type ImageFile implements BaseFile { path: String! basename: String! - parent_folder_id: ID! - zip_file_id: ID + parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead") + zip_file_id: ID @deprecated(reason: "Use zip_file instead") + + parent_folder: Folder! + zip_file: BasicFile mod_time: Time! size: Int64! @@ -75,6 +108,7 @@ type ImageFile implements BaseFile { fingerprint(type: String!): String fingerprints: [Fingerprint!]! + format: String! width: Int! height: Int! @@ -89,8 +123,11 @@ type GalleryFile implements BaseFile { path: String! basename: String! - parent_folder_id: ID! - zip_file_id: ID + parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead") + zip_file_id: ID @deprecated(reason: "Use zip_file instead") + + parent_folder: Folder! + zip_file: BasicFile mod_time: Time! size: Int64! @@ -125,3 +162,17 @@ input FileSetFingerprintsInput { "only supplied fingerprint types will be modified" fingerprints: [SetFingerprintsInput!]! } + +type FindFilesResultType { + count: Int! + + "Total megapixels of any image files" + megapixels: Float! + "Total duration in seconds of any video files" + duration: Float! + + "Total file size in bytes" + size: Int! + + files: [BaseFile!]! +} diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 14bb8680b..cab47172e 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -681,6 +681,77 @@ input ImageFilterType { tags_filter: TagFilterType } +input FileFilterType { + AND: FileFilterType + OR: FileFilterType + NOT: FileFilterType + + path: StringCriterionInput + basename: StringCriterionInput + dir: StringCriterionInput + + parent_folder: HierarchicalMultiCriterionInput + + "Filter by modification time" + mod_time: TimestampCriterionInput + + "Filter files that have an exact match available" + duplicated: PHashDuplicationCriterionInput + + "find files based on hash" + hashes: [FingerprintFilterInput!] + + video_file_filter: VideoFileFilterInput + image_file_filter: ImageFileFilterInput + + scene_count: IntCriterionInput + image_count: IntCriterionInput + gallery_count: IntCriterionInput + + "Filter by related scenes that meet this criteria" + scenes_filter: SceneFilterType + "Filter by related images that meet this criteria" + images_filter: ImageFilterType + "Filter by related galleries that meet this criteria" + galleries_filter: GalleryFilterType + + "Filter by creation time" + created_at: TimestampCriterionInput + "Filter by last update time" + updated_at: TimestampCriterionInput +} + +input VideoFileFilterInput { + resolution: ResolutionCriterionInput + orientation: OrientationCriterionInput + framerate: IntCriterionInput + bitrate: IntCriterionInput + format: StringCriterionInput + video_codec: StringCriterionInput + audio_codec: StringCriterionInput + + "in seconds" + duration: IntCriterionInput + + captions: StringCriterionInput + + interactive: Boolean + interactive_speed: IntCriterionInput +} + +input ImageFileFilterInput { + format: StringCriterionInput + resolution: ResolutionCriterionInput + orientation: OrientationCriterionInput +} + +input FingerprintFilterInput { + type: String! + value: String! + "Hamming distance - defaults to 0" + distance: Int +} + enum CriterionModifier { "=" EQUALS diff --git a/internal/api/fields.go b/internal/api/fields.go new file mode 100644 index 000000000..5f47ed06f --- /dev/null +++ b/internal/api/fields.go @@ -0,0 +1,23 @@ +package api + +import ( + "context" + + "github.com/99designs/gqlgen/graphql" +) + +type queryFields []string + +func collectQueryFields(ctx context.Context) queryFields { + fields := graphql.CollectAllFields(ctx) + return queryFields(fields) +} + +func (f queryFields) Has(field string) bool { + for _, v := range f { + if v == field { + return true + } + } + return false +} diff --git a/internal/api/loaders/dataloaders.go b/internal/api/loaders/dataloaders.go index 493c353d7..38f72b0a1 100644 --- a/internal/api/loaders/dataloaders.go +++ b/internal/api/loaders/dataloaders.go @@ -10,6 +10,7 @@ //go:generate go run github.com/vektah/dataloaden TagLoader int *github.com/stashapp/stash/pkg/models.Tag //go:generate go run github.com/vektah/dataloaden GroupLoader int *github.com/stashapp/stash/pkg/models.Group //go:generate go run github.com/vektah/dataloaden FileLoader github.com/stashapp/stash/pkg/models.FileID github.com/stashapp/stash/pkg/models.File +//go:generate go run github.com/vektah/dataloaden FolderLoader github.com/stashapp/stash/pkg/models.FolderID *github.com/stashapp/stash/pkg/models.Folder //go:generate go run github.com/vektah/dataloaden SceneFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID //go:generate go run github.com/vektah/dataloaden ImageFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID //go:generate go run github.com/vektah/dataloaden GalleryFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID @@ -62,6 +63,7 @@ type Loaders struct { TagByID *TagLoader GroupByID *GroupLoader FileByID *FileLoader + FolderByID *FolderLoader } type Middleware struct { @@ -117,6 +119,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchFiles(ctx), }, + FolderByID: &FolderLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchFolders(ctx), + }, SceneFiles: &SceneFileIDsLoader{ wait: wait, maxBatch: maxBatch, @@ -279,6 +286,17 @@ func (m Middleware) fetchFiles(ctx context.Context) func(keys []models.FileID) ( } } +func (m Middleware) fetchFolders(ctx context.Context) func(keys []models.FolderID) ([]*models.Folder, []error) { + return func(keys []models.FolderID) (ret []*models.Folder, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Folder.FindMany(ctx, keys) + return err + }) + return ret, toErrorSlice(err) + } +} + func (m Middleware) fetchScenesFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) { return func(keys []int) (ret [][]models.FileID, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { diff --git a/internal/api/loaders/folderloader_gen.go b/internal/api/loaders/folderloader_gen.go new file mode 100644 index 000000000..ca2518b82 --- /dev/null +++ b/internal/api/loaders/folderloader_gen.go @@ -0,0 +1,224 @@ +// Code generated by github.com/vektah/dataloaden, DO NOT EDIT. + +package loaders + +import ( + "sync" + "time" + + "github.com/stashapp/stash/pkg/models" +) + +// FolderLoaderConfig captures the config to create a new FolderLoader +type FolderLoaderConfig struct { + // Fetch is a method that provides the data for the loader + Fetch func(keys []models.FolderID) ([]*models.Folder, []error) + + // Wait is how long wait before sending a batch + Wait time.Duration + + // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit + MaxBatch int +} + +// NewFolderLoader creates a new FolderLoader given a fetch, wait, and maxBatch +func NewFolderLoader(config FolderLoaderConfig) *FolderLoader { + return &FolderLoader{ + fetch: config.Fetch, + wait: config.Wait, + maxBatch: config.MaxBatch, + } +} + +// FolderLoader batches and caches requests +type FolderLoader struct { + // this method provides the data for the loader + fetch func(keys []models.FolderID) ([]*models.Folder, []error) + + // how long to done before sending a batch + wait time.Duration + + // this will limit the maximum number of keys to send in one batch, 0 = no limit + maxBatch int + + // INTERNAL + + // lazily created cache + cache map[models.FolderID]*models.Folder + + // the current batch. keys will continue to be collected until timeout is hit, + // then everything will be sent to the fetch method and out to the listeners + batch *folderLoaderBatch + + // mutex to prevent races + mu sync.Mutex +} + +type folderLoaderBatch struct { + keys []models.FolderID + data []*models.Folder + error []error + closing bool + done chan struct{} +} + +// Load a Folder by key, batching and caching will be applied automatically +func (l *FolderLoader) Load(key models.FolderID) (*models.Folder, error) { + return l.LoadThunk(key)() +} + +// LoadThunk returns a function that when called will block waiting for a Folder. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *FolderLoader) LoadThunk(key models.FolderID) func() (*models.Folder, error) { + l.mu.Lock() + if it, ok := l.cache[key]; ok { + l.mu.Unlock() + return func() (*models.Folder, error) { + return it, nil + } + } + if l.batch == nil { + l.batch = &folderLoaderBatch{done: make(chan struct{})} + } + batch := l.batch + pos := batch.keyIndex(l, key) + l.mu.Unlock() + + return func() (*models.Folder, error) { + <-batch.done + + var data *models.Folder + if pos < len(batch.data) { + data = batch.data[pos] + } + + var err error + // its convenient to be able to return a single error for everything + if len(batch.error) == 1 { + err = batch.error[0] + } else if batch.error != nil { + err = batch.error[pos] + } + + if err == nil { + l.mu.Lock() + l.unsafeSet(key, data) + l.mu.Unlock() + } + + return data, err + } +} + +// LoadAll fetches many keys at once. It will be broken into appropriate sized +// sub batches depending on how the loader is configured +func (l *FolderLoader) LoadAll(keys []models.FolderID) ([]*models.Folder, []error) { + results := make([]func() (*models.Folder, error), len(keys)) + + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + + folders := make([]*models.Folder, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + folders[i], errors[i] = thunk() + } + return folders, errors +} + +// LoadAllThunk returns a function that when called will block waiting for a Folders. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *FolderLoader) LoadAllThunk(keys []models.FolderID) func() ([]*models.Folder, []error) { + results := make([]func() (*models.Folder, error), len(keys)) + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + return func() ([]*models.Folder, []error) { + folders := make([]*models.Folder, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + folders[i], errors[i] = thunk() + } + return folders, errors + } +} + +// Prime the cache with the provided key and value. If the key already exists, no change is made +// and false is returned. +// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) +func (l *FolderLoader) Prime(key models.FolderID, value *models.Folder) bool { + l.mu.Lock() + var found bool + if _, found = l.cache[key]; !found { + // make a copy when writing to the cache, its easy to pass a pointer in from a loop var + // and end up with the whole cache pointing to the same value. + cpy := *value + l.unsafeSet(key, &cpy) + } + l.mu.Unlock() + return !found +} + +// Clear the value at key from the cache, if it exists +func (l *FolderLoader) Clear(key models.FolderID) { + l.mu.Lock() + delete(l.cache, key) + l.mu.Unlock() +} + +func (l *FolderLoader) unsafeSet(key models.FolderID, value *models.Folder) { + if l.cache == nil { + l.cache = map[models.FolderID]*models.Folder{} + } + l.cache[key] = value +} + +// keyIndex will return the location of the key in the batch, if its not found +// it will add the key to the batch +func (b *folderLoaderBatch) keyIndex(l *FolderLoader, key models.FolderID) int { + for i, existingKey := range b.keys { + if key == existingKey { + return i + } + } + + pos := len(b.keys) + b.keys = append(b.keys, key) + if pos == 0 { + go b.startTimer(l) + } + + if l.maxBatch != 0 && pos >= l.maxBatch-1 { + if !b.closing { + b.closing = true + l.batch = nil + go b.end(l) + } + } + + return pos +} + +func (b *folderLoaderBatch) startTimer(l *FolderLoader) { + time.Sleep(l.wait) + l.mu.Lock() + + // we must have hit a batch limit and are already finalizing this batch + if b.closing { + l.mu.Unlock() + return + } + + l.batch = nil + l.mu.Unlock() + + b.end(l) +} + +func (b *folderLoaderBatch) end(l *FolderLoader) { + b.data, b.error = l.fetch(b.keys) + close(b.done) +} diff --git a/internal/api/models.go b/internal/api/models.go index d8f4dc63c..1c7346697 100644 --- a/internal/api/models.go +++ b/internal/api/models.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil" ) type BaseFile interface { @@ -27,6 +28,29 @@ func convertVisualFile(f models.File) (VisualFile, error) { } } +func convertBaseFile(f models.File) BaseFile { + if f == nil { + return nil + } + + switch f := f.(type) { + case BaseFile: + return f + case *models.VideoFile: + return &VideoFile{VideoFile: f} + case *models.ImageFile: + return &ImageFile{ImageFile: f} + case *models.BaseFile: + return &BasicFile{BaseFile: f} + default: + panic("unknown file type") + } +} + +func convertBaseFiles(files []models.File) []BaseFile { + return sliceutil.Map(files, convertBaseFile) +} + type GalleryFile struct { *models.BaseFile } @@ -62,3 +86,15 @@ func (ImageFile) IsVisualFile() {} func (f *ImageFile) Fingerprints() []models.Fingerprint { return f.ImageFile.Fingerprints } + +type BasicFile struct { + *models.BaseFile +} + +func (BasicFile) IsBaseFile() {} + +func (BasicFile) IsVisualFile() {} + +func (f *BasicFile) Fingerprints() []models.Fingerprint { + return f.BaseFile.Fingerprints +} diff --git a/internal/api/resolver.go b/internal/api/resolver.go index f3097969d..061d0e1a9 100644 --- a/internal/api/resolver.go +++ b/internal/api/resolver.go @@ -95,6 +95,12 @@ func (r *Resolver) VideoFile() VideoFileResolver { func (r *Resolver) ImageFile() ImageFileResolver { return &imageFileResolver{r} } +func (r *Resolver) BasicFile() BasicFileResolver { + return &basicFileResolver{r} +} +func (r *Resolver) Folder() FolderResolver { + return &folderResolver{r} +} func (r *Resolver) SavedFilter() SavedFilterResolver { return &savedFilterResolver{r} } @@ -125,6 +131,8 @@ type tagResolver struct{ *Resolver } type galleryFileResolver struct{ *Resolver } type videoFileResolver struct{ *Resolver } type imageFileResolver struct{ *Resolver } +type basicFileResolver struct{ *Resolver } +type folderResolver struct{ *Resolver } type savedFilterResolver struct{ *Resolver } type pluginResolver struct{ *Resolver } type configResultResolver struct{ *Resolver } diff --git a/internal/api/resolver_model_file.go b/internal/api/resolver_model_file.go index 35013cfbd..4b9995311 100644 --- a/internal/api/resolver_model_file.go +++ b/internal/api/resolver_model_file.go @@ -1,30 +1,80 @@ package api -import "context" +import ( + "context" -func (r *galleryFileResolver) Fingerprint(ctx context.Context, obj *GalleryFile, type_ string) (*string, error) { - fp := obj.BaseFile.Fingerprints.For(type_) - if fp != nil { - v := fp.Value() - return &v, nil + "github.com/stashapp/stash/internal/api/loaders" + "github.com/stashapp/stash/pkg/models" +) + +func fingerprintResolver(fp models.Fingerprints, type_ string) (*string, error) { + fingerprint := fp.For(type_) + if fingerprint != nil { + value := fingerprint.Value() + return &value, nil } return nil, nil } +func (r *galleryFileResolver) Fingerprint(ctx context.Context, obj *GalleryFile, type_ string) (*string, error) { + return fingerprintResolver(obj.BaseFile.Fingerprints, type_) +} + func (r *imageFileResolver) Fingerprint(ctx context.Context, obj *ImageFile, type_ string) (*string, error) { - fp := obj.ImageFile.Fingerprints.For(type_) - if fp != nil { - v := fp.Value() - return &v, nil - } - return nil, nil + return fingerprintResolver(obj.ImageFile.Fingerprints, type_) } func (r *videoFileResolver) Fingerprint(ctx context.Context, obj *VideoFile, type_ string) (*string, error) { - fp := obj.VideoFile.Fingerprints.For(type_) - if fp != nil { - v := fp.Value() - return &v, nil - } - return nil, nil + return fingerprintResolver(obj.VideoFile.Fingerprints, type_) +} + +func (r *basicFileResolver) Fingerprint(ctx context.Context, obj *BasicFile, type_ string) (*string, error) { + return fingerprintResolver(obj.BaseFile.Fingerprints, type_) +} + +func (r *galleryFileResolver) ParentFolder(ctx context.Context, obj *GalleryFile) (*models.Folder, error) { + return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID) +} + +func (r *imageFileResolver) ParentFolder(ctx context.Context, obj *ImageFile) (*models.Folder, error) { + return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID) +} + +func (r *videoFileResolver) ParentFolder(ctx context.Context, obj *VideoFile) (*models.Folder, error) { + return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID) +} + +func (r *basicFileResolver) ParentFolder(ctx context.Context, obj *BasicFile) (*models.Folder, error) { + return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID) +} + +func zipFileResolver(ctx context.Context, zipFileID *models.FileID) (*BasicFile, error) { + if zipFileID == nil { + return nil, nil + } + + f, err := loaders.From(ctx).FileByID.Load(*zipFileID) + if err != nil { + return nil, err + } + + return &BasicFile{ + BaseFile: f.Base(), + }, nil +} + +func (r *galleryFileResolver) ZipFile(ctx context.Context, obj *GalleryFile) (*BasicFile, error) { + return zipFileResolver(ctx, obj.ZipFileID) +} + +func (r *imageFileResolver) ZipFile(ctx context.Context, obj *ImageFile) (*BasicFile, error) { + return zipFileResolver(ctx, obj.ZipFileID) +} + +func (r *videoFileResolver) ZipFile(ctx context.Context, obj *VideoFile) (*BasicFile, error) { + return zipFileResolver(ctx, obj.ZipFileID) +} + +func (r *basicFileResolver) ZipFile(ctx context.Context, obj *BasicFile) (*BasicFile, error) { + return zipFileResolver(ctx, obj.ZipFileID) } diff --git a/internal/api/resolver_model_folder.go b/internal/api/resolver_model_folder.go new file mode 100644 index 000000000..ee6bbfd05 --- /dev/null +++ b/internal/api/resolver_model_folder.go @@ -0,0 +1,20 @@ +package api + +import ( + "context" + + "github.com/stashapp/stash/internal/api/loaders" + "github.com/stashapp/stash/pkg/models" +) + +func (r *folderResolver) ParentFolder(ctx context.Context, obj *models.Folder) (*models.Folder, error) { + if obj.ParentFolderID == nil { + return nil, nil + } + + return loaders.From(ctx).FolderByID.Load(*obj.ParentFolderID) +} + +func (r *folderResolver) ZipFile(ctx context.Context, obj *models.Folder) (*BasicFile, error) { + return zipFileResolver(ctx, obj.ZipFileID) +} diff --git a/internal/api/resolver_query_find_file.go b/internal/api/resolver_query_find_file.go new file mode 100644 index 000000000..ae53a89b4 --- /dev/null +++ b/internal/api/resolver_query_find_file.go @@ -0,0 +1,120 @@ +package api + +import ( + "context" + "errors" + "strconv" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" +) + +func (r *queryResolver) FindFile(ctx context.Context, id *string, path *string) (BaseFile, error) { + var ret models.File + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + qb := r.repository.File + var err error + switch { + case id != nil: + idInt, err := strconv.Atoi(*id) + if err != nil { + return err + } + var files []models.File + files, err = qb.Find(ctx, models.FileID(idInt)) + if err != nil { + return err + } + if len(files) > 0 { + ret = files[0] + } + case path != nil: + ret, err = qb.FindByPath(ctx, *path) + if err == nil && ret == nil { + return errors.New("file not found") + } + default: + return errors.New("either id or path must be provided") + } + + return err + }); err != nil { + return nil, err + } + + return convertBaseFile(ret), nil +} + +func (r *queryResolver) FindFiles( + ctx context.Context, + fileFilter *models.FileFilterType, + filter *models.FindFilterType, + ids []string, +) (ret *FindFilesResultType, err error) { + var fileIDs []models.FileID + if len(ids) > 0 { + fileIDsInt, err := stringslice.StringSliceToIntSlice(ids) + if err != nil { + return nil, err + } + + fileIDs = models.FileIDsFromInts(fileIDsInt) + } + + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + var files []models.File + var err error + + fields := collectQueryFields(ctx) + result := &models.FileQueryResult{} + + if len(fileIDs) > 0 { + files, err = r.repository.File.Find(ctx, fileIDs...) + if err == nil { + result.Count = len(files) + for _, f := range files { + if asVideo, ok := f.(*models.VideoFile); ok { + result.TotalDuration += asVideo.Duration + } + if asImage, ok := f.(*models.ImageFile); ok { + result.Megapixels += asImage.Megapixels() + } + + result.TotalSize += f.Base().Size + } + } + } else { + result, err = r.repository.File.Query(ctx, models.FileQueryOptions{ + QueryOptions: models.QueryOptions{ + FindFilter: filter, + Count: fields.Has("count"), + }, + FileFilter: fileFilter, + TotalDuration: fields.Has("duration"), + Megapixels: fields.Has("megapixels"), + TotalSize: fields.Has("size"), + }) + if err == nil { + files, err = result.Resolve(ctx) + } + } + + if err != nil { + return err + } + + ret = &FindFilesResultType{ + Count: result.Count, + Files: convertBaseFiles(files), + Duration: result.TotalDuration, + Megapixels: result.Megapixels, + Size: int(result.TotalSize), + } + + return nil + }); err != nil { + return nil, err + } + + return ret, nil +} diff --git a/pkg/models/file.go b/pkg/models/file.go index e6ce41d1e..1b77af21a 100644 --- a/pkg/models/file.go +++ b/pkg/models/file.go @@ -9,15 +9,34 @@ import ( type FileQueryOptions struct { QueryOptions FileFilter *FileFilterType + + TotalDuration bool + Megapixels bool + TotalSize bool } type FileFilterType struct { - And *FileFilterType `json:"AND"` - Or *FileFilterType `json:"OR"` - Not *FileFilterType `json:"NOT"` + OperatorFilter[FileFilterType] // Filter by path Path *StringCriterionInput `json:"path"` + + Basename *StringCriterionInput `json:"basename"` + Dir *StringCriterionInput `json:"dir"` + ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder"` + ModTime *TimestampCriterionInput `json:"mod_time"` + Duplicated *PHashDuplicationCriterionInput `json:"duplicated"` + Hashes []*FingerprintFilterInput `json:"hashes"` + VideoFileFilter *VideoFileFilterInput `json:"video_file_filter"` + ImageFileFilter *ImageFileFilterInput `json:"image_file_filter"` + SceneCount *IntCriterionInput `json:"scene_count"` + ImageCount *IntCriterionInput `json:"image_count"` + GalleryCount *IntCriterionInput `json:"gallery_count"` + ScenesFilter *SceneFilterType `json:"scenes_filter"` + ImagesFilter *ImageFilterType `json:"images_filter"` + GalleriesFilter *GalleryFilterType `json:"galleries_filter"` + CreatedAt *TimestampCriterionInput `json:"created_at"` + UpdatedAt *TimestampCriterionInput `json:"updated_at"` } func PathsFileFilter(paths []string) *FileFilterType { @@ -53,10 +72,10 @@ func PathsFileFilter(paths []string) *FileFilterType { } type FileQueryResult struct { - // can't use QueryResult because id type is wrong - - IDs []FileID - Count int + QueryResult[FileID] + TotalDuration float64 + Megapixels float64 + TotalSize int64 getter FileGetter files []File diff --git a/pkg/models/filter.go b/pkg/models/filter.go index 2d25f6516..97d850a55 100644 --- a/pkg/models/filter.go +++ b/pkg/models/filter.go @@ -200,3 +200,31 @@ type CustomFieldCriterionInput struct { Value []any `json:"value"` Modifier CriterionModifier `json:"modifier"` } + +type FingerprintFilterInput struct { + Type string `json:"type"` + Value string `json:"value"` + // Hamming distance - defaults to 0 + Distance *int `json:"distance,omitempty"` +} + +type VideoFileFilterInput struct { + Format *StringCriterionInput `json:"format,omitempty"` + Resolution *ResolutionCriterionInput `json:"resolution,omitempty"` + Orientation *OrientationCriterionInput `json:"orientation,omitempty"` + Framerate *IntCriterionInput `json:"framerate,omitempty"` + Bitrate *IntCriterionInput `json:"bitrate,omitempty"` + VideoCodec *StringCriterionInput `json:"video_codec,omitempty"` + AudioCodec *StringCriterionInput `json:"audio_codec,omitempty"` + // in seconds + Duration *IntCriterionInput `json:"duration,omitempty"` + Captions *StringCriterionInput `json:"captions,omitempty"` + Interactive *bool `json:"interactive,omitempty"` + InteractiveSpeed *IntCriterionInput `json:"interactive_speed,omitempty"` +} + +type ImageFileFilterInput struct { + Format *StringCriterionInput `json:"format,omitempty"` + Resolution *ResolutionCriterionInput `json:"resolution,omitempty"` + Orientation *OrientationCriterionInput `json:"orientation,omitempty"` +} diff --git a/pkg/models/image.go b/pkg/models/image.go index 370315159..9d2c6f016 100644 --- a/pkg/models/image.go +++ b/pkg/models/image.go @@ -106,7 +106,7 @@ type ImageQueryOptions struct { } type ImageQueryResult struct { - QueryResult + QueryResult[int] Megapixels float64 TotalSize float64 diff --git a/pkg/models/mocks/FolderReaderWriter.go b/pkg/models/mocks/FolderReaderWriter.go index 968bed4ad..020764942 100644 --- a/pkg/models/mocks/FolderReaderWriter.go +++ b/pkg/models/mocks/FolderReaderWriter.go @@ -178,6 +178,29 @@ func (_m *FolderReaderWriter) FindByZipFileID(ctx context.Context, zipFileID mod return r0, r1 } +// FindMany provides a mock function with given fields: ctx, id +func (_m *FolderReaderWriter) FindMany(ctx context.Context, id []models.FolderID) ([]*models.Folder, error) { + ret := _m.Called(ctx, id) + + var r0 []*models.Folder + if rf, ok := ret.Get(0).(func(context.Context, []models.FolderID) []*models.Folder); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Folder) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []models.FolderID) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Update provides a mock function with given fields: ctx, f func (_m *FolderReaderWriter) Update(ctx context.Context, f *models.Folder) error { ret := _m.Called(ctx, f) diff --git a/pkg/models/model_file.go b/pkg/models/model_file.go index e9df57990..f6b8bdc51 100644 --- a/pkg/models/model_file.go +++ b/pkg/models/model_file.go @@ -79,6 +79,14 @@ func (i FileID) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(i.String())) } +func FileIDsFromInts(ids []int) []FileID { + ret := make([]FileID, len(ids)) + for i, id := range ids { + ret[i] = FileID(id) + } + return ret +} + // DirEntry represents a file or directory in the file system. type DirEntry struct { ZipFileID *FileID `json:"zip_file_id"` @@ -252,6 +260,10 @@ func (f ImageFile) GetHeight() int { return f.Height } +func (f ImageFile) Megapixels() float64 { + return float64(f.Width*f.Height) / 1e6 +} + func (f ImageFile) GetFormat() string { return f.Format } diff --git a/pkg/models/query.go b/pkg/models/query.go index 1b2d347b9..a6e15bc4e 100644 --- a/pkg/models/query.go +++ b/pkg/models/query.go @@ -5,7 +5,7 @@ type QueryOptions struct { Count bool } -type QueryResult struct { - IDs []int +type QueryResult[T comparable] struct { + IDs []T Count int } diff --git a/pkg/models/repository_folder.go b/pkg/models/repository_folder.go index c3f82f529..20c155ead 100644 --- a/pkg/models/repository_folder.go +++ b/pkg/models/repository_folder.go @@ -5,6 +5,7 @@ import "context" // FolderGetter provides methods to get folders by ID. type FolderGetter interface { Find(ctx context.Context, id FolderID) (*Folder, error) + FindMany(ctx context.Context, id []FolderID) ([]*Folder, error) } // FolderFinder provides methods to find folders. diff --git a/pkg/models/scene.go b/pkg/models/scene.go index c7be343d9..9f28d40ba 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -126,7 +126,7 @@ type SceneQueryOptions struct { } type SceneQueryResult struct { - QueryResult + QueryResult[int] TotalDuration float64 TotalSize float64 diff --git a/pkg/sqlite/batch.go b/pkg/sqlite/batch.go index 71ad5d354..a59438835 100644 --- a/pkg/sqlite/batch.go +++ b/pkg/sqlite/batch.go @@ -3,7 +3,7 @@ package sqlite const defaultBatchSize = 1000 // batchExec executes the provided function in batches of the provided size. -func batchExec(ids []int, batchSize int, fn func(batch []int) error) error { +func batchExec[T any](ids []T, batchSize int, fn func(batch []T) error) error { for i := 0; i < len(ids); i += batchSize { end := i + batchSize if end > len(ids) { diff --git a/pkg/sqlite/criterion_handlers.go b/pkg/sqlite/criterion_handlers.go index 55ff31fca..82b9cfc65 100644 --- a/pkg/sqlite/criterion_handlers.go +++ b/pkg/sqlite/criterion_handlers.go @@ -70,6 +70,17 @@ func stringCriterionHandler(c *models.StringCriterionInput, column string) crite } } +func joinedStringCriterionHandler(c *models.StringCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + if addJoinFn != nil { + addJoinFn(f) + } + stringCriterionHandler(c, column)(ctx, f) + } + } +} + func enumCriterionHandler(modifier models.CriterionModifier, values []string, column string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if modifier.IsValid() { diff --git a/pkg/sqlite/file.go b/pkg/sqlite/file.go index 6bf6e32b5..ea2084c2c 100644 --- a/pkg/sqlite/file.go +++ b/pkg/sqlite/file.go @@ -275,6 +275,43 @@ func (r fileQueryRows) resolve() []models.File { return ret } +type fileRepositoryType struct { + repository + scenes joinRepository + images joinRepository + galleries joinRepository +} + +var ( + fileRepository = fileRepositoryType{ + repository: repository{ + tableName: sceneTable, + idColumn: idColumn, + }, + scenes: joinRepository{ + repository: repository{ + tableName: scenesFilesTable, + idColumn: fileIDColumn, + }, + fkColumn: sceneIDColumn, + }, + images: joinRepository{ + repository: repository{ + tableName: imagesFilesTable, + idColumn: fileIDColumn, + }, + fkColumn: imageIDColumn, + }, + galleries: joinRepository{ + repository: repository{ + tableName: galleriesFilesTable, + idColumn: fileIDColumn, + }, + fkColumn: galleryIDColumn, + }, + } +) + type FileStore struct { repository @@ -830,9 +867,11 @@ func (qb *FileStore) makeFilter(ctx context.Context, fileFilter *models.FileFilt query.not(qb.makeFilter(ctx, fileFilter.Not)) } - query.handleCriterion(ctx, pathCriterionHandler(fileFilter.Path, "folders.path", "files.basename", nil)) + filter := filterBuilderFromHandler(ctx, &fileFilterHandler{ + fileFilter: fileFilter, + }) - return query + return filter } func (qb *FileStore) Query(ctx context.Context, options models.FileQueryOptions) (*models.FileQueryResult, error) { @@ -890,7 +929,7 @@ func (qb *FileStore) Query(ctx context.Context, options models.FileQueryOptions) } func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.FileQueryOptions, query queryBuilder) (*models.FileQueryResult, error) { - if !options.Count { + if !options.Count && !options.TotalDuration && !options.Megapixels && !options.TotalSize { // nothing to do - return empty result return models.NewFileQueryResult(qb), nil } @@ -898,14 +937,43 @@ func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.File aggregateQuery := qb.newQuery() if options.Count { - aggregateQuery.addColumn("COUNT(temp.id) as total") + aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total") + } + + if options.TotalDuration { + query.addJoins( + join{ + table: videoFileTable, + onClause: "files.id = video_files.file_id", + }, + ) + query.addColumn("COALESCE(video_files.duration, 0) as duration") + aggregateQuery.addColumn("COALESCE(SUM(temp.duration), 0) as duration") + } + if options.Megapixels { + query.addJoins( + join{ + table: imageFileTable, + onClause: "files.id = image_files.file_id", + }, + ) + query.addColumn("COALESCE(image_files.width, 0) * COALESCE(image_files.height, 0) as megapixels") + aggregateQuery.addColumn("COALESCE(SUM(temp.megapixels), 0) / 1000000 as megapixels") + } + + if options.TotalSize { + query.addColumn("COALESCE(files.size, 0) as size") + aggregateQuery.addColumn("COALESCE(SUM(temp.size), 0) as size") } const includeSortPagination = false aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination)) out := struct { - Total int + Total int + Duration float64 + Megapixels float64 + Size int64 }{} if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil { return nil, err @@ -913,6 +981,9 @@ func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.File ret := models.NewFileQueryResult(qb) ret.Count = out.Total + ret.Megapixels = out.Megapixels + ret.TotalDuration = out.Duration + ret.TotalSize = out.Size return ret, nil } diff --git a/pkg/sqlite/file_filter.go b/pkg/sqlite/file_filter.go new file mode 100644 index 000000000..b115fee35 --- /dev/null +++ b/pkg/sqlite/file_filter.go @@ -0,0 +1,302 @@ +package sqlite + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" +) + +type fileFilterHandler struct { + fileFilter *models.FileFilterType +} + +func (qb *fileFilterHandler) validate() error { + fileFilter := qb.fileFilter + if fileFilter == nil { + return nil + } + + if err := validateFilterCombination(fileFilter.OperatorFilter); err != nil { + return err + } + + if subFilter := fileFilter.SubFilter(); subFilter != nil { + sqb := &fileFilterHandler{fileFilter: subFilter} + if err := sqb.validate(); err != nil { + return err + } + } + + return nil +} + +func (qb *fileFilterHandler) handle(ctx context.Context, f *filterBuilder) { + fileFilter := qb.fileFilter + if fileFilter == nil { + return + } + + if err := qb.validate(); err != nil { + f.setError(err) + return + } + + sf := fileFilter.SubFilter() + if sf != nil { + sub := &fileFilterHandler{sf} + handleSubFilter(ctx, sub, f, fileFilter.OperatorFilter) + } + + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *fileFilterHandler) criterionHandler() criterionHandler { + fileFilter := qb.fileFilter + return compoundHandler{ + &videoFileFilterHandler{ + filter: fileFilter.VideoFileFilter, + }, + &imageFileFilterHandler{ + filter: fileFilter.ImageFileFilter, + }, + + pathCriterionHandler(fileFilter.Path, "folders.path", "files.basename", nil), + stringCriterionHandler(fileFilter.Basename, "files.basename"), + stringCriterionHandler(fileFilter.Dir, "folders.path"), + ×tampCriterionHandler{fileFilter.ModTime, "files.mod_time", nil}, + + qb.parentFolderCriterionHandler(fileFilter.ParentFolder), + + qb.sceneCountCriterionHandler(fileFilter.SceneCount), + qb.imageCountCriterionHandler(fileFilter.ImageCount), + qb.galleryCountCriterionHandler(fileFilter.GalleryCount), + + qb.hashesCriterionHandler(fileFilter.Hashes), + + qb.phashDuplicatedCriterionHandler(fileFilter.Duplicated), + ×tampCriterionHandler{fileFilter.CreatedAt, "files.created_at", nil}, + ×tampCriterionHandler{fileFilter.UpdatedAt, "files.updated_at", nil}, + + &relatedFilterHandler{ + relatedIDCol: "scenes_files.scene_id", + relatedRepo: sceneRepository.repository, + relatedHandler: &sceneFilterHandler{fileFilter.ScenesFilter}, + joinFn: func(f *filterBuilder) { + fileRepository.scenes.innerJoin(f, "", "files.id") + }, + }, + &relatedFilterHandler{ + relatedIDCol: "images_files.image_id", + relatedRepo: imageRepository.repository, + relatedHandler: &imageFilterHandler{fileFilter.ImagesFilter}, + joinFn: func(f *filterBuilder) { + fileRepository.images.innerJoin(f, "", "files.id") + }, + }, + &relatedFilterHandler{ + relatedIDCol: "galleries_files.gallery_id", + relatedRepo: galleryRepository.repository, + relatedHandler: &galleryFilterHandler{fileFilter.GalleriesFilter}, + joinFn: func(f *filterBuilder) { + fileRepository.galleries.innerJoin(f, "", "files.id") + }, + }, + } +} + +func (qb *fileFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if folder == nil { + return + } + + folderCopy := *folder + switch folderCopy.Modifier { + case models.CriterionModifierEquals: + folderCopy.Modifier = models.CriterionModifierIncludesAll + case models.CriterionModifierNotEquals: + folderCopy.Modifier = models.CriterionModifierExcludes + } + + hh := hierarchicalMultiCriterionHandlerBuilder{ + primaryTable: fileTable, + foreignTable: folderTable, + foreignFK: "parent_folder_id", + parentFK: "parent_folder_id", + } + + hh.handler(&folderCopy)(ctx, f) + } +} + +func (qb *fileFilterHandler) sceneCountCriterionHandler(c *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: fileTable, + joinTable: scenesFilesTable, + primaryFK: fileIDColumn, + } + + return h.handler(c) +} + +func (qb *fileFilterHandler) imageCountCriterionHandler(c *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: fileTable, + joinTable: imagesFilesTable, + primaryFK: fileIDColumn, + } + + return h.handler(c) +} + +func (qb *fileFilterHandler) galleryCountCriterionHandler(c *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: fileTable, + joinTable: galleriesFilesTable, + primaryFK: fileIDColumn, + } + + return h.handler(c) +} + +func (qb *fileFilterHandler) phashDuplicatedCriterionHandler(duplicatedFilter *models.PHashDuplicationCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + // TODO: Wishlist item: Implement Distance matching + if duplicatedFilter != nil { + var v string + if *duplicatedFilter.Duplicated { + v = ">" + } else { + v = "=" + } + + f.addInnerJoin("(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) "+v+" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)", "scph", "files.id = scph.file_id") + } + } +} + +func (qb *fileFilterHandler) hashesCriterionHandler(hashes []*models.FingerprintFilterInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + // TODO - this won't work for AND/OR combinations + for i, hash := range hashes { + t := fmt.Sprintf("file_fingerprints_%d", i) + f.addLeftJoin(fingerprintTable, t, fmt.Sprintf("files.id = %s.file_id AND %s.type = ?", t, t), hash.Type) + + value, _ := utils.StringToPhash(hash.Value) + distance := 0 + if hash.Distance != nil { + distance = *hash.Distance + } + + if distance > 0 { + // needed to avoid a type mismatch + f.addWhere(fmt.Sprintf("typeof(%s.fingerprint) = 'integer'", t)) + f.addWhere(fmt.Sprintf("phash_distance(%s.fingerprint, ?) < ?", t), value, distance) + } else { + // use the default handler + intCriterionHandler(&models.IntCriterionInput{ + Value: int(value), + Modifier: models.CriterionModifierEquals, + }, t+".fingerprint", nil)(ctx, f) + } + } + } +} + +type videoFileFilterHandler struct { + filter *models.VideoFileFilterInput +} + +func (qb *videoFileFilterHandler) handle(ctx context.Context, f *filterBuilder) { + videoFileFilter := qb.filter + if videoFileFilter == nil { + return + } + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *videoFileFilterHandler) criterionHandler() criterionHandler { + videoFileFilter := qb.filter + return compoundHandler{ + joinedStringCriterionHandler(videoFileFilter.Format, "video_files.format", qb.addVideoFilesTable), + floatIntCriterionHandler(videoFileFilter.Duration, "video_files.duration", qb.addVideoFilesTable), + resolutionCriterionHandler(videoFileFilter.Resolution, "video_files.height", "video_files.width", qb.addVideoFilesTable), + orientationCriterionHandler(videoFileFilter.Orientation, "video_files.height", "video_files.width", qb.addVideoFilesTable), + floatIntCriterionHandler(videoFileFilter.Framerate, "ROUND(video_files.frame_rate)", qb.addVideoFilesTable), + intCriterionHandler(videoFileFilter.Bitrate, "video_files.bit_rate", qb.addVideoFilesTable), + qb.codecCriterionHandler(videoFileFilter.VideoCodec, "video_files.video_codec", qb.addVideoFilesTable), + qb.codecCriterionHandler(videoFileFilter.AudioCodec, "video_files.audio_codec", qb.addVideoFilesTable), + + boolCriterionHandler(videoFileFilter.Interactive, "video_files.interactive", qb.addVideoFilesTable), + intCriterionHandler(videoFileFilter.InteractiveSpeed, "video_files.interactive_speed", qb.addVideoFilesTable), + + qb.captionCriterionHandler(videoFileFilter.Captions), + } +} + +func (qb *videoFileFilterHandler) addVideoFilesTable(f *filterBuilder) { + f.addLeftJoin(videoFileTable, "", "video_files.file_id = files.id") +} + +func (qb *videoFileFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if codec != nil { + if addJoinFn != nil { + addJoinFn(f) + } + + stringCriterionHandler(codec, codecColumn)(ctx, f) + } + } +} + +func (qb *videoFileFilterHandler) captionCriterionHandler(captions *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + primaryTable: sceneTable, + primaryFK: sceneIDColumn, + joinTable: videoCaptionsTable, + stringColumn: captionCodeColumn, + addJoinTable: func(f *filterBuilder) { + f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = files.id") + }, + excludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) { + excludeClause := `files.id NOT IN ( + SELECT files.id from files + INNER JOIN video_captions on video_captions.file_id = files.id + WHERE video_captions.language_code LIKE ? + )` + f.addWhere(excludeClause, criterion.Value) + + // TODO - should we also exclude null values? + }, + } + + return h.handler(captions) +} + +type imageFileFilterHandler struct { + filter *models.ImageFileFilterInput +} + +func (qb *imageFileFilterHandler) handle(ctx context.Context, f *filterBuilder) { + ff := qb.filter + if ff == nil { + return + } + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *imageFileFilterHandler) criterionHandler() criterionHandler { + ff := qb.filter + return compoundHandler{ + joinedStringCriterionHandler(ff.Format, "image_files.format", qb.addImageFilesTable), + resolutionCriterionHandler(ff.Resolution, "image_files.height", "image_files.width", qb.addImageFilesTable), + orientationCriterionHandler(ff.Orientation, "image_files.height", "image_files.width", qb.addImageFilesTable), + } +} + +func (qb *imageFileFilterHandler) addImageFilesTable(f *filterBuilder) { + f.addLeftJoin(imageFileTable, "", "image_files.file_id = files.id") +} diff --git a/pkg/sqlite/file_filter_test.go b/pkg/sqlite/file_filter_test.go new file mode 100644 index 000000000..7bc6f3e6b --- /dev/null +++ b/pkg/sqlite/file_filter_test.go @@ -0,0 +1,101 @@ +//go:build integration +// +build integration + +package sqlite_test + +import ( + "context" + "strconv" + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stretchr/testify/assert" +) + +func TestFileQuery(t *testing.T) { + tests := []struct { + name string + findFilter *models.FindFilterType + filter *models.FileFilterType + includeIdxs []int + includeIDs []int + excludeIdxs []int + wantErr bool + }{ + { + name: "path", + filter: &models.FileFilterType{ + Path: &models.StringCriterionInput{ + Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "basename"), + Modifier: models.CriterionModifierIncludes, + }, + }, + includeIdxs: []int{fileIdxStartVideoFiles}, + excludeIdxs: []int{fileIdxStartImageFiles}, + }, + { + name: "basename", + filter: &models.FileFilterType{ + Basename: &models.StringCriterionInput{ + Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "basename"), + Modifier: models.CriterionModifierIncludes, + }, + }, + includeIdxs: []int{fileIdxStartVideoFiles}, + excludeIdxs: []int{fileIdxStartImageFiles}, + }, + { + name: "dir", + filter: &models.FileFilterType{ + Path: &models.StringCriterionInput{ + Value: folderPaths[folderIdxWithSceneFiles], + Modifier: models.CriterionModifierIncludes, + }, + }, + includeIDs: []int{int(sceneFileIDs[sceneIdxWithGroup])}, + excludeIdxs: []int{fileIdxStartImageFiles}, + }, + { + name: "parent folder", + filter: &models.FileFilterType{ + ParentFolder: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(int(folderIDs[folderIdxWithSceneFiles])), + }, + Modifier: models.CriterionModifierIncludes, + }, + }, + includeIDs: []int{int(sceneFileIDs[sceneIdxWithGroup])}, + excludeIdxs: []int{fileIdxStartImageFiles}, + }, + // TODO - add more tests for other file filters + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + results, err := db.File.Query(ctx, models.FileQueryOptions{ + FileFilter: tt.filter, + QueryOptions: models.QueryOptions{ + FindFilter: tt.findFilter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } + + include := indexesToIDs(sceneIDs, tt.includeIdxs) + include = append(include, tt.includeIDs...) + exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(results.IDs, models.FileID(i)) + } + for _, e := range exclude { + assert.NotContains(results.IDs, models.FileID(e)) + } + }) + } +} diff --git a/pkg/sqlite/folder.go b/pkg/sqlite/folder.go index 4cf632d49..f90a578bd 100644 --- a/pkg/sqlite/folder.go +++ b/pkg/sqlite/folder.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "path/filepath" + "slices" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" @@ -225,6 +226,52 @@ func (qb *FolderStore) Find(ctx context.Context, id models.FolderID) (*models.Fo return ret, nil } +// FindByIDs finds multiple folders by their IDs. +// No check is made to see if the folders exist, and the order of the returned folders +// is not guaranteed to be the same as the order of the input IDs. +func (qb *FolderStore) FindByIDs(ctx context.Context, ids []models.FolderID) ([]*models.Folder, error) { + folders := make([]*models.Folder, 0, len(ids)) + + table := qb.table() + if err := batchExec(ids, defaultBatchSize, func(batch []models.FolderID) error { + q := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(batch)) + unsorted, err := qb.getMany(ctx, q) + if err != nil { + return err + } + + folders = append(folders, unsorted...) + + return nil + }); err != nil { + return nil, err + } + + return folders, nil +} + +func (qb *FolderStore) FindMany(ctx context.Context, ids []models.FolderID) ([]*models.Folder, error) { + folders := make([]*models.Folder, len(ids)) + + unsorted, err := qb.FindByIDs(ctx, ids) + if err != nil { + return nil, err + } + + for _, s := range unsorted { + i := slices.Index(ids, s.ID) + folders[i] = s + } + + for i := range folders { + if folders[i] == nil { + return nil, fmt.Errorf("folder with id %d not found", ids[i]) + } + } + + return folders, nil +} + func (qb *FolderStore) FindByPath(ctx context.Context, p string) (*models.Folder, error) { q := qb.selectDataset().Prepared(true).Where(qb.table().Col("path").Eq(p)) From 27bc6c8fca4515f3fbb208e227f5667a59105d6a Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Thu, 26 Jun 2025 00:33:55 +0300 Subject: [PATCH 006/157] Update captions documentation (#5967) * Update captions documentation to clarify file location and naming conventions * Clarify naming convention --- ui/v2.5/src/docs/en/Manual/Captions.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ui/v2.5/src/docs/en/Manual/Captions.md b/ui/v2.5/src/docs/en/Manual/Captions.md index e52fc54bb..df2bee8bc 100644 --- a/ui/v2.5/src/docs/en/Manual/Captions.md +++ b/ui/v2.5/src/docs/en/Manual/Captions.md @@ -2,15 +2,17 @@ Stash supports captioning with SRT and VTT files. -These files need to be named as follows: +Captions will only be detected if they are located in the same folder as the corresponding scene file. + +Ensure the caption files follow these naming conventions: ## Scene -- {scene_name}.{language_code}.ext -- {scene_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. Scenes with captions can be filtered with the `captions` criterion. -**Note:** If the caption file was added after the scene was initially added during scan you will need to run a Selective Scan task for it to show up. +**Note:** If the caption file was added after the scene was initially added during scan, you will need to run a Selective Scan task for it to show up. From d0a7b09bf3c23a422b26731f101da5b6db018009 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 26 Jun 2025 09:17:22 +1000 Subject: [PATCH 007/157] Scene list toolbar (#5938) * Add sticky query toolbar to scenes page * Filter button accept count instead of filter * Add play button * Add create button functionality. Remove new scene button from navbar * Separate toolbar into component * Separate sort by select component * Don't show filter tags control if no criteria * Add utility setter methods to ListFilterModel * Add results header with display options * Use css for filter tag styling * Add className to OperationDropdown and Item * Increase size of sidebar controls on mobile --- ui/v2.5/src/components/List/FilterTags.tsx | 6 +- .../components/List/Filters/FilterButton.tsx | 16 +- .../components/List/Filters/FilterSidebar.tsx | 26 +- ui/v2.5/src/components/List/ListFilter.tsx | 171 ++++---- .../components/List/ListOperationButtons.tsx | 26 +- ui/v2.5/src/components/List/styles.scss | 38 +- ui/v2.5/src/components/MainNavbar.tsx | 1 - ui/v2.5/src/components/Scenes/SceneList.tsx | 384 +++++++++++++++--- ui/v2.5/src/components/Scenes/styles.scss | 89 ++++ ui/v2.5/src/components/Shared/styles.scss | 7 +- ui/v2.5/src/index.scss | 15 +- ui/v2.5/src/locales/en-GB.json | 6 +- .../src/models/list-filter/filter-options.ts | 2 +- ui/v2.5/src/models/list-filter/filter.ts | 28 ++ 14 files changed, 642 insertions(+), 173 deletions(-) diff --git a/ui/v2.5/src/components/List/FilterTags.tsx b/ui/v2.5/src/components/List/FilterTags.tsx index a384f05ca..b690f8781 100644 --- a/ui/v2.5/src/components/List/FilterTags.tsx +++ b/ui/v2.5/src/components/List/FilterTags.tsx @@ -101,8 +101,12 @@ export const FilterTags: React.FC = ({ ); } + if (criteria.length === 0) { + return null; + } + return ( -
+
{criteria.map(renderFilterTags)} {criteria.length >= 3 && ( +
+ } diff --git a/ui/v2.5/src/components/List/ListFilter.tsx b/ui/v2.5/src/components/List/ListFilter.tsx index 4933c7e75..99bae365e 100644 --- a/ui/v2.5/src/components/List/ListFilter.tsx +++ b/ui/v2.5/src/components/List/ListFilter.tsx @@ -36,6 +36,7 @@ import { useDebounce } from "src/hooks/debounce"; import { View } from "./views"; import { ClearableInput } from "../Shared/ClearableInput"; import { useStopWheelScroll } from "src/utils/form"; +import { ISortByOption } from "src/models/list-filter/filter-options"; export function useDebouncedSearchInput( filter: ListFilterModel, @@ -230,6 +231,94 @@ export const PageSizeSelector: React.FC<{ ); }; +export const SortBySelect: React.FC<{ + className?: string; + sortBy: string | undefined; + sortDirection: SortDirectionEnum; + options: ISortByOption[]; + onChangeSortBy: (eventKey: string | null) => void; + onChangeSortDirection: () => void; + onReshuffleRandomSort: () => void; +}> = ({ + className, + sortBy, + sortDirection, + options, + onChangeSortBy, + onChangeSortDirection, + onReshuffleRandomSort, +}) => { + const intl = useIntl(); + + const currentSortBy = options.find((o) => o.value === sortBy); + + function renderSortByOptions() { + return options + .map((o) => { + return { + message: intl.formatMessage({ id: o.messageID }), + value: o.value, + }; + }) + .sort((a, b) => a.message.localeCompare(b.message)) + .map((option) => ( + + {option.message} + + )); + } + + return ( + + + + {currentSortBy + ? intl.formatMessage({ id: currentSortBy.messageID }) + : ""} + + + + {renderSortByOptions()} + + + {sortDirection === SortDirectionEnum.Asc + ? intl.formatMessage({ id: "ascending" }) + : intl.formatMessage({ id: "descending" })} + + } + > + + + {sortBy === "random" && ( + + {intl.formatMessage({ id: "actions.reshuffle" })} + + } + > + + + )} + + ); +}; + interface IListFilterProps { onFilterUpdate: (newFilter: ListFilterModel) => void; filter: ListFilterModel; @@ -247,8 +336,6 @@ export const ListFilter: React.FC = ({ }) => { const filterOptions = filter.options; - const intl = useIntl(); - useEffect(() => { Mousetrap.bind("r", () => onReshuffleRandomSort()); @@ -289,32 +376,7 @@ export const ListFilter: React.FC = ({ onFilterUpdate(newFilter); } - function renderSortByOptions() { - return filterOptions.sortByOptions - .map((o) => { - return { - message: intl.formatMessage({ id: o.messageID }), - value: o.value, - }; - }) - .sort((a, b) => a.message.localeCompare(b.message)) - .map((option) => ( - - {option.message} - - )); - } - function render() { - const currentSortBy = filterOptions.sortByOptions.find( - (o) => o.value === filter.sortBy - ); - return ( <> {!withSidebar && ( @@ -342,56 +404,21 @@ export const ListFilter: React.FC = ({ > openFilterDialog()} - filter={filter} + count={filter.count()} /> )} - - - - {currentSortBy - ? intl.formatMessage({ id: currentSortBy.messageID }) - : ""} - - - - {renderSortByOptions()} - - - {filter.sortDirection === SortDirectionEnum.Asc - ? intl.formatMessage({ id: "ascending" }) - : intl.formatMessage({ id: "descending" })} - - } - > - - - {filter.sortBy === "random" && ( - - {intl.formatMessage({ id: "actions.reshuffle" })} - - } - > - - - )} - + > = ({ - children, -}) => { +export const OperationDropdown: React.FC< + PropsWithChildren<{ + className?: string; + }> +> = ({ className, children }) => { if (!children) return null; return ( - + @@ -33,6 +36,21 @@ export const OperationDropdown: React.FC> = ({ ); }; +export const OperationDropdownItem: React.FC<{ + text: string; + onClick: () => void; + className?: string; +}> = ({ text, onClick, className }) => { + return ( + + {text} + + ); +}; + export interface IListFilterOperation { text: string; onClick: () => void; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 9a719b4b2..49deb5983 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -412,6 +412,12 @@ input[type="range"].zoom-slider { } } +.filter-tags { + display: flex; + justify-content: center; + margin-bottom: 0.5rem; +} + .filter-tags .clear-all-button { color: $text-color; // to match filter pills @@ -929,25 +935,49 @@ input[type="range"].zoom-slider { } .sidebar { + // make controls slightly larger on mobile + @include media-breakpoint-down(xs) { + .btn, + .form-control { + font-size: 1.25rem; + } + } + .sidebar-search-container { display: flex; margin-bottom: 0.5rem; - margin-top: 0.25rem; } .search-term-input { flex-grow: 1; - margin-right: 0.25rem; + margin-right: 0; .clearable-text-field { height: 100%; } } + + .edit-filter-button { + width: 100%; + } + + .sidebar-footer { + background-color: $body-bg; + bottom: 0; + display: none; + padding: 0.5rem; + position: sticky; + + @include media-breakpoint-down(xs) { + display: flex; + justify-content: center; + } + } } @include media-breakpoint-down(xs) { - .sidebar .search-term-input { - margin-right: 0.5rem; + .sidebar .sidebar-search-container { + margin-top: 0.25rem; } } diff --git a/ui/v2.5/src/components/MainNavbar.tsx b/ui/v2.5/src/components/MainNavbar.tsx index 98bbc26c6..59f8e51aa 100644 --- a/ui/v2.5/src/components/MainNavbar.tsx +++ b/ui/v2.5/src/components/MainNavbar.tsx @@ -103,7 +103,6 @@ const allMenuItems: IMenuItem[] = [ href: "/scenes", icon: faPlayCircle, hotkey: "g s", - userCreatable: true, }, { name: "images", diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 3f9bf6315..fd75b96be 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -19,7 +19,13 @@ import { SceneCardsGrid } from "./SceneCardsGrid"; import { TaggerContext } from "../Tagger/context"; import { IdentifyDialog } from "../Dialogs/IdentifyDialog/IdentifyDialog"; import { ConfigurationContext } from "src/hooks/Config"; -import { faPlay } from "@fortawesome/free-solid-svg-icons"; +import { + faPencil, + faPlay, + faPlus, + faTimes, + faTrash, +} from "@fortawesome/free-solid-svg-icons"; import { SceneMergeModal } from "./SceneMergeDialog"; import { objectTitle } from "src/core/files"; import TextUtils from "src/utils/text"; @@ -27,8 +33,10 @@ import { View } from "../List/views"; import { FileSize } from "../Shared/FileSize"; import { LoadedContent } from "../List/PagedList"; import { useCloseEditDelete, useFilterOperations } from "../List/util"; -import { IListFilterOperation } from "../List/ListOperationButtons"; -import { FilteredListToolbar } from "../List/FilteredListToolbar"; +import { + OperationDropdown, + OperationDropdownItem, +} from "../List/ListOperationButtons"; import { useFilteredItemList } from "../List/ItemList"; import { FilterTags } from "../List/FilterTags"; import { Sidebar, SidebarPane, useSidebarState } from "../Shared/Sidebar"; @@ -49,6 +57,11 @@ import { } from "../List/Filters/FilterSidebar"; import { PatchContainerComponent } from "src/patch"; import { Pagination, PaginationIndex } from "../List/Pagination"; +import { Button, ButtonGroup, ButtonToolbar } from "react-bootstrap"; +import { FilterButton } from "../List/Filters/FilterButton"; +import { Icon } from "../Shared/Icon"; +import { ListViewOptions } from "../List/ListViewOptions"; +import { PageSizeSelector, SortBySelect } from "../List/ListFilter"; function renderMetadataByline(result: GQL.FindScenesQueryResult) { const duration = result?.data?.findScenes?.duration; @@ -82,33 +95,51 @@ function renderMetadataByline(result: GQL.FindScenesQueryResult) { function usePlayScene() { const history = useHistory(); + const { configuration: config } = useContext(ConfigurationContext); + const cont = config?.interface.continuePlaylistDefault ?? false; + const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false; + const playScene = useCallback( - (queue: SceneQueue, sceneID: string, options: IPlaySceneOptions) => { - history.push(queue.makeLink(sceneID, options)); + (queue: SceneQueue, sceneID: string, options?: IPlaySceneOptions) => { + history.push( + queue.makeLink(sceneID, { autoPlay, continue: cont, ...options }) + ); }, - [history] + [history, cont, autoPlay] ); return playScene; } function usePlaySelected(selectedIds: Set) { - const { configuration: config } = useContext(ConfigurationContext); const playScene = usePlayScene(); const playSelected = useCallback(() => { // populate queue and go to first scene const sceneIDs = Array.from(selectedIds.values()); const queue = SceneQueue.fromSceneIDList(sceneIDs); - const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false; - playScene(queue, sceneIDs[0], { autoPlay }); - }, [selectedIds, config?.interface.autostartVideoOnPlaySelected, playScene]); + + playScene(queue, sceneIDs[0]); + }, [selectedIds, playScene]); return playSelected; } +function usePlayFirst() { + const playScene = usePlayScene(); + + const playFirst = useCallback( + (queue: SceneQueue, sceneID: string, index: number) => { + // populate queue and go to first scene + playScene(queue, sceneID, { sceneIndex: index }); + }, + [playScene] + ); + + return playFirst; +} + function usePlayRandom(filter: ListFilterModel, count: number) { - const { configuration: config } = useContext(ConfigurationContext); const playScene = usePlayScene(); const playRandom = useCallback(async () => { @@ -130,15 +161,9 @@ function usePlayRandom(filter: ListFilterModel, count: number) { if (scene) { // navigate to the image player page const queue = SceneQueue.fromListFilterModel(filterCopy); - const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false; - playScene(queue, scene.id, { sceneIndex: index, autoPlay }); + playScene(queue, scene.id, { sceneIndex: index }); } - }, [ - filter, - count, - config?.interface.autostartVideoOnPlaySelected, - playScene, - ]); + }, [filter, count, playScene]); return playRandom; } @@ -213,12 +238,23 @@ const SidebarContent: React.FC<{ sidebarOpen: boolean; onClose?: () => void; showEditFilter: (editingCriterion?: string) => void; -}> = ({ filter, setFilter, view, showEditFilter, sidebarOpen, onClose }) => { + count?: number; +}> = ({ + filter, + setFilter, + view, + showEditFilter, + sidebarOpen, + onClose, + count, +}) => { + const showResultsId = + count !== undefined ? "actions.show_count_results" : "actions.show_results"; + return ( <> + +
+ +
); }; +interface IOperations { + text: string; + onClick: () => void; + isDisplayed?: () => boolean; + className?: string; +} + +const ListToolbarContent: React.FC<{ + criteriaCount: number; + items: GQL.SlimSceneDataFragment[]; + selectedIds: Set; + operations: IOperations[]; + onToggleSidebar: () => void; + onSelectAll: () => void; + onSelectNone: () => void; + onEdit: () => void; + onDelete: () => void; + onPlay: () => void; + onCreateNew: () => void; +}> = ({ + criteriaCount, + items, + selectedIds, + operations, + onToggleSidebar, + onSelectAll, + onSelectNone, + onEdit, + onDelete, + onPlay, + onCreateNew, +}) => { + const intl = useIntl(); + + const hasSelection = selectedIds.size > 0; + + return ( + <> + {!hasSelection && ( +
+ onToggleSidebar()} + count={criteriaCount} + title={intl.formatMessage({ id: "actions.sidebar.toggle" })} + /> +
+ )} + {hasSelection && ( +
+ + {selectedIds.size} selected + +
+ )} +
+ + {!!items.length && ( + + )} + {!hasSelection && ( + + )} + + {hasSelection && ( + <> + + + + )} + + + {operations.map((o) => { + if (o.isDisplayed && !o.isDisplayed()) { + return null; + } + + return ( + + ); + })} + + +
+ + ); +}; + +const ListResultsHeader: React.FC<{ + loading: boolean; + filter: ListFilterModel; + totalCount: number; + metadataByline?: React.ReactNode; + onChangeFilter: (filter: ListFilterModel) => void; +}> = ({ loading, filter, totalCount, metadataByline, onChangeFilter }) => { + return ( + +
+ +
+
+ + onChangeFilter(filter.setSortBy(s ?? undefined)) + } + onChangeSortDirection={() => + onChangeFilter(filter.toggleSortDirection()) + } + onReshuffleRandomSort={() => + onChangeFilter(filter.reshuffleRandomSort()) + } + /> + onChangeFilter(filter.setPageSize(s))} + /> + + onChangeFilter(filter.setDisplayMode(mode)) + } + onSetZoom={(zoom) => onChangeFilter(filter.setZoom(zoom))} + /> +
+
+ ); +}; + interface IFilteredScenes { filterHook?: (filter: ListFilterModel) => ListFilterModel; defaultSort?: string; @@ -312,6 +531,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => { selectedIds, selectedItems, onSelectChange, + onSelectAll, onSelectNone, hasSelection, } = listSelect; @@ -337,13 +557,36 @@ export const FilteredSceneList = (props: IFilteredScenes) => { }); const metadataByline = useMemo(() => { - if (cachedResult.loading) return ""; + if (cachedResult.loading) return null; - return renderMetadataByline(cachedResult) ?? ""; + return renderMetadataByline(cachedResult) ?? null; }, [cachedResult]); - const playSelected = usePlaySelected(selectedIds); + const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]); + const playRandom = usePlayRandom(filter, totalCount); + const playSelected = usePlaySelected(selectedIds); + const playFirst = usePlayFirst(); + + function onCreateNew() { + history.push("/scenes/new"); + } + + function onPlay() { + if (items.length === 0) { + return; + } + + // if there are selected items, play those + if (hasSelection) { + playSelected(); + return; + } + + // otherwise, play the first item in the list + const sceneID = items[0].id; + playFirst(queue, sceneID, 0); + } function onExport(all: boolean) { showModal( @@ -381,16 +624,41 @@ export const FilteredSceneList = (props: IFilteredScenes) => { ); } - const otherOperations: IListFilterOperation[] = [ + function onEdit() { + showModal( + + ); + } + + function onDelete() { + showModal( + + ); + } + + const otherOperations = [ { - text: intl.formatMessage({ id: "actions.play_selected" }), - onClick: playSelected, - isDisplayed: () => hasSelection, - icon: faPlay, + text: intl.formatMessage({ id: "actions.play" }), + onClick: () => onPlay(), + isDisplayed: () => items.length > 0, + className: "play-item", + }, + { + text: intl.formatMessage( + { id: "actions.create_entity" }, + { entityType: intl.formatMessage({ id: "scene" }) } + ), + onClick: () => onCreateNew(), + isDisplayed: () => !hasSelection, + className: "create-new-item", }, { text: intl.formatMessage({ id: "actions.play_random" }), onClick: playRandom, + isDisplayed: () => totalCount > 1, }, { text: `${intl.formatMessage({ id: "actions.generate" })}…`, @@ -452,34 +720,36 @@ export const FilteredSceneList = (props: IFilteredScenes) => { view={view} sidebarOpen={showSidebar} onClose={() => setShowSidebar(false)} + count={cachedResult.loading ? undefined : totalCount} />
- + setShowSidebar(!showSidebar)} + onSelectAll={() => onSelectAll()} + onSelectNone={() => onSelectNone()} + onEdit={onEdit} + onDelete={onDelete} + onCreateNew={onCreateNew} + onPlay={onPlay} + /> + + + - showModal( - - ) - } - onDelete={() => { - showModal( - - ); - }} - operations={otherOperations} - onToggleSidebar={() => setShowSidebar((v) => !v)} - zoomable + totalCount={totalCount} + metadataByline={metadataByline} + onChangeFilter={(newFilter) => setFilter(newFilter)} /> { onRemoveAll={() => clearAllCriteria()} /> - - div { + align-items: center; + display: flex; + gap: 0.5rem; + justify-content: flex-start; + + &:last-child { + flex-shrink: 0; + justify-content: flex-end; + margin-left: auto; + } + } +} + +.scene-list-toolbar { + flex-wrap: wrap; + // offset the main padding + margin-top: -0.5rem; + padding-bottom: 0.5rem; + padding-top: 0.5rem; + position: sticky; + top: $navbar-height; + z-index: 10; + + @include media-breakpoint-down(xs) { + top: 0; + } + + .selected-items-info .btn { + margin-right: 0.5rem; + } + + // hide drop down menu items for play and create new + // when the buttons are visible + @include media-breakpoint-up(sm) { + .scene-list-operations { + .play-item, + .create-new-item { + display: none; + } + } + } + + // hide play and create new buttons on xs screens + // show these in the drop down menu instead + @include media-breakpoint-down(xs) { + .play-button, + .create-new-button { + display: none; + } + } +} + +.scene-list-header { + flex-wrap: wrap-reverse; + gap: 0.5rem; + margin-bottom: 0.5rem; + + .paginationIndex { + margin: 0; + } + + // center the header on smaller screens + @include media-breakpoint-down(sm) { + & > div, + & > div:last-child { + flex-basis: 100%; + justify-content: center; + margin-left: auto; + margin-right: auto; + } + } +} + +.detail-body .scene-list-toolbar { + top: calc($sticky-detail-header-height + $navbar-height); + + @include media-breakpoint-down(xs) { + top: 0; + } +} diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index c80c92fdd..b3726cd4f 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -774,8 +774,9 @@ $sidebar-width: 250px; .sidebar { bottom: 0; left: 0; - margin-top: 4rem; + margin-top: $navbar-height; overflow-y: auto; + padding-top: 0.5rem; position: fixed; scrollbar-gutter: stable; top: 0; @@ -890,8 +891,7 @@ $sticky-header-height: calc(50px + 3.3rem); padding-left: 0; position: sticky; - // sticky detail header is 50px + 3.3rem - top: calc(50px + 3.3rem); + top: calc($sticky-detail-header-height + $navbar-height); .sidebar-toolbar { padding-top: 15px; @@ -918,7 +918,6 @@ $sticky-header-height: calc(50px + 3.3rem); flex: 100% 0 0; height: calc(100vh - 4rem); max-height: calc(100vh - 4rem); - padding-top: 0; top: 0; } diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 9393397ed..c013b852a 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -1,9 +1,10 @@ // variables required by other scss files // this is calculated from the existing height -// TODO: we should set this explicitly in the navbar $navbar-height: 48.75px; +$sticky-detail-header-height: 50px; + @import "styles/theme"; @import "styles/range"; @import "styles/scrollbars"; @@ -55,7 +56,7 @@ body { @include media-breakpoint-down(xs) { @media (orientation: portrait) { - padding: 1rem 0 $navbar-height; + padding: 0.5rem 0 $navbar-height; } } } @@ -85,10 +86,10 @@ dd { .sticky.detail-header { display: block; - min-height: 50px; + min-height: $sticky-detail-header-height; padding: unset; position: fixed; - top: 3.3rem; + top: $navbar-height; z-index: 10; @media (max-width: 576px) { @@ -692,8 +693,7 @@ div.dropdown-menu { .badge { margin: unset; - // stylelint-disable declaration-no-important - white-space: normal !important; + white-space: normal; } } @@ -1025,6 +1025,9 @@ div.dropdown-menu { top: auto; } } + @include media-breakpoint-up(xl) { + height: $navbar-height; + } .navbar-toggler { padding: 0.5em 0; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index b69e5a763..3d342754f 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -81,6 +81,7 @@ "open_random": "Open Random", "optimise_database": "Optimise Database", "overwrite": "Overwrite", + "play": "Play", "play_random": "Play Random", "play_selected": "Play selected", "preview": "Preview", @@ -124,9 +125,12 @@ "set_image": "Set image…", "show": "Show", "show_configuration": "Show Configuration", + "show_results": "Show results", + "show_count_results": "Show {count} results", "sidebar": { "close": "Close sidebar", - "open": "Open sidebar" + "open": "Open sidebar", + "toggle": "Toggle sidebar" }, "skip": "Skip", "split": "Split", diff --git a/ui/v2.5/src/models/list-filter/filter-options.ts b/ui/v2.5/src/models/list-filter/filter-options.ts index 68bc23e79..32b86e786 100644 --- a/ui/v2.5/src/models/list-filter/filter-options.ts +++ b/ui/v2.5/src/models/list-filter/filter-options.ts @@ -1,7 +1,7 @@ import { CriterionOption } from "./criteria/criterion"; import { DisplayMode } from "./types"; -interface ISortByOption { +export interface ISortByOption { messageID: string; value: string; } diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index e7cf6a6eb..ac9d9de1e 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -521,6 +521,34 @@ export class ListFilterModel { public setPageSize(pageSize: number) { const ret = this.clone(); ret.itemsPerPage = pageSize; + ret.currentPage = 1; // reset to first page + return ret; + } + + public setSortBy(sortBy: string | undefined) { + const ret = this.clone(); + ret.sortBy = sortBy; + ret.currentPage = 1; // reset to first page + return ret; + } + + public toggleSortDirection() { + const ret = this.clone(); + + if (ret.sortDirection === SortDirectionEnum.Asc) { + ret.sortDirection = SortDirectionEnum.Desc; + } else { + ret.sortDirection = SortDirectionEnum.Asc; + } + + ret.currentPage = 1; // reset to first page + return ret; + } + + public reshuffleRandomSort() { + const ret = this.clone(); + ret.currentPage = 1; + ret.randomSeed = -1; return ret; } From 661d2f64bb97b0bdeda9b1e58635120ed9b6b3dc Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 26 Jun 2025 15:47:45 +1000 Subject: [PATCH 008/157] Make path criterion default modifier includes instead of equals (#5968) --- .../src/models/list-filter/criteria/country.ts | 10 +++++----- .../src/models/list-filter/criteria/criterion.ts | 16 +++++++--------- ui/v2.5/src/models/list-filter/criteria/path.ts | 12 +++++++----- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/ui/v2.5/src/models/list-filter/criteria/country.ts b/ui/v2.5/src/models/list-filter/criteria/country.ts index 2430c2a59..e29ac97ff 100644 --- a/ui/v2.5/src/models/list-filter/criteria/country.ts +++ b/ui/v2.5/src/models/list-filter/criteria/country.ts @@ -3,11 +3,11 @@ import { CriterionModifier } from "src/core/generated-graphql"; import { getCountryByISO } from "src/utils/country"; import { StringCriterion, StringCriterionOption } from "./criterion"; -export const CountryCriterionOption = new StringCriterionOption( - "country", - "country", - () => new CountryCriterion() -); +export const CountryCriterionOption = new StringCriterionOption({ + messageID: "country", + type: "country", + makeCriterion: () => new CountryCriterion(), +}); export class CountryCriterion extends StringCriterion { constructor() { diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index d06272c4c..a4d3a145c 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -526,13 +526,12 @@ export class IHierarchicalLabeledIdCriterion extends ModifierCriterion ModifierCriterion + options: Partial< + Omit + > & + Pick ) { super({ - messageID, - type: value, modifierOptions: [ CriterionModifier.Equals, CriterionModifier.NotEquals, @@ -545,9 +544,8 @@ export class StringCriterionOption extends ModifierCriterionOption { ], defaultModifier: CriterionModifier.Equals, inputType: "text", - makeCriterion: makeCriterion - ? makeCriterion - : () => new StringCriterion(this), + makeCriterion: () => new StringCriterion(this), + ...options, }); } } @@ -556,7 +554,7 @@ export function createStringCriterionOption( type: CriterionType, messageID?: string ) { - return new StringCriterionOption(messageID ?? type, type); + return new StringCriterionOption({ messageID: messageID ?? type, type }); } export class MandatoryStringCriterionOption extends ModifierCriterionOption { diff --git a/ui/v2.5/src/models/list-filter/criteria/path.ts b/ui/v2.5/src/models/list-filter/criteria/path.ts index 2b57faf85..42a7789cf 100644 --- a/ui/v2.5/src/models/list-filter/criteria/path.ts +++ b/ui/v2.5/src/models/list-filter/criteria/path.ts @@ -1,10 +1,12 @@ +import { CriterionModifier } from "src/core/generated-graphql"; import { StringCriterion, StringCriterionOption } from "./criterion"; -export const PathCriterionOption = new StringCriterionOption( - "path", - "path", - () => new PathCriterion() -); +export const PathCriterionOption = new StringCriterionOption({ + messageID: "path", + type: "path", + defaultModifier: CriterionModifier.Includes, + makeCriterion: () => new PathCriterion(), +}); export class PathCriterion extends StringCriterion { constructor() { From 7eff7f02d09b0de6c631533fe95ce804583fd946 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 26 Jun 2025 15:48:29 +1000 Subject: [PATCH 009/157] Add findFolder and findFolders queries to graphql schema (#5965) * Add findFolder and findFolders queries to graphql schema * Add zip file criterion to file and folder queries --- graphql/schema/schema.graphql | 10 ++ graphql/schema/types/file.graphql | 7 +- graphql/schema/types/filters.graphql | 27 +++ internal/api/resolver_query_find_folder.go | 100 ++++++++++++ pkg/models/file.go | 1 + pkg/models/folder.go | 92 +++++++++++ pkg/models/mocks/FolderReaderWriter.go | 23 +++ pkg/models/model_folder.go | 8 + pkg/models/repository_folder.go | 5 + pkg/sqlite/file.go | 2 +- pkg/sqlite/file_filter.go | 38 +++++ pkg/sqlite/file_filter_test.go | 32 +++- pkg/sqlite/folder.go | 181 ++++++++++++++++++++- pkg/sqlite/folder_filter.go | 150 +++++++++++++++++ pkg/sqlite/folder_filter_test.go | 95 +++++++++++ pkg/sqlite/setup_test.go | 41 ++++- 16 files changed, 800 insertions(+), 12 deletions(-) create mode 100644 internal/api/resolver_query_find_folder.go create mode 100644 pkg/models/folder.go create mode 100644 pkg/sqlite/folder_filter.go create mode 100644 pkg/sqlite/folder_filter_test.go diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 1ca653403..7d0a761da 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -16,6 +16,16 @@ type Query { ids: [ID!] ): FindFilesResultType! + "Find a file by its id or path" + findFolder(id: ID, path: String): Folder! + + "Queries for Files" + findFolders( + folder_filter: FolderFilterType + filter: FindFilterType + ids: [ID!] + ): FindFoldersResultType! + "Find a scene by ID or Checksum" findScene(id: ID, checksum: String): Scene findSceneByHash(input: SceneHashInput!): Scene diff --git a/graphql/schema/types/file.graphql b/graphql/schema/types/file.graphql index c967c38f2..835479fad 100644 --- a/graphql/schema/types/file.graphql +++ b/graphql/schema/types/file.graphql @@ -10,7 +10,7 @@ type Folder { parent_folder_id: ID @deprecated(reason: "Use parent_folder instead") zip_file_id: ID @deprecated(reason: "Use zip_file instead") - parent_folder: Folder! + parent_folder: Folder zip_file: BasicFile mod_time: Time! @@ -176,3 +176,8 @@ type FindFilesResultType { files: [BaseFile!]! } + +type FindFoldersResultType { + count: Int! + folders: [Folder!]! +} diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index cab47172e..23ec4ca48 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -691,6 +691,7 @@ input FileFilterType { dir: StringCriterionInput parent_folder: HierarchicalMultiCriterionInput + zip_file: MultiCriterionInput "Filter by modification time" mod_time: TimestampCriterionInput @@ -721,6 +722,32 @@ input FileFilterType { updated_at: TimestampCriterionInput } +input FolderFilterType { + AND: FolderFilterType + OR: FolderFilterType + NOT: FolderFilterType + + path: StringCriterionInput + + parent_folder: HierarchicalMultiCriterionInput + zip_file: MultiCriterionInput + + "Filter by modification time" + mod_time: TimestampCriterionInput + + gallery_count: IntCriterionInput + + "Filter by files that meet this criteria" + files_filter: FileFilterType + "Filter by related galleries that meet this criteria" + galleries_filter: GalleryFilterType + + "Filter by creation time" + created_at: TimestampCriterionInput + "Filter by last update time" + updated_at: TimestampCriterionInput +} + input VideoFileFilterInput { resolution: ResolutionCriterionInput orientation: OrientationCriterionInput diff --git a/internal/api/resolver_query_find_folder.go b/internal/api/resolver_query_find_folder.go new file mode 100644 index 000000000..a7a798dd1 --- /dev/null +++ b/internal/api/resolver_query_find_folder.go @@ -0,0 +1,100 @@ +package api + +import ( + "context" + "errors" + "strconv" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" +) + +func (r *queryResolver) FindFolder(ctx context.Context, id *string, path *string) (*models.Folder, error) { + var ret *models.Folder + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Folder + var err error + switch { + case id != nil: + idInt, err := strconv.Atoi(*id) + if err != nil { + return err + } + ret, err = qb.Find(ctx, models.FolderID(idInt)) + if err != nil { + return err + } + case path != nil: + ret, err = qb.FindByPath(ctx, *path) + if err == nil && ret == nil { + return errors.New("folder not found") + } + default: + return errors.New("either id or path must be provided") + } + + return err + }); err != nil { + return nil, err + } + + return ret, nil +} + +func (r *queryResolver) FindFolders( + ctx context.Context, + folderFilter *models.FolderFilterType, + filter *models.FindFilterType, + ids []string, +) (ret *FindFoldersResultType, err error) { + var folderIDs []models.FolderID + if len(ids) > 0 { + folderIDsInt, err := stringslice.StringSliceToIntSlice(ids) + if err != nil { + return nil, err + } + + folderIDs = models.FolderIDsFromInts(folderIDsInt) + } + + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + var folders []*models.Folder + var err error + + fields := collectQueryFields(ctx) + result := &models.FolderQueryResult{} + + if len(folderIDs) > 0 { + folders, err = r.repository.Folder.FindMany(ctx, folderIDs) + if err == nil { + result.Count = len(folders) + } + } else { + result, err = r.repository.Folder.Query(ctx, models.FolderQueryOptions{ + QueryOptions: models.QueryOptions{ + FindFilter: filter, + Count: fields.Has("count"), + }, + FolderFilter: folderFilter, + }) + if err == nil { + folders, err = result.Resolve(ctx) + } + } + + if err != nil { + return err + } + + ret = &FindFoldersResultType{ + Count: result.Count, + Folders: folders, + } + + return nil + }); err != nil { + return nil, err + } + + return ret, nil +} diff --git a/pkg/models/file.go b/pkg/models/file.go index 1b77af21a..63c30ba4d 100644 --- a/pkg/models/file.go +++ b/pkg/models/file.go @@ -24,6 +24,7 @@ type FileFilterType struct { Basename *StringCriterionInput `json:"basename"` Dir *StringCriterionInput `json:"dir"` ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder"` + ZipFile *MultiCriterionInput `json:"zip_file"` ModTime *TimestampCriterionInput `json:"mod_time"` Duplicated *PHashDuplicationCriterionInput `json:"duplicated"` Hashes []*FingerprintFilterInput `json:"hashes"` diff --git a/pkg/models/folder.go b/pkg/models/folder.go new file mode 100644 index 000000000..ada9e17b7 --- /dev/null +++ b/pkg/models/folder.go @@ -0,0 +1,92 @@ +package models + +import ( + "context" + "path/filepath" + "strings" +) + +type FolderQueryOptions struct { + QueryOptions + FolderFilter *FolderFilterType + + TotalDuration bool + Megapixels bool + TotalSize bool +} + +type FolderFilterType struct { + OperatorFilter[FolderFilterType] + + Path *StringCriterionInput `json:"path,omitempty"` + Basename *StringCriterionInput `json:"basename,omitempty"` + // Filter by parent directory path + Dir *StringCriterionInput `json:"dir,omitempty"` + ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder,omitempty"` + ZipFile *MultiCriterionInput `json:"zip_file,omitempty"` + // Filter by modification time + ModTime *TimestampCriterionInput `json:"mod_time,omitempty"` + GalleryCount *IntCriterionInput `json:"gallery_count,omitempty"` + // Filter by files that meet this criteria + FilesFilter *FileFilterType `json:"files_filter,omitempty"` + // Filter by related galleries that meet this criteria + GalleriesFilter *GalleryFilterType `json:"galleries_filter,omitempty"` + // Filter by creation time + CreatedAt *TimestampCriterionInput `json:"created_at,omitempty"` + // Filter by last update time + UpdatedAt *TimestampCriterionInput `json:"updated_at,omitempty"` +} + +func PathsFolderFilter(paths []string) *FileFilterType { + if paths == nil { + return nil + } + + sep := string(filepath.Separator) + + var ret *FileFilterType + var or *FileFilterType + for _, p := range paths { + newOr := &FileFilterType{} + if or != nil { + or.Or = newOr + } else { + ret = newOr + } + + or = newOr + + if !strings.HasSuffix(p, sep) { + p += sep + } + + or.Path = &StringCriterionInput{ + Modifier: CriterionModifierEquals, + Value: p + "%", + } + } + + return ret +} + +type FolderQueryResult struct { + QueryResult[FolderID] + + getter FolderGetter + folders []*Folder + resolveErr error +} + +func NewFolderQueryResult(folderGetter FolderGetter) *FolderQueryResult { + return &FolderQueryResult{ + getter: folderGetter, + } +} + +func (r *FolderQueryResult) Resolve(ctx context.Context) ([]*Folder, error) { + // cache results + if r.folders == nil && r.resolveErr == nil { + r.folders, r.resolveErr = r.getter.FindMany(ctx, r.IDs) + } + return r.folders, r.resolveErr +} diff --git a/pkg/models/mocks/FolderReaderWriter.go b/pkg/models/mocks/FolderReaderWriter.go index 020764942..512925fd6 100644 --- a/pkg/models/mocks/FolderReaderWriter.go +++ b/pkg/models/mocks/FolderReaderWriter.go @@ -201,6 +201,29 @@ func (_m *FolderReaderWriter) FindMany(ctx context.Context, id []models.FolderID return r0, r1 } +// Query provides a mock function with given fields: ctx, options +func (_m *FolderReaderWriter) Query(ctx context.Context, options models.FolderQueryOptions) (*models.FolderQueryResult, error) { + ret := _m.Called(ctx, options) + + var r0 *models.FolderQueryResult + if rf, ok := ret.Get(0).(func(context.Context, models.FolderQueryOptions) *models.FolderQueryResult); ok { + r0 = rf(ctx, options) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.FolderQueryResult) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, models.FolderQueryOptions) error); ok { + r1 = rf(ctx, options) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Update provides a mock function with given fields: ctx, f func (_m *FolderReaderWriter) Update(ctx context.Context, f *models.Folder) error { ret := _m.Called(ctx, f) diff --git a/pkg/models/model_folder.go b/pkg/models/model_folder.go index 590cdd7bd..39897aa60 100644 --- a/pkg/models/model_folder.go +++ b/pkg/models/model_folder.go @@ -35,6 +35,14 @@ func (i FolderID) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(i.String())) } +func FolderIDsFromInts(ids []int) []FolderID { + ret := make([]FolderID, len(ids)) + for i, id := range ids { + ret[i] = FolderID(id) + } + return ret +} + // Folder represents a folder in the file system. type Folder struct { ID FolderID `json:"id"` diff --git a/pkg/models/repository_folder.go b/pkg/models/repository_folder.go index 20c155ead..671e8780d 100644 --- a/pkg/models/repository_folder.go +++ b/pkg/models/repository_folder.go @@ -17,6 +17,10 @@ type FolderFinder interface { FindByParentFolderID(ctx context.Context, parentFolderID FolderID) ([]*Folder, error) } +type FolderQueryer interface { + Query(ctx context.Context, options FolderQueryOptions) (*FolderQueryResult, error) +} + type FolderCounter interface { CountAllInPaths(ctx context.Context, p []string) (int, error) } @@ -48,6 +52,7 @@ type FolderFinderDestroyer interface { // FolderReader provides all methods to read folders. type FolderReader interface { FolderFinder + FolderQueryer FolderCounter } diff --git a/pkg/sqlite/file.go b/pkg/sqlite/file.go index ea2084c2c..ad3442ff7 100644 --- a/pkg/sqlite/file.go +++ b/pkg/sqlite/file.go @@ -321,7 +321,7 @@ type FileStore struct { func NewFileStore() *FileStore { return &FileStore{ repository: repository{ - tableName: sceneTable, + tableName: fileTable, idColumn: idColumn, }, diff --git a/pkg/sqlite/file_filter.go b/pkg/sqlite/file_filter.go index b115fee35..60ca01648 100644 --- a/pkg/sqlite/file_filter.go +++ b/pkg/sqlite/file_filter.go @@ -68,6 +68,7 @@ func (qb *fileFilterHandler) criterionHandler() criterionHandler { ×tampCriterionHandler{fileFilter.ModTime, "files.mod_time", nil}, qb.parentFolderCriterionHandler(fileFilter.ParentFolder), + qb.zipFileCriterionHandler(fileFilter.ZipFile), qb.sceneCountCriterionHandler(fileFilter.SceneCount), qb.imageCountCriterionHandler(fileFilter.ImageCount), @@ -106,6 +107,43 @@ func (qb *fileFilterHandler) criterionHandler() criterionHandler { } } +func (qb *fileFilterHandler) zipFileCriterionHandler(criterion *models.MultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if criterion != nil { + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { + var notClause string + if criterion.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addWhere(fmt.Sprintf("files.zip_file_id IS %s NULL", notClause)) + return + } + + if len(criterion.Value) == 0 { + return + } + + var args []interface{} + for _, tagID := range criterion.Value { + args = append(args, tagID) + } + + whereClause := "" + havingClause := "" + switch criterion.Modifier { + case models.CriterionModifierIncludes: + whereClause = "files.zip_file_id IN " + getInBinding(len(criterion.Value)) + case models.CriterionModifierExcludes: + whereClause = "files.zip_file_id NOT IN " + getInBinding(len(criterion.Value)) + } + + f.addWhere(whereClause, args...) + f.addHaving(havingClause) + } + } +} + func (qb *fileFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if folder == nil { diff --git a/pkg/sqlite/file_filter_test.go b/pkg/sqlite/file_filter_test.go index 7bc6f3e6b..50eed0129 100644 --- a/pkg/sqlite/file_filter_test.go +++ b/pkg/sqlite/file_filter_test.go @@ -18,7 +18,7 @@ func TestFileQuery(t *testing.T) { findFilter *models.FindFilterType filter *models.FileFilterType includeIdxs []int - includeIDs []int + includeIDs []models.FileID excludeIdxs []int wantErr bool }{ @@ -52,7 +52,7 @@ func TestFileQuery(t *testing.T) { Modifier: models.CriterionModifierIncludes, }, }, - includeIDs: []int{int(sceneFileIDs[sceneIdxWithGroup])}, + includeIDs: []models.FileID{sceneFileIDs[sceneIdxWithGroup]}, excludeIdxs: []int{fileIdxStartImageFiles}, }, { @@ -65,7 +65,20 @@ func TestFileQuery(t *testing.T) { Modifier: models.CriterionModifierIncludes, }, }, - includeIDs: []int{int(sceneFileIDs[sceneIdxWithGroup])}, + includeIDs: []models.FileID{sceneFileIDs[sceneIdxWithGroup]}, + excludeIdxs: []int{fileIdxStartImageFiles}, + }, + { + name: "zip file", + filter: &models.FileFilterType{ + ZipFile: &models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(int(fileIDs[fileIdxZip])), + }, + Modifier: models.CriterionModifierIncludes, + }, + }, + includeIDs: []models.FileID{fileIDs[fileIdxInZip]}, excludeIdxs: []int{fileIdxStartImageFiles}, }, // TODO - add more tests for other file filters @@ -86,15 +99,18 @@ func TestFileQuery(t *testing.T) { return } - include := indexesToIDs(sceneIDs, tt.includeIdxs) - include = append(include, tt.includeIDs...) - exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) + include := indexesToIDPtrs(fileIDs, tt.includeIdxs) + for _, id := range tt.includeIDs { + v := id + include = append(include, &v) + } + exclude := indexesToIDPtrs(fileIDs, tt.excludeIdxs) for _, i := range include { - assert.Contains(results.IDs, models.FileID(i)) + assert.Contains(results.IDs, models.FileID(*i)) } for _, e := range exclude { - assert.NotContains(results.IDs, models.FileID(e)) + assert.NotContains(results.IDs, models.FileID(*e)) } }) } diff --git a/pkg/sqlite/folder.go b/pkg/sqlite/folder.go index f90a578bd..3ac962b8b 100644 --- a/pkg/sqlite/folder.go +++ b/pkg/sqlite/folder.go @@ -16,6 +16,7 @@ import ( ) const folderTable = "folders" +const folderIDColumn = "folder_id" type folderRow struct { ID models.FolderID `db:"id" goqu:"skipinsert"` @@ -83,6 +84,25 @@ func (r folderQueryRows) resolve() []*models.Folder { return ret } +type folderRepositoryType struct { + repository + + galleries repository +} + +var ( + folderRepository = folderRepositoryType{ + repository: repository{ + tableName: folderTable, + idColumn: idColumn, + }, + galleries: repository{ + tableName: galleryTable, + idColumn: folderIDColumn, + }, + } +) + type FolderStore struct { repository @@ -92,7 +112,7 @@ type FolderStore struct { func NewFolderStore() *FolderStore { return &FolderStore{ repository: repository{ - tableName: sceneTable, + tableName: folderTable, idColumn: idColumn, }, @@ -360,3 +380,162 @@ func (qb *FolderStore) FindByZipFileID(ctx context.Context, zipFileID models.Fil return qb.getMany(ctx, q) } + +func (qb *FolderStore) validateFilter(fileFilter *models.FolderFilterType) error { + const and = "AND" + const or = "OR" + const not = "NOT" + + if fileFilter.And != nil { + if fileFilter.Or != nil { + return illegalFilterCombination(and, or) + } + if fileFilter.Not != nil { + return illegalFilterCombination(and, not) + } + + return qb.validateFilter(fileFilter.And) + } + + if fileFilter.Or != nil { + if fileFilter.Not != nil { + return illegalFilterCombination(or, not) + } + + return qb.validateFilter(fileFilter.Or) + } + + if fileFilter.Not != nil { + return qb.validateFilter(fileFilter.Not) + } + + return nil +} + +func (qb *FolderStore) makeFilter(ctx context.Context, folderFilter *models.FolderFilterType) *filterBuilder { + query := &filterBuilder{} + + if folderFilter.And != nil { + query.and(qb.makeFilter(ctx, folderFilter.And)) + } + if folderFilter.Or != nil { + query.or(qb.makeFilter(ctx, folderFilter.Or)) + } + if folderFilter.Not != nil { + query.not(qb.makeFilter(ctx, folderFilter.Not)) + } + + filter := filterBuilderFromHandler(ctx, &folderFilterHandler{ + folderFilter: folderFilter, + }) + + return filter +} + +func (qb *FolderStore) Query(ctx context.Context, options models.FolderQueryOptions) (*models.FolderQueryResult, error) { + folderFilter := options.FolderFilter + findFilter := options.FindFilter + + if folderFilter == nil { + folderFilter = &models.FolderFilterType{} + } + if findFilter == nil { + findFilter = &models.FindFilterType{} + } + + query := qb.newQuery() + + distinctIDs(&query, folderTable) + + if q := findFilter.Q; q != nil && *q != "" { + searchColumns := []string{"folders.path"} + query.parseQueryString(searchColumns, *q) + } + + if err := qb.validateFilter(folderFilter); err != nil { + return nil, err + } + filter := qb.makeFilter(ctx, folderFilter) + + if err := query.addFilter(filter); err != nil { + return nil, err + } + + if err := qb.setQuerySort(&query, findFilter); err != nil { + return nil, err + } + query.sortAndPagination += getPagination(findFilter) + + result, err := qb.queryGroupedFields(ctx, options, query) + if err != nil { + return nil, fmt.Errorf("error querying aggregate fields: %w", err) + } + + idsResult, err := query.findIDs(ctx) + if err != nil { + return nil, fmt.Errorf("error finding IDs: %w", err) + } + + result.IDs = make([]models.FolderID, len(idsResult)) + for i, id := range idsResult { + result.IDs[i] = models.FolderID(id) + } + + return result, nil +} + +func (qb *FolderStore) queryGroupedFields(ctx context.Context, options models.FolderQueryOptions, query queryBuilder) (*models.FolderQueryResult, error) { + if !options.Count { + // nothing to do - return empty result + return models.NewFolderQueryResult(qb), nil + } + + aggregateQuery := qb.newQuery() + + if options.Count { + aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total") + } + + const includeSortPagination = false + aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination)) + + out := struct { + Total int + Duration float64 + Megapixels float64 + Size int64 + }{} + if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil { + return nil, err + } + + ret := models.NewFolderQueryResult(qb) + ret.Count = out.Total + + return ret, nil +} + +var folderSortOptions = sortOptions{ + "created_at", + "id", + "path", + "random", + "updated_at", +} + +func (qb *FolderStore) setQuerySort(query *queryBuilder, findFilter *models.FindFilterType) error { + if findFilter == nil || findFilter.Sort == nil || *findFilter.Sort == "" { + return nil + } + sort := findFilter.GetSort("path") + + // CVE-2024-32231 - ensure sort is in the list of allowed sorts + if err := folderSortOptions.validateSort(sort); err != nil { + return err + } + + direction := findFilter.GetDirection() + query.sortAndPagination += getSort(sort, direction, "folders") + + return nil +} diff --git a/pkg/sqlite/folder_filter.go b/pkg/sqlite/folder_filter.go new file mode 100644 index 000000000..2fda0d1e3 --- /dev/null +++ b/pkg/sqlite/folder_filter.go @@ -0,0 +1,150 @@ +package sqlite + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/models" +) + +type folderFilterHandler struct { + folderFilter *models.FolderFilterType +} + +func (qb *folderFilterHandler) validate() error { + folderFilter := qb.folderFilter + if folderFilter == nil { + return nil + } + + if err := validateFilterCombination(folderFilter.OperatorFilter); err != nil { + return err + } + + if subFilter := folderFilter.SubFilter(); subFilter != nil { + sqb := &folderFilterHandler{folderFilter: subFilter} + if err := sqb.validate(); err != nil { + return err + } + } + + return nil +} + +func (qb *folderFilterHandler) handle(ctx context.Context, f *filterBuilder) { + folderFilter := qb.folderFilter + if folderFilter == nil { + return + } + + if err := qb.validate(); err != nil { + f.setError(err) + return + } + + sf := folderFilter.SubFilter() + if sf != nil { + sub := &folderFilterHandler{sf} + handleSubFilter(ctx, sub, f, folderFilter.OperatorFilter) + } + + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *folderFilterHandler) criterionHandler() criterionHandler { + folderFilter := qb.folderFilter + return compoundHandler{ + stringCriterionHandler(folderFilter.Path, "folders.path"), + ×tampCriterionHandler{folderFilter.ModTime, "folders.mod_time", nil}, + + qb.parentFolderCriterionHandler(folderFilter.ParentFolder), + qb.zipFileCriterionHandler(folderFilter.ZipFile), + + qb.galleryCountCriterionHandler(folderFilter.GalleryCount), + + ×tampCriterionHandler{folderFilter.CreatedAt, "folders.created_at", nil}, + ×tampCriterionHandler{folderFilter.UpdatedAt, "folders.updated_at", nil}, + + &relatedFilterHandler{ + relatedIDCol: "galleries.id", + relatedRepo: galleryRepository.repository, + relatedHandler: &galleryFilterHandler{folderFilter.GalleriesFilter}, + joinFn: func(f *filterBuilder) { + folderRepository.galleries.innerJoin(f, "", "folders.id") + }, + }, + } +} + +func (qb *folderFilterHandler) zipFileCriterionHandler(criterion *models.MultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if criterion != nil { + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { + var notClause string + if criterion.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addWhere(fmt.Sprintf("folders.zip_file_id IS %s NULL", notClause)) + return + } + + if len(criterion.Value) == 0 { + return + } + + var args []interface{} + for _, tagID := range criterion.Value { + args = append(args, tagID) + } + + whereClause := "" + havingClause := "" + switch criterion.Modifier { + case models.CriterionModifierIncludes: + whereClause = "folders.zip_file_id IN " + getInBinding(len(criterion.Value)) + case models.CriterionModifierExcludes: + whereClause = "folders.zip_file_id NOT IN " + getInBinding(len(criterion.Value)) + } + + f.addWhere(whereClause, args...) + f.addHaving(havingClause) + } + } +} + +func (qb *folderFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if folder == nil { + return + } + + folderCopy := *folder + switch folderCopy.Modifier { + case models.CriterionModifierEquals: + folderCopy.Modifier = models.CriterionModifierIncludesAll + case models.CriterionModifierNotEquals: + folderCopy.Modifier = models.CriterionModifierExcludes + } + + hh := hierarchicalMultiCriterionHandlerBuilder{ + primaryTable: folderTable, + foreignTable: folderTable, + foreignFK: "parent_folder_id", + parentFK: "parent_folder_id", + } + + hh.handler(&folderCopy)(ctx, f) + } +} + +func (qb *folderFilterHandler) galleryCountCriterionHandler(galleryCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if galleryCount != nil { + f.addLeftJoin("galleries", "", "galleries.folder_id = folders.id") + clause, args := getIntCriterionWhereClause("count(distinct galleries.id)", *galleryCount) + + f.addHaving(clause, args...) + } + } +} diff --git a/pkg/sqlite/folder_filter_test.go b/pkg/sqlite/folder_filter_test.go new file mode 100644 index 000000000..c1c7d7a37 --- /dev/null +++ b/pkg/sqlite/folder_filter_test.go @@ -0,0 +1,95 @@ +//go:build integration +// +build integration + +package sqlite_test + +import ( + "context" + "strconv" + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stretchr/testify/assert" +) + +func TestFolderQuery(t *testing.T) { + tests := []struct { + name string + findFilter *models.FindFilterType + filter *models.FolderFilterType + includeIdxs []int + includeIDs []models.FolderID + excludeIdxs []int + wantErr bool + }{ + { + name: "path", + filter: &models.FolderFilterType{ + Path: &models.StringCriterionInput{ + Value: getFolderPath(folderIdxWithSubFolder, nil), + Modifier: models.CriterionModifierIncludes, + }, + }, + includeIdxs: []int{folderIdxWithSubFolder, folderIdxWithParentFolder}, + excludeIdxs: []int{folderIdxInZip}, + }, + { + name: "parent folder", + filter: &models.FolderFilterType{ + ParentFolder: &models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(int(folderIDs[folderIdxWithSubFolder])), + }, + Modifier: models.CriterionModifierIncludes, + }, + }, + includeIdxs: []int{folderIdxWithParentFolder}, + excludeIdxs: []int{folderIdxWithSubFolder, folderIdxInZip}, + }, + { + name: "zip file", + filter: &models.FolderFilterType{ + ZipFile: &models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(int(fileIDs[fileIdxZip])), + }, + Modifier: models.CriterionModifierIncludes, + }, + }, + includeIdxs: []int{folderIdxInZip}, + excludeIdxs: []int{folderIdxForObjectFiles}, + }, + // TODO - add more tests for other folder filters + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + results, err := db.Folder.Query(ctx, models.FolderQueryOptions{ + FolderFilter: tt.filter, + QueryOptions: models.QueryOptions{ + FindFilter: tt.findFilter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } + + include := indexesToIDPtrs(folderIDs, tt.includeIdxs) + for _, id := range tt.includeIDs { + v := id + include = append(include, &v) + } + exclude := indexesToIDPtrs(folderIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(results.IDs, models.FolderID(*i)) + } + for _, e := range exclude { + assert.NotContains(results.IDs, models.FolderID(*e)) + } + }) + } +} diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 72aeab5bb..a1df897ca 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -578,6 +578,22 @@ func indexToID(ids []int, idx int) int { return ids[idx] } +func indexesToIDPtrs[T any](ids []T, indexes []int) []*T { + ret := make([]*T, len(indexes)) + for i, idx := range indexes { + ret[i] = indexToIDPtr(ids, idx) + } + + return ret +} + +func indexToIDPtr[T any](ids []T, idx int) *T { + if idx < 0 { + return nil + } + return &ids[idx] +} + func indexFromID(ids []int, id int) int { for i, v := range ids { if v == id { @@ -675,7 +691,9 @@ func populateDB() error { return fmt.Errorf("creating files: %w", err) } - // TODO - link folders to zip files + if err := linkFoldersToZip(ctx); err != nil { + return fmt.Errorf("linking folders to zip files: %w", err) + } if err := createTags(ctx, db.Tag, tagsNameCase, tagsNameNoCase); err != nil { return fmt.Errorf("error creating tags: %s", err.Error()) @@ -798,6 +816,27 @@ func createFolders(ctx context.Context) error { return nil } +func linkFoldersToZip(ctx context.Context) error { + // link folders to zip files + for folderIdx, fileIdx := range folderZipFiles { + folderID := folderIDs[folderIdx] + fileID := fileIDs[fileIdx] + + f, err := db.Folder.Find(ctx, folderID) + if err != nil { + return fmt.Errorf("Error finding folder [%d] to link to zip file [%d]", folderID, fileID) + } + + f.ZipFileID = &fileID + + if err := db.Folder.Update(ctx, f); err != nil { + return fmt.Errorf("Error linking folder [%d] to zip file [%d]: %s", folderIdx, fileIdx, err.Error()) + } + } + + return nil +} + func getFileBaseName(index int) string { return getPrefixedStringValue("file", index, "basename") } From 3af472d3b2fdda1061c9b401aa4a73efab507a2f Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Fri, 27 Jun 2025 05:54:44 +0300 Subject: [PATCH 010/157] Fix typo in Plugins documentation for clarity (#5972) --- ui/v2.5/src/docs/en/Manual/Plugins.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/docs/en/Manual/Plugins.md b/ui/v2.5/src/docs/en/Manual/Plugins.md index 47b87cf01..f7517aa2c 100644 --- a/ui/v2.5/src/docs/en/Manual/Plugins.md +++ b/ui/v2.5/src/docs/en/Manual/Plugins.md @@ -1,6 +1,7 @@ # Plugins Stash supports plugins that can do the following: + - perform custom tasks when triggered by the user from the Tasks page - perform custom tasks when triggered from specific events - add custom CSS to the UI @@ -14,7 +15,7 @@ Plugin tasks can be implemented using embedded Javascript, or by calling an exte Plugins can be installed and managed from the `Settings > Plugins` page. -Scrapers are installed using the `Available Plugins` section. This section allows configuring sources from which to install plugins. The `Community (stable)` source is configured by default. This source contains plugins for the current _stable_ version of stash. +Plugins are installed using the `Available Plugins` section. This section allows configuring sources from which to install plugins. The `Community (stable)` source is configured by default. This source contains plugins for the current _stable_ version of stash. These are the plugin sources maintained by the stashapp organisation: From 5323d68d3d3234a00de67be838b9ea93105af74f Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 27 Jun 2025 16:26:03 +1000 Subject: [PATCH 011/157] Add graphql playground link to tools panel (#5807) * Add graphql playground link to tools panel * Move to separate section --- .../Settings/SettingsToolsPanel.tsx | 60 ++++++++++++------- ui/v2.5/src/locales/en-GB.json | 2 + 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx b/ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx index 8168ffb99..e3577a499 100644 --- a/ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx @@ -5,33 +5,49 @@ import { Link } from "react-router-dom"; import { Setting } from "./Inputs"; import { SettingSection } from "./SettingSection"; import { PatchContainerComponent } from "src/patch"; +import { ExternalLink } from "../Shared/ExternalLink"; const SettingsToolsSection = PatchContainerComponent("SettingsToolsSection"); export const SettingsToolsPanel: React.FC = () => { return ( - - - - - - } - /> + <> + + + + + + } + /> + + + + + + + + } + /> - - - - } - /> - - + + + + } + /> + + + ); }; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 3d342754f..8095870cb 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -569,6 +569,8 @@ "set_name_date_details_from_metadata_if_present": "Set name, date, details from embedded file metadata" }, "tools": { + "graphql_playground": "GraphQL playground", + "heading": "Tools", "scene_duplicate_checker": "Scene Duplicate Checker", "scene_filename_parser": { "add_field": "Add Field", From 429022a46865fcb902499adb5c41fa27867184d3 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 30 Jun 2025 07:51:53 +1000 Subject: [PATCH 012/157] Handle marshalling scraped movie to group (#5974) --- internal/api/scraped_content.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/api/scraped_content.go b/internal/api/scraped_content.go index 6288812ef..f7d40c95d 100644 --- a/internal/api/scraped_content.go +++ b/internal/api/scraped_content.go @@ -135,6 +135,13 @@ func marshalScrapedGroups(content []scraper.ScrapedContent) ([]*models.ScrapedGr ret = append(ret, m) case models.ScrapedGroup: ret = append(ret, &m) + // it's possible that a scraper returns models.ScrapedMovie + case *models.ScrapedMovie: + g := m.ScrapedGroup() + ret = append(ret, &g) + case models.ScrapedMovie: + g := m.ScrapedGroup() + ret = append(ret, &g) default: return nil, fmt.Errorf("%w: cannot turn ScrapedContent into ScrapedGroup", models.ErrConversion) } From bd8ec8cb832e55eb9b3e769a10119e90083b3d8f Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 30 Jun 2025 07:52:12 +1000 Subject: [PATCH 013/157] Don't update stash ids when scraping from stash-box (#5975) --- .../Performers/PerformerDetails/PerformerEditPanel.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index df5b62b05..a3f128fee 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -466,7 +466,6 @@ export const PerformerEditPanel: React.FC = ({ setScraper(undefined); } else { setScrapedPerformer(result); - updateStashIDs(performerResult.remote_site_id); } } From 7215b6e9180ef99d62785e58d6ff7c795f60e1d1 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 30 Jun 2025 07:52:32 +1000 Subject: [PATCH 014/157] Ensure tmp dir is created before creating temp file (#5977) --- pkg/models/paths/paths_generated.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/models/paths/paths_generated.go b/pkg/models/paths/paths_generated.go index d87e1eed6..2b5f5003e 100644 --- a/pkg/models/paths/paths_generated.go +++ b/pkg/models/paths/paths_generated.go @@ -43,6 +43,9 @@ func (gp *generatedPaths) GetTmpPath(fileName string) string { // TempFile creates a temporary file using os.CreateTemp. // It is the equivalent of calling os.CreateTemp using Tmp and pattern. func (gp *generatedPaths) TempFile(pattern string) (*os.File, error) { + if err := gp.EnsureTmpDir(); err != nil { + logger.Warnf("Could not ensure existence of a temporary directory: %v", err) + } return os.CreateTemp(gp.Tmp, pattern) } From 61ab6ce6bd7eb1a825d8df9507c94a303ad7bbdc Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 30 Jun 2025 07:52:53 +1000 Subject: [PATCH 015/157] Fix funscript parsing issues (#5978) * Accept floating point numbers for at field in funscript * Ignore type of script version field * Write rounded ints for csv --- .../generator_interactive_heatmap_speed.go | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/internal/manager/generator_interactive_heatmap_speed.go b/internal/manager/generator_interactive_heatmap_speed.go index ac6ca53bd..d10ce5b19 100644 --- a/internal/manager/generator_interactive_heatmap_speed.go +++ b/internal/manager/generator_interactive_heatmap_speed.go @@ -28,7 +28,8 @@ type InteractiveHeatmapSpeedGenerator struct { type Script struct { // Version of Launchscript - Version string `json:"version"` + // #5600 - ignore version, don't validate type + Version json.RawMessage `json:"version"` // Inverted causes up and down movement to be flipped. Inverted bool `json:"inverted,omitempty"` // Range is the percentage of a full stroke to use. @@ -40,7 +41,7 @@ type Script struct { // Action is a move at a specific time. type Action struct { // At time in milliseconds the action should fire. - At int64 `json:"at"` + At float64 `json:"at"` // Pos is the place in percent to move to. Pos int `json:"pos"` @@ -109,8 +110,8 @@ func (g *InteractiveHeatmapSpeedGenerator) LoadFunscriptData(path string, sceneD // trim actions with negative timestamps to avoid index range errors when generating heatmap // #3181 - also trim actions that occur after the scene duration loggedBadTimestamp := false - sceneDurationMilli := int64(sceneDuration * 1000) - isValid := func(x int64) bool { + sceneDurationMilli := sceneDuration * 1000 + isValid := func(x float64) bool { return x >= 0 && x < sceneDurationMilli } @@ -132,7 +133,7 @@ func (g *InteractiveHeatmapSpeedGenerator) LoadFunscriptData(path string, sceneD func (funscript *Script) UpdateIntensityAndSpeed() { - var t1, t2 int64 + var t1, t2 float64 var p1, p2 int var intensity float64 for i := range funscript.Actions { @@ -241,13 +242,13 @@ func (gt GradientTable) GetYRange(t float64) [2]float64 { func (funscript Script) getGradientTable(numSegments int, sceneDurationMilli int64) GradientTable { const windowSize = 15 - const backfillThreshold = 500 + const backfillThreshold = float64(500) segments := make([]struct { count int intensity int yRange [2]float64 - at int64 + at float64 }, numSegments) gradient := make(GradientTable, numSegments) posList := []int{} @@ -297,7 +298,7 @@ func (funscript Script) getGradientTable(numSegments int, sceneDurationMilli int // Fill in gaps in segments for i := 0; i < numSegments; i++ { - segmentTS := (maxts / int64(numSegments)) * int64(i) + segmentTS := float64((maxts / int64(numSegments)) * int64(i)) // Empty segment - fill it with the previous up to backfillThreshold ms if segments[i].count == 0 { @@ -406,7 +407,8 @@ func ConvertFunscriptToCSV(funscriptPath string) ([]byte, error) { pos = convertRange(pos, 0, funscript.Range, 0, 100) } - buffer.WriteString(fmt.Sprintf("%d,%d\r\n", action.At, pos)) + // I don't know whether the csv format requires int or float, so for now we'll use int + buffer.WriteString(fmt.Sprintf("%d,%d\r\n", int(math.Round(action.At)), pos)) } return buffer.Bytes(), nil } From 6f4920cb8106804ddd32873144593140f5298baa Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 30 Jun 2025 07:53:08 +1000 Subject: [PATCH 016/157] Update custom css links and replace plex theme link with themes link (#5976) --- ui/v2.5/src/docs/en/Manual/Interface.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/docs/en/Manual/Interface.md b/ui/v2.5/src/docs/en/Manual/Interface.md index 0f5fc23c4..31c7e25d4 100644 --- a/ui/v2.5/src/docs/en/Manual/Interface.md +++ b/ui/v2.5/src/docs/en/Manual/Interface.md @@ -30,9 +30,9 @@ By default, when a scene has a resume point, the scene player will automatically ## Custom CSS -The stash UI can be customised using custom CSS. See [here](https://docs.stashapp.cc/user-interface-ui/custom-css-snippets) for a community-curated set of CSS snippets to customise your UI. +The stash UI can be customised using custom CSS. See [here](https://docs.stashapp.cc/themes/custom-css-snippets/) for a community-curated set of CSS snippets to customise your UI. -[Stash Plex Theme](https://docs.stashapp.cc/user-interface-ui/themes/plex) is a community created theme inspired by the popular Plex interface. +There is also a [collection of community-created themes](https://docs.stashapp.cc/themes/list/#browse-themes) available. ## Custom Javascript From 3a232b1d6c92bc3cdd3bea7922cb8e73f9148a52 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 30 Jun 2025 07:53:33 +1000 Subject: [PATCH 017/157] Pagination styling (#5973) * Raise pagination slightly to avoid occlusion from link bar * Add shadow to pagination --- ui/v2.5/src/components/List/styles.scss | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 49deb5983..d025ed6c1 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -984,14 +984,20 @@ input[type="range"].zoom-slider { .pagination-footer { background-color: transparent; bottom: $navbar-height; - padding: 0.5rem 1rem; + margin: auto; + padding: 0.5rem 1rem 0.75rem; position: sticky; + width: fit-content; z-index: 10; @include media-breakpoint-up(sm) { bottom: 0; } + .pagination.btn-group { + box-shadow: 0 8px 10px 2px rgb(0 0 0 / 30%); + } + .pagination { margin-bottom: 0; From f01f95ddfb88ff02fc199c6c5dcd54a38e5115dc Mon Sep 17 00:00:00 2001 From: QxxxGit <71350626+QxxxGit@users.noreply.github.com> Date: Mon, 30 Jun 2025 23:48:16 -0400 Subject: [PATCH 018/157] Organize UIPluginApi.md docs and pluginApi.d.ts (#5971) * Organized alphabetically and removed duplicate Setting and TabTitleCounter * Organized components alphabetically * Add missing PerformerDetailsPanel and PerformerDetailsPanel.DetailGroup * Add missing SceneFileInfoPanel component * Add missing MainNavBar.MenuItems and MainNavBar.UtilityItems in docs --- ui/v2.5/src/docs/en/Manual/UIPluginApi.md | 31 ++--- ui/v2.5/src/pluginApi.d.ts | 138 +++++++++++----------- 2 files changed, 86 insertions(+), 83 deletions(-) diff --git a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md index 37c9993ba..f010deb38 100644 --- a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md +++ b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md @@ -153,8 +153,8 @@ Returns `void`. - `CompressedPerformerDetailsPanel` - `ConstantSetting` - `CountrySelect` -- `CustomFields` - `CustomFieldInput` +- `CustomFields` - `DateInput` - `DetailImage` - `ExternalLinkButtons` @@ -169,6 +169,9 @@ Returns `void`. - `GalleryIDSelect` - `GallerySelect` - `GallerySelect.sort` +- `GroupIDSelect` +- `GroupSelect` +- `GroupSelect.sort` - `HeaderImage` - `HoverPopover` - `Icon` @@ -176,10 +179,9 @@ Returns `void`. - `ImageInput` - `LightboxLink` - `LoadingIndicator` +- `MainNavBar.MenuItems` +- `MainNavBar.UtilityItems` - `ModalSetting` -- `GroupIDSelect` -- `GroupSelect` -- `GroupSelect.sort` - `NumberSetting` - `Pagination` - `PaginationIndex` @@ -192,16 +194,17 @@ Returns `void`. - `PerformerCard.Title` - `PerformerDetailsPanel` - `PerformerDetailsPanel.DetailGroup` -- `PerformerIDSelect` -- `PerformerPage` -- `PerformerSelect` -- `PerformerSelect.sort` - `PerformerGalleriesPanel` - `PerformerGroupsPanel` - `PerformerHeaderImage` +- `PerformerIDSelect` - `PerformerImagesPanel` +- `PerformerPage` - `PerformerScenesPanel` +- `PerformerSelect` +- `PerformerSelect.sort` - `PluginRoutes` +- `PluginSettings` - `RatingNumber` - `RatingStars` - `RatingSystem` @@ -210,18 +213,20 @@ Returns `void`. - `SceneCard.Image` - `SceneCard.Overlays` - `SceneCard.Popovers` +- `SceneFileInfoPanel` - `SceneIDSelect` - `ScenePage` -- `ScenePage.Tabs` - `ScenePage.TabContent` +- `ScenePage.Tabs` - `ScenePlayer` - `SceneSelect` - `SceneSelect.sort` - `SelectSetting` - `Setting` +- `SettingGroup` - `SettingModal` -- `StringSetting` - `StringListSetting` +- `StringSetting` - `StudioIDSelect` - `StudioSelect` - `StudioSelect.sort` @@ -233,15 +238,11 @@ Returns `void`. - `TagCard.Overlays` - `TagCard.Popovers` - `TagCard.Title` -- `TagLink` -- `TabTitleCounter` - `TagIDSelect` +- `TagLink` - `TagSelect` - `TagSelect.sort` - `TruncatedText` -- `PluginSettings` -- `Setting` -- `SettingGroup` ### `PluginApi.Event` diff --git a/ui/v2.5/src/pluginApi.d.ts b/ui/v2.5/src/pluginApi.d.ts index 2e6d23266..da4a64765 100644 --- a/ui/v2.5/src/pluginApi.d.ts +++ b/ui/v2.5/src/pluginApi.d.ts @@ -652,88 +652,90 @@ declare namespace PluginApi { }>; } const components: { - HoverPopover: React.FC; - TagLink: React.FC; - LoadingIndicator: React.FC; - Icon: React.FC; - "MainNavBar.MenuItems": React.FC; - "MainNavBar.UtilityItems": React.FC; - PerformerSelect: React.FC; - PerformerIDSelect: React.FC; - TagSelect: React.FC; - TagIDSelect: React.FC; - StudioSelect: React.FC; - StudioIDSelect: React.FC; - GallerySelect: React.FC; - GalleryIDSelect: React.FC; - GroupSelect: React.FC; - GroupIDSelect: React.FC; - SceneSelect: React.FC; - SceneIDSelect: React.FC; - DateInput: React.FC; - CountrySelect: React.FC; - FolderSelect: React.FC; - "SceneCard.Popovers": React.FC; - "SceneCard.Details": React.FC; - "SceneCard.Overlays": React.FC; - "SceneCard.Image": React.FC; - PluginSettings: React.FC; - Setting: React.FC; - SettingGroup: React.FC; + AlertModal: React.FC; + BackgroundImage: React.FC; BooleanSetting: React.FC; - SelectSetting: React.FC; ChangeButtonSetting: React.FC; - SettingModal: React.FC; - ModalSetting: React.FC; - StringSetting: React.FC; - NumberSetting: React.FC; - StringListSetting: React.FC; ConstantSetting: React.FC; - SceneFileInfoPanel: React.FC; - PerformerPage: React.FC; - PerformerAppearsWithPanel: React.FC; - PerformerGalleriesPanel: React.FC; - PerformerGroupsPanel: React.FC; - PerformerHeaderImage: React.FC; - PerformerScenesPanel: React.FC; - PerformerImagesPanel: React.FC; - TabTitleCounter: React.FC; - PerformerCard: React.FC; + CountrySelect: React.FC; + CustomFieldInput: React.FC; + CustomFields: React.FC; + DateInput: React.FC; + DetailImage: React.FC; ExternalLinkButtons: React.FC; ExternalLinksButton: React.FC; - CustomFields: React.FC; - CustomFieldInput: React.FC; - ImageInput: React.FC; - DetailImage: React.FC; - HeaderImage: React.FC; - LightboxLink: React.FC; - "PerformerCard.Popovers": React.FC; - "PerformerCard.Details": React.FC; - "PerformerCard.Overlays": React.FC; - "PerformerCard.Image": React.FC; - "PerformerCard.Title": React.FC; - "TagCard.Popovers": React.FC; - "TagCard.Details": React.FC; - "TagCard.Overlays": React.FC; - "TagCard.Image": React.FC; - "TagCard.Title": React.FC; - ScenePage: React.FC; - "ScenePage.Tabs": React.FC; - "ScenePage.TabContent": React.FC; - ScenePlayer: React.FC; + FolderSelect: React.FC; FrontPage: React.FC; GalleryCard: React.FC; "GalleryCard.Details": React.FC; "GalleryCard.Image": React.FC; "GalleryCard.Overlays": React.FC; "GalleryCard.Popovers": React.FC; - TruncatedText: React.FC; - SweatDrops: React.FC; - AlertModal: React.FC; - BackgroundImage: React.FC; + GalleryIDSelect: React.FC; + GallerySelect: React.FC; + GroupIDSelect: React.FC; + GroupSelect: React.FC; + HeaderImage: React.FC; + HoverPopover: React.FC; + Icon: React.FC; + ImageInput: React.FC; + LightboxLink: React.FC; + LoadingIndicator: React.FC; + "MainNavBar.MenuItems": React.FC; + "MainNavBar.UtilityItems": React.FC; + ModalSetting: React.FC; + NumberSetting: React.FC; + PerformerAppearsWithPanel: React.FC; + PerformerCard: React.FC; + "PerformerCard.Details": React.FC; + "PerformerCard.Image": React.FC; + "PerformerCard.Overlays": React.FC; + "PerformerCard.Popovers": React.FC; + "PerformerCard.Title": React.FC; + PerformerDetailsPanel: React.FC; + "PerformerDetailsPanel.DetailGroup": React.FC; + PerformerGalleriesPanel: React.FC; + PerformerGroupsPanel: React.FC; + PerformerHeaderImage: React.FC; + PerformerIDSelect: React.FC; + PerformerImagesPanel: React.FC; + PerformerPage: React.FC; + PerformerScenesPanel: React.FC; + PerformerSelect: React.FC; + PluginSettings: React.FC; RatingNumber: React.FC; RatingStars: React.FC; RatingSystem: React.FC; + SceneFileInfoPanel: React.FC; + SceneIDSelect: React.FC; + ScenePage: React.FC; + "ScenePage.TabContent": React.FC; + "ScenePage.Tabs": React.FC; + ScenePlayer: React.FC; + SceneSelect: React.FC; + "SceneCard.Details": React.FC; + "SceneCard.Image": React.FC; + "SceneCard.Overlays": React.FC; + "SceneCard.Popovers": React.FC; + SelectSetting: React.FC; + Setting: React.FC; + SettingGroup: React.FC; + SettingModal: React.FC; + StringListSetting: React.FC; + StringSetting: React.FC; + StudioIDSelect: React.FC; + StudioSelect: React.FC; + SweatDrops: React.FC; + TabTitleCounter: React.FC; + TagIDSelect: React.FC; + "TagCard.Details": React.FC; + "TagCard.Image": React.FC; + "TagCard.Overlays": React.FC; + "TagCard.Popovers": React.FC; + "TagCard.Title": React.FC; + TagLink: React.FC; + TagSelect: React.FC; + TruncatedText: React.FC; }; type PatchableComponentNames = keyof typeof components | string; namespace utils { From d98e9c661847ca771a49d282fb3580c1d043c0d5 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 2 Jul 2025 16:34:40 +1000 Subject: [PATCH 019/157] Show filter tags in scene list toolbar (#5969) * Add filter tags to toolbar * Show overflow control if filter tags overflow * Remove second set of filter tags from top of page * Add border around filter area --- ui/v2.5/src/components/List/FilterTags.tsx | 199 +++++++++++++++++++- ui/v2.5/src/components/List/styles.scss | 17 +- ui/v2.5/src/components/Scenes/SceneList.tsx | 32 ++-- ui/v2.5/src/components/Scenes/styles.scss | 39 +++- ui/v2.5/src/components/Shared/styles.scss | 8 + ui/v2.5/src/locales/en-GB.json | 3 +- 6 files changed, 273 insertions(+), 25 deletions(-) diff --git a/ui/v2.5/src/components/List/FilterTags.tsx b/ui/v2.5/src/components/List/FilterTags.tsx index b690f8781..8d9b24e40 100644 --- a/ui/v2.5/src/components/List/FilterTags.tsx +++ b/ui/v2.5/src/components/List/FilterTags.tsx @@ -1,11 +1,18 @@ -import React, { PropsWithChildren } from "react"; -import { Badge, BadgeProps, Button } from "react-bootstrap"; +import React, { + PropsWithChildren, + useEffect, + useLayoutEffect, + useReducer, + useRef, +} from "react"; +import { Badge, BadgeProps, Button, Overlay, Popover } from "react-bootstrap"; import { Criterion } from "src/models/list-filter/criteria/criterion"; import { FormattedMessage, useIntl } from "react-intl"; import { Icon } from "../Shared/Icon"; import { faTimes } from "@fortawesome/free-solid-svg-icons"; import { BsPrefixProps, ReplaceProps } from "react-bootstrap/esm/helpers"; import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields"; +import { useDebounce } from "src/hooks/debounce"; type TagItemProps = PropsWithChildren< ReplaceProps<"span", BsPrefixProps<"span"> & BadgeProps> @@ -41,11 +48,59 @@ export const FilterTag: React.FC<{ ); }; +const MoreFilterTags: React.FC<{ + tags: React.ReactNode[]; +}> = ({ tags }) => { + const [showTooltip, setShowTooltip] = React.useState(false); + const target = useRef(null); + + if (!tags.length) { + return null; + } + + function handleMouseEnter() { + setShowTooltip(true); + } + + function handleMouseLeave() { + setShowTooltip(false); + } + + return ( + <> + + + {tags} + + + + + + + ); +}; + interface IFilterTagsProps { criteria: Criterion[]; onEditCriterion: (c: Criterion) => void; onRemoveCriterion: (c: Criterion, valueIndex?: number) => void; onRemoveAll: () => void; + truncateOnOverflow?: boolean; } export const FilterTags: React.FC = ({ @@ -53,8 +108,117 @@ export const FilterTags: React.FC = ({ onEditCriterion, onRemoveCriterion, onRemoveAll, + truncateOnOverflow = false, }) => { const intl = useIntl(); + const ref = useRef(null); + + const [cutoff, setCutoff] = React.useState(); + const elementGap = 10; // Adjust this value based on your CSS gap or margin + const moreTagWidth = 80; // reserve space for the "more" tag + + const [, forceUpdate] = useReducer((x) => x + 1, 0); + + const debounceResetCutoff = useDebounce( + () => { + setCutoff(undefined); + // setting cutoff won't trigger a re-render if it's already undefined + // so we force a re-render to recalculate the cutoff + forceUpdate(); + }, + 100 // Adjust the debounce delay as needed + ); + + // trigger recalculation of cutoff when control resizes + useEffect(() => { + if (!truncateOnOverflow || !ref.current) { + return; + } + + const resizeObserver = new ResizeObserver(() => { + debounceResetCutoff(); + }); + + const { current } = ref; + resizeObserver.observe(current); + + return () => { + resizeObserver.disconnect(); + }; + }, [truncateOnOverflow, debounceResetCutoff]); + + // we need to check this on every render, and the call to setCutoff _should_ be safe + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + useLayoutEffect(() => { + if (!truncateOnOverflow) { + setCutoff(undefined); + return; + } + + const { current } = ref; + + if (current) { + // calculate the number of tags that can fit in the container + const containerWidth = current.clientWidth; + const children = Array.from(current.children); + + // don't recalculate anything if the more tag is visible and cutoff is already set + const moreTags = children.find((child) => { + return (child as HTMLElement).classList.contains("more-tags"); + }); + + if (moreTags && !!cutoff) { + return; + } + + const childTags = children.filter((child) => { + return ( + (child as HTMLElement).classList.contains("tag-item") || + (child as HTMLElement).classList.contains("clear-all-button") + ); + }); + + const clearAllButton = children.find((child) => { + return (child as HTMLElement).classList.contains("clear-all-button"); + }); + + // calculate the total width without the more tag + const defaultTotalWidth = childTags.reduce((total, child, idx) => { + return ( + total + + ((child as HTMLElement).offsetWidth ?? 0) + + (idx === childTags.length - 1 ? 0 : elementGap) + ); + }, 0); + + if (containerWidth >= defaultTotalWidth) { + // if the container is wide enough to fit all tags, reset cutoff + setCutoff(undefined); + return; + } + + let totalWidth = 0; + let visibleCount = 0; + + // reserve space for the more tags control + totalWidth += moreTagWidth; + + // reserve space for the clear all button if present + if (clearAllButton) { + totalWidth += (clearAllButton as HTMLElement).offsetWidth ?? 0; + } + + for (const child of children) { + totalWidth += ((child as HTMLElement).offsetWidth ?? 0) + elementGap; + if (totalWidth > containerWidth) { + break; + } + visibleCount++; + } + + setCutoff(visibleCount); + } + }); function onRemoveCriterionTag( criterion: Criterion, @@ -72,7 +236,7 @@ export const FilterTags: React.FC = ({ onEditCriterion(criterion); } - function renderFilterTags(criterion: Criterion) { + function getFilterTags(criterion: Criterion) { if ( criterion instanceof CustomFieldsCriterion && criterion.value.length > 1 @@ -105,9 +269,34 @@ export const FilterTags: React.FC = ({ return null; } + const className = "wrap-tags filter-tags"; + + const filterTags = criteria.map((c) => getFilterTags(c)).flat(); + + if (cutoff && filterTags.length > cutoff) { + const visibleCriteria = filterTags.slice(0, cutoff); + const hiddenCriteria = filterTags.slice(cutoff); + + return ( +
+ {visibleCriteria} + + {criteria.length >= 3 && ( + + )} +
+ ); + } + return ( -
- {criteria.map(renderFilterTags)} +
+ {filterTags} {criteria.length >= 3 && ( @@ -232,7 +232,7 @@ interface IPluginApi { - )} -
+ if (searchTerm && searchTerm.length > 0) { + filterTags.unshift( + + + {searchTerm} + + } + onClick={() => onEditSearchTerm?.()} + onRemove={() => onRemoveSearchTerm?.()} + /> ); } + const visibleCriteria = cutoff ? filterTags.slice(0, cutoff) : filterTags; + const hiddenCriteria = cutoff ? filterTags.slice(cutoff) : []; + return (
- {filterTags} - {criteria.length >= 3 && ( + {visibleCriteria} + + {filterTags.length >= 3 && (
@@ -520,6 +533,9 @@ export const FilteredSceneList = (props: IFilteredScenes) => { const intl = useIntl(); const history = useHistory(); + const searchFocus = useFocus(); + const [, setSearchFocus] = searchFocus; + const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props; // States @@ -774,6 +790,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => { sidebarOpen={showSidebar} onClose={() => setShowSidebar(false)} count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} />
@@ -783,6 +800,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => { })} > { onToggleSidebar={() => setShowSidebar(!showSidebar)} onEditCriterion={(c) => showEditFilter(c.criterionOption.type)} onRemoveCriterion={removeCriterion} - onRemoveAllCriterion={() => clearAllCriteria()} + onRemoveAllCriterion={() => clearAllCriteria(true)} + onEditSearchTerm={() => { + setShowSidebar(true); + setSearchFocus(true); + }} + onRemoveSearchTerm={() => setFilter(filter.clearSearchTerm())} onSelectAll={() => onSelectAll()} onSelectNone={() => onSelectNone()} onEdit={onEdit} diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index c013b852a..fb4d82a1c 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -698,8 +698,10 @@ div.dropdown-menu { } .tag-item { + align-items: center; background-color: $muted-gray; color: $dark-text; + display: flex; font-size: 12px; font-weight: 400; line-height: 16px; @@ -710,17 +712,20 @@ div.dropdown-menu { cursor: pointer; } + .search-term svg { + margin-left: 0; + } + .btn { background: none; border: none; bottom: 2px; color: $dark-text; font-size: 12px; - line-height: 1rem; + line-height: 16px; margin-right: -0.5rem; opacity: 0.5; padding: 0 0.5rem; - position: relative; &:active, &:hover { diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index ac9d9de1e..2a68cd6a2 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -476,13 +476,23 @@ export class ListFilterModel { return this.setCriteria(criteria); } - public clearCriteria() { + public clearCriteria(clearSearchTerm = false) { const ret = this.clone(); + if (clearSearchTerm) { + ret.searchTerm = ""; + } ret.criteria = []; ret.currentPage = 1; return ret; } + public clearSearchTerm() { + const ret = this.clone(); + ret.searchTerm = ""; + ret.currentPage = 1; // reset to first page + return ret; + } + public setCriteria(criteria: Criterion[]) { const ret = this.clone(); ret.criteria = criteria; diff --git a/ui/v2.5/src/utils/focus.ts b/ui/v2.5/src/utils/focus.ts index f1ede47f9..cf1b20a88 100644 --- a/ui/v2.5/src/utils/focus.ts +++ b/ui/v2.5/src/utils/focus.ts @@ -2,10 +2,14 @@ import { useRef, useEffect, useCallback } from "react"; const useFocus = () => { const htmlElRef = useRef(null); - const setFocus = useCallback(() => { + const setFocus = useCallback((selectAll?: boolean) => { const currentEl = htmlElRef.current; if (currentEl) { - currentEl.focus(); + if (selectAll) { + currentEl.select(); + } else { + currentEl.focus(); + } } }, []); From 14be3c24ffcba1f8d898a3bc42d1cec38992af5c Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 8 Jul 2025 13:12:46 +1000 Subject: [PATCH 027/157] Revert "Search term filter tag on scene list (#5991)" (#6003) This reverts commit 21ee88b149b303a64eefa39caea61524e0165156. --- ui/v2.5/src/components/List/FilterTags.tsx | 64 ++++++++----------- .../components/List/Filters/FilterSidebar.tsx | 13 +--- ui/v2.5/src/components/List/util.ts | 9 +-- ui/v2.5/src/components/Scenes/SceneList.tsx | 25 +------- ui/v2.5/src/index.scss | 9 +-- ui/v2.5/src/models/list-filter/filter.ts | 12 +--- ui/v2.5/src/utils/focus.ts | 8 +-- 7 files changed, 37 insertions(+), 103 deletions(-) diff --git a/ui/v2.5/src/components/List/FilterTags.tsx b/ui/v2.5/src/components/List/FilterTags.tsx index 6812edcf7..8d9b24e40 100644 --- a/ui/v2.5/src/components/List/FilterTags.tsx +++ b/ui/v2.5/src/components/List/FilterTags.tsx @@ -9,37 +9,31 @@ import { Badge, BadgeProps, Button, Overlay, Popover } from "react-bootstrap"; import { Criterion } from "src/models/list-filter/criteria/criterion"; import { FormattedMessage, useIntl } from "react-intl"; import { Icon } from "../Shared/Icon"; -import { faMagnifyingGlass, faTimes } from "@fortawesome/free-solid-svg-icons"; +import { faTimes } from "@fortawesome/free-solid-svg-icons"; import { BsPrefixProps, ReplaceProps } from "react-bootstrap/esm/helpers"; import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields"; import { useDebounce } from "src/hooks/debounce"; -import cx from "classnames"; type TagItemProps = PropsWithChildren< ReplaceProps<"span", BsPrefixProps<"span"> & BadgeProps> >; export const TagItem: React.FC = (props) => { - const { className, children, ...others } = props; + const { children } = props; return ( - + {children} ); }; export const FilterTag: React.FC<{ - className?: string; label: React.ReactNode; onClick: React.MouseEventHandler; onRemove: React.MouseEventHandler; -}> = ({ className, label, onClick, onRemove }) => { +}> = ({ label, onClick, onRemove }) => { return ( - + {label} + )} +
); } - const visibleCriteria = cutoff ? filterTags.slice(0, cutoff) : filterTags; - const hiddenCriteria = cutoff ? filterTags.slice(cutoff) : []; - return (
- {visibleCriteria} - - {filterTags.length >= 3 && ( + {filterTags} + {criteria.length >= 3 && (
@@ -533,9 +520,6 @@ export const FilteredSceneList = (props: IFilteredScenes) => { const intl = useIntl(); const history = useHistory(); - const searchFocus = useFocus(); - const [, setSearchFocus] = searchFocus; - const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props; // States @@ -790,7 +774,6 @@ export const FilteredSceneList = (props: IFilteredScenes) => { sidebarOpen={showSidebar} onClose={() => setShowSidebar(false)} count={cachedResult.loading ? undefined : totalCount} - focus={searchFocus} />
@@ -800,7 +783,6 @@ export const FilteredSceneList = (props: IFilteredScenes) => { })} > { onToggleSidebar={() => setShowSidebar(!showSidebar)} onEditCriterion={(c) => showEditFilter(c.criterionOption.type)} onRemoveCriterion={removeCriterion} - onRemoveAllCriterion={() => clearAllCriteria(true)} - onEditSearchTerm={() => { - setShowSidebar(true); - setSearchFocus(true); - }} - onRemoveSearchTerm={() => setFilter(filter.clearSearchTerm())} + onRemoveAllCriterion={() => clearAllCriteria()} onSelectAll={() => onSelectAll()} onSelectNone={() => onSelectNone()} onEdit={onEdit} diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index fb4d82a1c..c013b852a 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -698,10 +698,8 @@ div.dropdown-menu { } .tag-item { - align-items: center; background-color: $muted-gray; color: $dark-text; - display: flex; font-size: 12px; font-weight: 400; line-height: 16px; @@ -712,20 +710,17 @@ div.dropdown-menu { cursor: pointer; } - .search-term svg { - margin-left: 0; - } - .btn { background: none; border: none; bottom: 2px; color: $dark-text; font-size: 12px; - line-height: 16px; + line-height: 1rem; margin-right: -0.5rem; opacity: 0.5; padding: 0 0.5rem; + position: relative; &:active, &:hover { diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 2a68cd6a2..ac9d9de1e 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -476,23 +476,13 @@ export class ListFilterModel { return this.setCriteria(criteria); } - public clearCriteria(clearSearchTerm = false) { + public clearCriteria() { const ret = this.clone(); - if (clearSearchTerm) { - ret.searchTerm = ""; - } ret.criteria = []; ret.currentPage = 1; return ret; } - public clearSearchTerm() { - const ret = this.clone(); - ret.searchTerm = ""; - ret.currentPage = 1; // reset to first page - return ret; - } - public setCriteria(criteria: Criterion[]) { const ret = this.clone(); ret.criteria = criteria; diff --git a/ui/v2.5/src/utils/focus.ts b/ui/v2.5/src/utils/focus.ts index cf1b20a88..f1ede47f9 100644 --- a/ui/v2.5/src/utils/focus.ts +++ b/ui/v2.5/src/utils/focus.ts @@ -2,14 +2,10 @@ import { useRef, useEffect, useCallback } from "react"; const useFocus = () => { const htmlElRef = useRef(null); - const setFocus = useCallback((selectAll?: boolean) => { + const setFocus = useCallback(() => { const currentEl = htmlElRef.current; if (currentEl) { - if (selectAll) { - currentEl.select(); - } else { - currentEl.focus(); - } + currentEl.focus(); } }, []); From e23bdfa2046d682586d9bbded3de4ac4d2028866 Mon Sep 17 00:00:00 2001 From: feederbox826 <144178721+feederbox826@users.noreply.github.com> Date: Tue, 9 Sep 2025 01:03:55 -0400 Subject: [PATCH 028/157] Add media hardware key support (#6031) --- .../src/components/ScenePlayer/ScenePlayer.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 5749f6331..4440f80df 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -120,6 +120,22 @@ function handleHotkeys(player: VideoJsPlayer, event: videojs.KeyboardEvent) { return; } + const skipButtons = player.skipButtons(); + if (skipButtons) { + // handle multimedia keys + switch (event.key) { + case "MediaTrackNext": + if (!skipButtons.onNext) return; + skipButtons.onNext(); + break; + case "MediaTrackPrevious": + if (!skipButtons.onPrevious) return; + skipButtons.onPrevious(); + break; + // MediaPlayPause handled by videojs + } + } + switch (event.which) { case 32: // space case 13: // enter From c0ba119ebf94fddbc711f680e69e2b333781e3a8 Mon Sep 17 00:00:00 2001 From: feederbox826 <144178721+feederbox826@users.noreply.github.com> Date: Tue, 9 Sep 2025 01:04:39 -0400 Subject: [PATCH 029/157] exclude empty regex exclude (#6023) --- internal/manager/exclude_files.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/manager/exclude_files.go b/internal/manager/exclude_files.go index 6c5452d0d..7ab24b51c 100644 --- a/internal/manager/exclude_files.go +++ b/internal/manager/exclude_files.go @@ -60,6 +60,10 @@ func generateRegexps(patterns []string) []*regexp.Regexp { var fileRegexps []*regexp.Regexp for _, pattern := range patterns { + if pattern == "" || pattern == " " { + logger.Warnf("Skipping empty exclude pattern") + continue + } if !strings.HasPrefix(pattern, "(?i)") { pattern = "(?i)" + pattern } From b5b207c940b1bae137127dfeb3579e65a6a1d461 Mon Sep 17 00:00:00 2001 From: feederbox826 <144178721+feederbox826@users.noreply.github.com> Date: Tue, 9 Sep 2025 01:07:00 -0400 Subject: [PATCH 030/157] remove ruby and faraday gem (#6020) --- docker/ci/x86_64/Dockerfile | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docker/ci/x86_64/Dockerfile b/docker/ci/x86_64/Dockerfile index f0f1e242b..957da347c 100644 --- a/docker/ci/x86_64/Dockerfile +++ b/docker/ci/x86_64/Dockerfile @@ -12,9 +12,8 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-linux-arm32v6; \ FROM --platform=$TARGETPLATFORM alpine:latest AS app COPY --from=binary /stash /usr/bin/ -RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg ruby tzdata vips vips-tools \ - && pip install --user --break-system-packages mechanicalsoup cloudscraper stashapp-tools \ - && gem install faraday +RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg tzdata vips vips-tools \ + && pip install --user --break-system-packages mechanicalsoup cloudscraper stashapp-tools ENV STASH_CONFIG_FILE=/root/.stash/config.yml # Basic build-time metadata as defined at https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys From fd36c0fac7051ff6dae40f3808af4a6aefa143ed Mon Sep 17 00:00:00 2001 From: gregpetersonanon Date: Mon, 8 Sep 2025 22:10:13 -0700 Subject: [PATCH 031/157] Allow scan to continue when encountering an error (#6073) --- pkg/file/folder_rename_detect.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/file/folder_rename_detect.go b/pkg/file/folder_rename_detect.go index 4f6d31bd5..4c057461b 100644 --- a/pkg/file/folder_rename_detect.go +++ b/pkg/file/folder_rename_detect.go @@ -107,7 +107,8 @@ func (s *scanJob) detectFolderMove(ctx context.Context, file scanFile) (*models. info, err := d.Info() if err != nil { - return fmt.Errorf("reading info for %q: %w", path, err) + logger.Errorf("reading info for %q: %v", path, err) + return nil } if !s.acceptEntry(ctx, path, info) { From b1883f3df57d868ade3fe20b9339e39755b86acd Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 9 Sep 2025 16:44:51 +1000 Subject: [PATCH 032/157] Add gallery link to image lightbox (#6012) --- ui/v2.5/src/core/files.ts | 2 +- ui/v2.5/src/core/galleries.ts | 2 +- ui/v2.5/src/hooks/Lightbox/Lightbox.tsx | 30 ++++++++++++++++++++---- ui/v2.5/src/hooks/Lightbox/lightbox.scss | 17 +++++++++++++- ui/v2.5/src/hooks/Lightbox/types.ts | 12 ++++++++++ 5 files changed, 55 insertions(+), 8 deletions(-) diff --git a/ui/v2.5/src/core/files.ts b/ui/v2.5/src/core/files.ts index d17d34d16..b90d10193 100644 --- a/ui/v2.5/src/core/files.ts +++ b/ui/v2.5/src/core/files.ts @@ -6,7 +6,7 @@ export interface IFile { } interface IObjectWithFiles { - files?: IFile[]; + files?: GQL.Maybe; } export interface IObjectWithTitleFiles extends IObjectWithFiles { diff --git a/ui/v2.5/src/core/galleries.ts b/ui/v2.5/src/core/galleries.ts index bedc2453e..722ba8d3b 100644 --- a/ui/v2.5/src/core/galleries.ts +++ b/ui/v2.5/src/core/galleries.ts @@ -6,7 +6,7 @@ interface IFile { } interface IGallery { - files: IFile[]; + files: GQL.Maybe; folder?: GQL.Maybe; } diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index 0af1e835b..6e4eb856a 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -44,11 +44,13 @@ import { faSearchMinus, faTimes, faBars, + faImages, } from "@fortawesome/free-solid-svg-icons"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { useDebounce } from "../debounce"; import { isVideo } from "src/utils/visualFile"; import { imageTitle } from "src/core/files"; +import { galleryTitle } from "src/core/galleries"; const CLASSNAME = "Lightbox"; const CLASSNAME_HEADER = `${CLASSNAME}-header`; @@ -62,6 +64,8 @@ const CLASSNAME_OPTIONS_INLINE = `${CLASSNAME_OPTIONS}-inline`; const CLASSNAME_RIGHT = `${CLASSNAME_HEADER}-right`; const CLASSNAME_FOOTER = `${CLASSNAME}-footer`; const CLASSNAME_FOOTER_LEFT = `${CLASSNAME_FOOTER}-left`; +const CLASSNAME_FOOTER_CENTER = `${CLASSNAME_FOOTER}-center`; +const CLASSNAME_FOOTER_RIGHT = `${CLASSNAME_FOOTER}-right`; const CLASSNAME_DISPLAY = `${CLASSNAME}-display`; const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`; const CLASSNAME_INSTANT = `${CLASSNAME_CAROUSEL}-instant`; @@ -933,14 +937,30 @@ export const LightboxComponent: React.FC = ({ )}
-
+
{currentImage && ( - close()}> - {title ?? ""} - + <> + close()} + > + {title ?? ""} + + {currentImage.galleries?.length ? ( + close()} + > + + {galleryTitle(currentImage.galleries[0])} + + ) : null} + )}
-
+
); diff --git a/ui/v2.5/src/hooks/Lightbox/lightbox.scss b/ui/v2.5/src/hooks/Lightbox/lightbox.scss index b12de3cf9..95a5fbc42 100644 --- a/ui/v2.5/src/hooks/Lightbox/lightbox.scss +++ b/ui/v2.5/src/hooks/Lightbox/lightbox.scss @@ -105,10 +105,25 @@ padding-left: 1em; } + &-center { + display: flex; + flex-direction: column; + justify-content: center; + padding-left: 1em; + text-align: center; + } + a { color: $text-color; - font-weight: bold; text-decoration: none; + + .fa-icon { + margin-right: 0.5rem; + } + + &.image-link { + font-weight: bold; + } } } diff --git a/ui/v2.5/src/hooks/Lightbox/types.ts b/ui/v2.5/src/hooks/Lightbox/types.ts index 56c9d6b71..58cdc8434 100644 --- a/ui/v2.5/src/hooks/Lightbox/types.ts +++ b/ui/v2.5/src/hooks/Lightbox/types.ts @@ -14,6 +14,17 @@ interface IFiles { video_codec?: GQL.Maybe; } +interface IWithPath { + path: string; +} + +export interface IGallery { + id: string; + title?: GQL.Maybe; + files?: GQL.Maybe; + folder?: GQL.Maybe; +} + export interface ILightboxImage { id?: string; title?: GQL.Maybe; @@ -21,6 +32,7 @@ export interface ILightboxImage { o_counter?: GQL.Maybe; paths: IImagePaths; visual_files?: IFiles[]; + galleries?: GQL.Maybe; } export interface IChapter { From cc97e96eaad64bae50c013affe914fc5356b3935 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 9 Sep 2025 16:45:29 +1000 Subject: [PATCH 033/157] Add wall zoom functionality (#6011) * Show zoom slider when wall view active * Add zoom functionality to scene wall * Add zoom functionality to image wall * Add zoom functionality to gallery wall * Add zoom functionality for marker wall --- .../src/components/Galleries/GalleryList.tsx | 2 +- ui/v2.5/src/components/Galleries/styles.scss | 80 +++++++++++++------ ui/v2.5/src/components/Images/ImageList.tsx | 29 ++++++- .../src/components/List/ListViewOptions.tsx | 3 +- ui/v2.5/src/components/Scenes/SceneList.tsx | 8 +- .../src/components/Scenes/SceneMarkerList.tsx | 5 +- .../Scenes/SceneMarkerWallPanel.tsx | 26 +++++- .../src/components/Scenes/SceneWallPanel.tsx | 32 +++++++- 8 files changed, 148 insertions(+), 37 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index 7becbe93a..a0930b927 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -149,7 +149,7 @@ export const GalleryList: React.FC = ({ if (filter.displayMode === DisplayMode.Wall) { return (
-
+
{result.data.findGalleries.galleries.map((gallery) => ( ))} diff --git a/ui/v2.5/src/components/Galleries/styles.scss b/ui/v2.5/src/components/Galleries/styles.scss index 12439a94d..58116e936 100644 --- a/ui/v2.5/src/components/Galleries/styles.scss +++ b/ui/v2.5/src/components/Galleries/styles.scss @@ -206,7 +206,7 @@ $galleryTabWidth: 450px; } } -.GalleryWall { +div.GalleryWall { display: flex; flex-wrap: wrap; margin: 0 auto; @@ -249,28 +249,6 @@ $galleryTabWidth: 450px; z-index: 1; } - @mixin galleryWidth($width) { - height: math.div($width, 3) * 2; - - &-landscape { - width: $width; - } - - &-portrait { - width: math.div($width, 2); - } - } - - @media (min-width: 576px) { - @include galleryWidth(96vw); - } - @media (min-width: 768px) { - @include galleryWidth(48vw); - } - @media (min-width: 1200px) { - @include galleryWidth(32vw); - } - &-img { height: 100%; object-fit: cover; @@ -355,6 +333,62 @@ $galleryTabWidth: 450px; } } +div.GalleryWall { + @mixin galleryWidth($width) { + height: math.div($width, 3) * 2; + + &-landscape { + width: $width; + } + + &-portrait { + width: math.div($width, 2); + } + } + + .GalleryWallCard { + @media (min-width: 576px) { + @include galleryWidth(96vw); + } + } + + &.zoom-0 .GalleryWallCard { + @media (min-width: 768px) { + @include galleryWidth(16vw); + } + @media (min-width: 1200px) { + @include galleryWidth(10vw); + } + } + + &.zoom-1 .GalleryWallCard { + @media (min-width: 768px) { + @include galleryWidth(24vw); + } + @media (min-width: 1200px) { + @include galleryWidth(16vw); + } + } + + &.zoom-2 .GalleryWallCard { + @media (min-width: 768px) { + @include galleryWidth(32vw); + } + @media (min-width: 1200px) { + @include galleryWidth(24vw); + } + } + + &.zoom-3 .GalleryWallCard { + @media (min-width: 768px) { + @include galleryWidth(48vw); + } + @media (min-width: 1200px) { + @include galleryWidth(32vw); + } + } +} + .gallery-file-card.card { margin: 0; padding: 0; diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index 12eb264b1..a468c2815 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -35,9 +35,22 @@ interface IImageWallProps { currentPage: number; pageCount: number; handleImageOpen: (index: number) => void; + zoomIndex: number; } -const ImageWall: React.FC = ({ images, handleImageOpen }) => { +const zoomWidths = [280, 340, 480, 640]; +const breakpointZoomHeights = [ + { minWidth: 576, heights: [100, 120, 240, 360] }, + { minWidth: 768, heights: [120, 160, 240, 480] }, + { minWidth: 1200, heights: [120, 160, 240, 300] }, + { minWidth: 1400, heights: [160, 240, 300, 480] }, +]; + +const ImageWall: React.FC = ({ + images, + zoomIndex, + handleImageOpen, +}) => { const { configuration } = useContext(ConfigurationContext); const uiConfig = configuration?.ui; @@ -76,11 +89,21 @@ const ImageWall: React.FC = ({ images, handleImageOpen }) => { ); function columns(containerWidth: number) { - let preferredSize = 300; + let preferredSize = zoomWidths[zoomIndex]; let columnCount = containerWidth / preferredSize; return Math.round(columnCount); } + function targetRowHeight(containerWidth: number) { + let zoomHeight = 280; + breakpointZoomHeights.forEach((e) => { + if (containerWidth >= e.minWidth) { + zoomHeight = e.heights[zoomIndex]; + } + }); + return zoomHeight; + } + return (
{photos.length ? ( @@ -91,6 +114,7 @@ const ImageWall: React.FC = ({ images, handleImageOpen }) => { margin={uiConfig?.imageWallOptions?.margin!} direction={uiConfig?.imageWallOptions?.direction!} columns={columns} + targetRowHeight={targetRowHeight} /> ) : null}
@@ -211,6 +235,7 @@ const ImageListImages: React.FC = ({ currentPage={filter.currentPage} pageCount={pageCount} handleImageOpen={handleImageOpen} + zoomIndex={filter.zoomIndex} /> ); } diff --git a/ui/v2.5/src/components/List/ListViewOptions.tsx b/ui/v2.5/src/components/List/ListViewOptions.tsx index e83ff9290..1ea928983 100644 --- a/ui/v2.5/src/components/List/ListViewOptions.tsx +++ b/ui/v2.5/src/components/List/ListViewOptions.tsx @@ -130,7 +130,8 @@ export const ListViewOptions: React.FC = ({
{onSetZoom && zoomIndex !== undefined && - displayMode === DisplayMode.Grid ? ( + (displayMode === DisplayMode.Grid || + displayMode === DisplayMode.Wall) ? (
; + return ( + + ); } if (filter.displayMode === DisplayMode.Tagger) { return ; diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index f28ac718b..94eb6e133 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -95,7 +95,10 @@ export const SceneMarkerList: React.FC = ({ if (filter.displayMode === DisplayMode.Wall) { return ( - + ); } diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx index f240b36e6..5202b94d1 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx @@ -120,6 +120,7 @@ export const MarkerWallItem: React.FC> = ( interface IMarkerWallProps { markers: GQL.SceneMarkerDataFragment[]; + zoomIndex: number; } // HACK: typescript doesn't allow Gallery to accept a parameter for some reason @@ -152,9 +153,14 @@ function getDimensions(file?: IFile) { }; } -const defaultTargetRowHeight = 250; +const breakpointZoomHeights = [ + { minWidth: 576, heights: [100, 120, 240, 360] }, + { minWidth: 768, heights: [120, 160, 240, 480] }, + { minWidth: 1200, heights: [120, 160, 240, 300] }, + { minWidth: 1400, heights: [160, 240, 300, 480] }, +]; -const MarkerWall: React.FC = ({ markers }) => { +const MarkerWall: React.FC = ({ markers, zoomIndex }) => { const history = useHistory(); const margin = 3; @@ -202,6 +208,16 @@ const MarkerWall: React.FC = ({ markers }) => { return Math.round(columnCount); } + function targetRowHeight(containerWidth: number) { + let zoomHeight = 280; + breakpointZoomHeights.forEach((e) => { + if (containerWidth >= e.minWidth) { + zoomHeight = e.heights[zoomIndex]; + } + }); + return zoomHeight; + } + const renderImage = useCallback((props: RenderImageProps) => { return ; }, []); @@ -216,7 +232,7 @@ const MarkerWall: React.FC = ({ markers }) => { margin={margin} direction={direction} columns={columns} - targetRowHeight={defaultTargetRowHeight} + targetRowHeight={targetRowHeight} /> ) : null}
@@ -225,10 +241,12 @@ const MarkerWall: React.FC = ({ markers }) => { interface IMarkerWallPanelProps { markers: GQL.SceneMarkerDataFragment[]; + zoomIndex: number; } export const MarkerWallPanel: React.FC = ({ markers, + zoomIndex, }) => { - return ; + return ; }; diff --git a/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx index 546a1488a..dcc0bf734 100644 --- a/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx @@ -126,14 +126,24 @@ function getDimensions(s: GQL.SlimSceneDataFragment) { interface ISceneWallProps { scenes: GQL.SlimSceneDataFragment[]; sceneQueue?: SceneQueue; + zoomIndex: number; } // HACK: typescript doesn't allow Gallery to accept a parameter for some reason const SceneGallery = Gallery as unknown as GalleryI; -const defaultTargetRowHeight = 250; +const breakpointZoomHeights = [ + { minWidth: 576, heights: [100, 120, 240, 360] }, + { minWidth: 768, heights: [120, 160, 240, 480] }, + { minWidth: 1200, heights: [120, 160, 240, 300] }, + { minWidth: 1400, heights: [160, 240, 300, 480] }, +]; -const SceneWall: React.FC = ({ scenes, sceneQueue }) => { +const SceneWall: React.FC = ({ + scenes, + sceneQueue, + zoomIndex, +}) => { const history = useHistory(); const margin = 3; @@ -186,6 +196,16 @@ const SceneWall: React.FC = ({ scenes, sceneQueue }) => { return Math.round(columnCount); } + function targetRowHeight(containerWidth: number) { + let zoomHeight = 280; + breakpointZoomHeights.forEach((e) => { + if (containerWidth >= e.minWidth) { + zoomHeight = e.heights[zoomIndex]; + } + }); + return zoomHeight; + } + const renderImage = useCallback((props: RenderImageProps) => { return ; }, []); @@ -200,7 +220,7 @@ const SceneWall: React.FC = ({ scenes, sceneQueue }) => { margin={margin} direction={direction} columns={columns} - targetRowHeight={defaultTargetRowHeight} + targetRowHeight={targetRowHeight} /> ) : null}
@@ -210,11 +230,15 @@ const SceneWall: React.FC = ({ scenes, sceneQueue }) => { interface ISceneWallPanelProps { scenes: GQL.SlimSceneDataFragment[]; sceneQueue?: SceneQueue; + zoomIndex: number; } export const SceneWallPanel: React.FC = ({ scenes, sceneQueue, + zoomIndex, }) => { - return ; + return ( + + ); }; From 12c4e1f61c49cd4e625a62e9bde7df9e02c0c47c Mon Sep 17 00:00:00 2001 From: Otter Bot Society <76196097+OtterBotSociety@users.noreply.github.com> Date: Mon, 8 Sep 2025 23:48:16 -0700 Subject: [PATCH 034/157] Treat images with no exif metadata as well-oriented (#6006) --- pkg/file/image/orientation.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/file/image/orientation.go b/pkg/file/image/orientation.go index 84f5774cf..0d9ebb2e3 100644 --- a/pkg/file/image/orientation.go +++ b/pkg/file/image/orientation.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "io" + "strings" "github.com/rwcarlsen/goexif/exif" "github.com/stashapp/stash/pkg/logger" @@ -33,7 +34,7 @@ func areDimensionsFlipped(fs models.FS, path string) (bool, error) { x, err := exif.Decode(r) if err != nil { - if errors.Is(err, io.EOF) { + if errors.Is(err, io.EOF) || strings.Contains(err.Error(), "failed to find exif") { // no exif data return false, nil } From edcc4e896892aba93484e929d833e1bec5eab627 Mon Sep 17 00:00:00 2001 From: feederbox826 <144178721+feederbox826@users.noreply.github.com> Date: Tue, 16 Sep 2025 00:25:22 -0400 Subject: [PATCH 035/157] Fix descender line-height (#6087) --- ui/v2.5/src/index.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index c013b852a..d73cd3b2a 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -1411,3 +1411,8 @@ select { overflow-y: auto; padding-right: 1.5rem; } + +// Fix descenders clipping in line-height #6047 +h3 .TruncatedText { + line-height: 1.5; +} From 98716d556819974970c9c124e7ab0355af551fc4 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 17 Sep 2025 14:41:48 +1000 Subject: [PATCH 036/157] Show search field always (#6079) --- .../components/List/Filters/FilterSidebar.tsx | 14 ------ ui/v2.5/src/components/Scenes/SceneList.tsx | 49 ++++++++++++------- ui/v2.5/src/components/Scenes/styles.scss | 30 +++++++++++- ui/v2.5/src/components/Shared/styles.scss | 2 - ui/v2.5/src/index.scss | 2 + 5 files changed, 62 insertions(+), 35 deletions(-) diff --git a/ui/v2.5/src/components/List/Filters/FilterSidebar.tsx b/ui/v2.5/src/components/List/Filters/FilterSidebar.tsx index 0ff201f86..8f76c83e2 100644 --- a/ui/v2.5/src/components/List/Filters/FilterSidebar.tsx +++ b/ui/v2.5/src/components/List/Filters/FilterSidebar.tsx @@ -68,20 +68,6 @@ export function useFilteredSidebarKeybinds(props: { }) { const { showSidebar, setShowSidebar } = props; - // Show the sidebar when the user presses the "/" key - useEffect(() => { - Mousetrap.bind("/", (e) => { - if (!showSidebar) { - setShowSidebar(true); - e.preventDefault(); - } - }); - - return () => { - Mousetrap.unbind("/"); - }; - }, [showSidebar, setShowSidebar]); - // Hide the sidebar when the user presses the "Esc" key useEffect(() => { Mousetrap.bind("esc", (e) => { diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index a59d2c395..07fc04119 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -61,7 +61,11 @@ import { Button, ButtonGroup, ButtonToolbar } from "react-bootstrap"; import { FilterButton } from "../List/Filters/FilterButton"; import { Icon } from "../Shared/Icon"; import { ListViewOptions } from "../List/ListViewOptions"; -import { PageSizeSelector, SortBySelect } from "../List/ListFilter"; +import { + PageSizeSelector, + SearchTermInput, + SortBySelect, +} from "../List/ListFilter"; import { Criterion } from "src/models/list-filter/criteria/criterion"; function renderMetadataByline(result: GQL.FindScenesQueryResult) { @@ -332,11 +336,12 @@ interface IOperations { } const ListToolbarContent: React.FC<{ - criteria: Criterion[]; + filter: ListFilterModel; items: GQL.SlimSceneDataFragment[]; selectedIds: Set; operations: IOperations[]; onToggleSidebar: () => void; + onSetFilter: (filter: ListFilterModel) => void; onEditCriterion: (c: Criterion) => void; onRemoveCriterion: (criterion: Criterion, valueIndex?: number) => void; onRemoveAllCriterion: () => void; @@ -347,11 +352,12 @@ const ListToolbarContent: React.FC<{ onPlay: () => void; onCreateNew: () => void; }> = ({ - criteria, + filter, items, selectedIds, operations, onToggleSidebar, + onSetFilter, onEditCriterion, onRemoveCriterion, onRemoveAllCriterion, @@ -364,25 +370,31 @@ const ListToolbarContent: React.FC<{ }) => { const intl = useIntl(); + const { criteria } = filter; const hasSelection = selectedIds.size > 0; return ( <> {!hasSelection && ( -
- onToggleSidebar()} - count={criteria.length} - title={intl.formatMessage({ id: "actions.sidebar.toggle" })} - /> - -
+ <> +
+ +
+
+ onToggleSidebar()} + count={criteria.length} + title={intl.formatMessage({ id: "actions.sidebar.toggle" })} + /> + +
+ )} {hasSelection && (
@@ -789,7 +801,8 @@ export const FilteredSceneList = (props: IFilteredScenes) => { })} > div:first-child { + > div.filter-section { border: 1px solid $secondary; border-radius: 0.25rem; flex-grow: 1; @@ -1075,6 +1075,23 @@ input[type="range"].blue-slider { } } + .search-container { + border-right: 1px solid $secondary; + display: block; + margin-right: -0.5rem; + padding-right: 10px; + width: calc($sidebar-width - 15px); + + .search-term-input { + margin-right: 0; + width: 100%; + + .clearable-text-field { + height: 100%; + } + } + } + .filter-tags { flex-grow: 1; flex-wrap: nowrap; @@ -1093,6 +1110,17 @@ input[type="range"].blue-slider { } } +@include media-breakpoint-up(xl) { + .sidebar-pane:not(.hide-sidebar) .scene-list-toolbar .search-container { + display: none; + } +} +@include media-breakpoint-down(md) { + .sidebar-pane.hide-sidebar .scene-list-toolbar .search-container { + display: none; + } +} + .scene-list-header { flex-wrap: wrap-reverse; gap: 0.5rem; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 10e381bd8..881018566 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -755,8 +755,6 @@ button.btn.favorite-button { } } -$sidebar-width: 250px; - .sidebar-pane { display: flex; diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index d73cd3b2a..50768d1df 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -5,6 +5,8 @@ $navbar-height: 48.75px; $sticky-detail-header-height: 50px; +$sidebar-width: 250px; + @import "styles/theme"; @import "styles/range"; @import "styles/scrollbars"; From 8012f2eb8a4ec16721dd94c8c426edb9fa5407a5 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 17 Sep 2025 15:08:47 +1000 Subject: [PATCH 037/157] Add search term input to edit filter dialog (#6082) --- .../src/components/List/EditFilterDialog.tsx | 10 +++++++ ui/v2.5/src/components/List/styles.scss | 26 +++++++++++++++++++ ui/v2.5/src/locales/en-GB.json | 1 + 3 files changed, 37 insertions(+) diff --git a/ui/v2.5/src/components/List/EditFilterDialog.tsx b/ui/v2.5/src/components/List/EditFilterDialog.tsx index 4b31ac31a..1ee9e8364 100644 --- a/ui/v2.5/src/components/List/EditFilterDialog.tsx +++ b/ui/v2.5/src/components/List/EditFilterDialog.tsx @@ -34,6 +34,7 @@ import { FilterMode } from "src/core/generated-graphql"; import { useFocusOnce } from "src/utils/focus"; import Mousetrap from "mousetrap"; import ScreenUtils from "src/utils/screen"; +import { SearchTermInput } from "./ListFilter"; interface ICriterionList { criteria: string[]; @@ -453,6 +454,15 @@ export const EditFilterDialog: React.FC = ({ "criterion-selected": !!criterion, })} > +
+ + + + +
span { + width: 100%; + } + + .search-term-input { + flex-basis: 100%; + } + } + } + .filter-tags { border-top: 1px solid rgb(16 22 26 / 40%); padding: 1rem 1rem 0 1rem; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index bd6308425..2faca723d 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1317,6 +1317,7 @@ "edit_filter": "Edit Filter", "name": "Filter", "saved_filters": "Saved filters", + "search_term": "Search term", "update_filter": "Update Filter", "more_filter_criteria": "+{count} more" }, From 793a5f826e6612011121df4d7e01d9513f64793e Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 18 Sep 2025 12:09:19 +1000 Subject: [PATCH 038/157] Edit filter load save (#6092) * Add load/save buttons to edit filter dialog * Add title to save filter dialog * Change ExistingSavedFilterList parameters * Add title to load/save buttons --- .../src/components/List/EditFilterDialog.tsx | 112 ++++++++++++++++-- .../src/components/List/SavedFilterList.tsx | 103 ++++++++++++---- ui/v2.5/src/components/List/styles.scss | 8 ++ ui/v2.5/src/locales/en-GB.json | 2 + 4 files changed, 192 insertions(+), 33 deletions(-) diff --git a/ui/v2.5/src/components/List/EditFilterDialog.tsx b/ui/v2.5/src/components/List/EditFilterDialog.tsx index 1ee9e8364..e914b194e 100644 --- a/ui/v2.5/src/components/List/EditFilterDialog.tsx +++ b/ui/v2.5/src/components/List/EditFilterDialog.tsx @@ -29,11 +29,15 @@ import { import { useCompare, usePrevious } from "src/hooks/state"; import { CriterionType } from "src/models/list-filter/types"; import { useToast } from "src/hooks/Toast"; -import { useConfigureUI } from "src/core/StashService"; -import { FilterMode } from "src/core/generated-graphql"; +import { useConfigureUI, useSaveFilter } from "src/core/StashService"; +import { + FilterMode, + SavedFilterDataFragment, +} from "src/core/generated-graphql"; import { useFocusOnce } from "src/utils/focus"; import Mousetrap from "mousetrap"; import ScreenUtils from "src/utils/screen"; +import { LoadFilterDialog, SaveFilterDialog } from "./SavedFilterList"; import { SearchTermInput } from "./ListFilter"; interface ICriterionList { @@ -232,6 +236,13 @@ export const EditFilterDialog: React.FC = ({ const [searchRef, setSearchFocus] = useFocusOnce(!ScreenUtils.isTouch()); + const [showSaveDialog, setShowSaveDialog] = useState(false); + const [savingFilter, setSavingFilter] = useState(false); + + const [showLoadDialog, setShowLoadDialog] = useState(false); + + const saveFilter = useSaveFilter(); + const { criteria } = currentFilter; const criteriaList = useMemo(() => { @@ -433,9 +444,74 @@ export const EditFilterDialog: React.FC = ({ setCurrentFilter(newFilter); } + function onLoadFilter(f: SavedFilterDataFragment) { + const newFilter = filter.clone(); + + newFilter.currentPage = 1; + // #1795 - reset search term if not present in saved filter + newFilter.searchTerm = ""; + newFilter.configureFromSavedFilter(f); + // #1507 - reset random seed when loaded + newFilter.randomSeed = -1; + + onApply(newFilter); + } + + async function onSaveFilter(name: string, id?: string) { + try { + setSavingFilter(true); + await saveFilter(filter, name, id); + + Toast.success( + intl.formatMessage( + { + id: "toast.saved_entity", + }, + { + entity: intl.formatMessage({ id: "filter" }).toLocaleLowerCase(), + } + ) + ); + setShowSaveDialog(false); + onApply(currentFilter); + } catch (err) { + Toast.error(err); + } finally { + setSavingFilter(false); + } + } + return ( <> - onCancel()} className="edit-filter-dialog"> + {showSaveDialog && ( + { + if (name) { + onSaveFilter(name, id); + } else { + setShowSaveDialog(false); + } + }} + isSaving={savingFilter} + /> + )} + {showLoadDialog && ( + { + if (f) { + onLoadFilter(f); + } + setShowLoadDialog(false); + }} + /> + )} + onCancel()} + className="edit-filter-dialog" + >
@@ -487,12 +563,30 @@ export const EditFilterDialog: React.FC = ({
- - +
+ + +
+
+ + +
diff --git a/ui/v2.5/src/components/List/SavedFilterList.tsx b/ui/v2.5/src/components/List/SavedFilterList.tsx index cbeeaa70a..7e03404d2 100644 --- a/ui/v2.5/src/components/List/SavedFilterList.tsx +++ b/ui/v2.5/src/components/List/SavedFilterList.tsx @@ -30,12 +30,14 @@ import { faBookmark, faSave, faTimes } from "@fortawesome/free-solid-svg-icons"; import { AlertModal } from "../Shared/Alert"; import cx from "classnames"; import { TruncatedInlineText } from "../Shared/TruncatedText"; +import { OperationButton } from "../Shared/OperationButton"; const ExistingSavedFilterList: React.FC<{ name: string; - setName: (name: string) => void; - existing: { name: string; id: string }[]; -}> = ({ name, setName, existing }) => { + onSelect: (value: SavedFilterDataFragment) => void; + savedFilters: SavedFilterDataFragment[]; + disabled?: boolean; +}> = ({ name, onSelect, savedFilters: existing, disabled = false }) => { const filtered = useMemo(() => { if (!name) return existing; @@ -51,7 +53,8 @@ const ExistingSavedFilterList: React.FC<{ @@ -64,7 +67,8 @@ const ExistingSavedFilterList: React.FC<{ export const SaveFilterDialog: React.FC<{ mode: FilterMode; onClose: (name?: string, id?: string) => void; -}> = ({ mode, onClose }) => { + isSaving?: boolean; +}> = ({ mode, onClose, isSaving = false }) => { const intl = useIntl(); const [filterName, setFilterName] = useState(""); @@ -79,6 +83,74 @@ export const SaveFilterDialog: React.FC<{ return ( + + + + + + + + + setFilterName(e.target.value)} + disabled={isSaving} + /> + + + setFilterName(f.name)} + savedFilters={data?.findSavedFilters ?? []} + /> + + {!!overwritingFilter && ( + + + + )} + + + + onClose(filterName, overwritingFilter?.id)} + > + {intl.formatMessage({ id: "actions.save" })} + + + + ); +}; + +export const LoadFilterDialog: React.FC<{ + mode: FilterMode; + onClose: (filter?: SavedFilterDataFragment) => void; +}> = ({ mode, onClose }) => { + const intl = useIntl(); + const [filterName, setFilterName] = useState(""); + + const { data } = useFindSavedFilters(mode); + + return ( + + + + @@ -94,31 +166,14 @@ export const SaveFilterDialog: React.FC<{ onClose(f)} + savedFilters={data?.findSavedFilters ?? []} /> - - {!!overwritingFilter && ( - - - - )} - ); diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 723f48bbc..96a1e4c26 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -312,6 +312,14 @@ input[type="range"].zoom-slider { padding-right: 0; } + .modal-footer { + justify-content: space-between; + + > div > :not(:first-child) { + margin-left: 0.25rem; + } + } + .search-term-row { align-items: center; display: flex; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 2faca723d..a8d731b32 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -68,6 +68,8 @@ "ignore": "Ignore", "import": "Import…", "import_from_file": "Import from file", + "load": "Load", + "load_filter": "Load filter", "logout": "Log out", "make_primary": "Make Primary", "merge": "Merge", From 3bb771a149598d5bf17020e6bd86bf36669bd8e4 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 24 Sep 2025 10:45:09 +1000 Subject: [PATCH 039/157] Add search term filter tag to scene list filter tags (#6095) * Add search term to filter tags on scene list page Clicking on the tag selects all on the search term input. Clicking on the x erases it. * Ensure clear criteria maintains consistent behaviour on other pages * Hide search term tag when input is visible --- ui/v2.5/src/components/List/FilterTags.tsx | 64 +++++++++++-------- .../components/List/Filters/FilterSidebar.tsx | 13 +++- ui/v2.5/src/components/List/util.ts | 9 ++- ui/v2.5/src/components/Scenes/SceneList.tsx | 24 ++++++- ui/v2.5/src/components/Scenes/styles.scss | 15 +++++ ui/v2.5/src/index.scss | 9 ++- ui/v2.5/src/models/list-filter/filter.ts | 12 +++- ui/v2.5/src/utils/focus.ts | 8 ++- 8 files changed, 116 insertions(+), 38 deletions(-) diff --git a/ui/v2.5/src/components/List/FilterTags.tsx b/ui/v2.5/src/components/List/FilterTags.tsx index 8d9b24e40..6812edcf7 100644 --- a/ui/v2.5/src/components/List/FilterTags.tsx +++ b/ui/v2.5/src/components/List/FilterTags.tsx @@ -9,31 +9,37 @@ import { Badge, BadgeProps, Button, Overlay, Popover } from "react-bootstrap"; import { Criterion } from "src/models/list-filter/criteria/criterion"; import { FormattedMessage, useIntl } from "react-intl"; import { Icon } from "../Shared/Icon"; -import { faTimes } from "@fortawesome/free-solid-svg-icons"; +import { faMagnifyingGlass, faTimes } from "@fortawesome/free-solid-svg-icons"; import { BsPrefixProps, ReplaceProps } from "react-bootstrap/esm/helpers"; import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields"; import { useDebounce } from "src/hooks/debounce"; +import cx from "classnames"; type TagItemProps = PropsWithChildren< ReplaceProps<"span", BsPrefixProps<"span"> & BadgeProps> >; export const TagItem: React.FC = (props) => { - const { children } = props; + const { className, children, ...others } = props; return ( - + {children} ); }; export const FilterTag: React.FC<{ + className?: string; label: React.ReactNode; onClick: React.MouseEventHandler; onRemove: React.MouseEventHandler; -}> = ({ label, onClick, onRemove }) => { +}> = ({ className, label, onClick, onRemove }) => { return ( - + {label} - )} -
+ if (searchTerm && searchTerm.length > 0) { + filterTags.unshift( + + + {searchTerm} + + } + onClick={() => onEditSearchTerm?.()} + onRemove={() => onRemoveSearchTerm?.()} + /> ); } + const visibleCriteria = cutoff ? filterTags.slice(0, cutoff) : filterTags; + const hiddenCriteria = cutoff ? filterTags.slice(cutoff) : []; + return (
- {filterTags} - {criteria.length >= 3 && ( + {visibleCriteria} + + {filterTags.length >= 3 && (
@@ -538,6 +549,9 @@ export const FilteredSceneList = (props: IFilteredScenes) => { const intl = useIntl(); const history = useHistory(); + const searchFocus = useFocus(); + const [, setSearchFocus] = searchFocus; + const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props; // States @@ -792,6 +806,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => { sidebarOpen={showSidebar} onClose={() => setShowSidebar(false)} count={cachedResult.loading ? undefined : totalCount} + focus={searchFocus} />
@@ -809,7 +824,12 @@ export const FilteredSceneList = (props: IFilteredScenes) => { onToggleSidebar={() => setShowSidebar(!showSidebar)} onEditCriterion={(c) => showEditFilter(c.criterionOption.type)} onRemoveCriterion={removeCriterion} - onRemoveAllCriterion={() => clearAllCriteria()} + onRemoveAllCriterion={() => clearAllCriteria(true)} + onEditSearchTerm={() => { + setShowSidebar(true); + setSearchFocus(true); + }} + onRemoveSearchTerm={() => setFilter(filter.clearSearchTerm())} onSelectAll={() => onSelectAll()} onSelectNone={() => onSelectNone()} onEdit={onEdit} diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index 679cf84b5..09b8d5ea7 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -1121,6 +1121,21 @@ input[type="range"].blue-slider { } } +// hide the search term tag item when the search box is visible +@include media-breakpoint-up(lg) { + .scene-list-toolbar .filter-tags .search-term-filter-tag { + display: none; + } +} +@include media-breakpoint-down(md) { + .sidebar-pane:not(.hide-sidebar) + .scene-list-toolbar + .filter-tags + .search-term-filter-tag { + display: none; + } +} + .scene-list-header { flex-wrap: wrap-reverse; gap: 0.5rem; diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 50768d1df..4209abc2c 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -700,8 +700,10 @@ div.dropdown-menu { } .tag-item { + align-items: center; background-color: $muted-gray; color: $dark-text; + display: inline-flex; font-size: 12px; font-weight: 400; line-height: 16px; @@ -712,17 +714,20 @@ div.dropdown-menu { cursor: pointer; } + .search-term svg { + margin-left: 0; + } + .btn { background: none; border: none; bottom: 2px; color: $dark-text; font-size: 12px; - line-height: 1rem; + line-height: 16px; margin-right: -0.5rem; opacity: 0.5; padding: 0 0.5rem; - position: relative; &:active, &:hover { diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index ac9d9de1e..2a68cd6a2 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -476,13 +476,23 @@ export class ListFilterModel { return this.setCriteria(criteria); } - public clearCriteria() { + public clearCriteria(clearSearchTerm = false) { const ret = this.clone(); + if (clearSearchTerm) { + ret.searchTerm = ""; + } ret.criteria = []; ret.currentPage = 1; return ret; } + public clearSearchTerm() { + const ret = this.clone(); + ret.searchTerm = ""; + ret.currentPage = 1; // reset to first page + return ret; + } + public setCriteria(criteria: Criterion[]) { const ret = this.clone(); ret.criteria = criteria; diff --git a/ui/v2.5/src/utils/focus.ts b/ui/v2.5/src/utils/focus.ts index f1ede47f9..cf1b20a88 100644 --- a/ui/v2.5/src/utils/focus.ts +++ b/ui/v2.5/src/utils/focus.ts @@ -2,10 +2,14 @@ import { useRef, useEffect, useCallback } from "react"; const useFocus = () => { const htmlElRef = useRef(null); - const setFocus = useCallback(() => { + const setFocus = useCallback((selectAll?: boolean) => { const currentEl = htmlElRef.current; if (currentEl) { - currentEl.focus(); + if (selectAll) { + currentEl.select(); + } else { + currentEl.focus(); + } } }, []); From 823ed346c1e47673b79ee0f653cf5d74638eab0f Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 24 Sep 2025 11:27:08 +1000 Subject: [PATCH 040/157] Add separate sidebar toggle button (#6077) * Move sidebar toggle to right. Change icon * Show sidebar button on selection * Fix clicking toggle cycling visibility on smaller views * Show more tags component when cutoff == 0 * Hide filter/filter icon buttons in certain situations * Move sidebar toggle to left on xl viewports --- ui/v2.5/src/components/List/FilterTags.tsx | 7 +-- ui/v2.5/src/components/Scenes/SceneList.tsx | 21 ++++++-- ui/v2.5/src/components/Scenes/styles.scss | 53 ++++++++++++++++++--- 3 files changed, 67 insertions(+), 14 deletions(-) diff --git a/ui/v2.5/src/components/List/FilterTags.tsx b/ui/v2.5/src/components/List/FilterTags.tsx index 6812edcf7..0e4259838 100644 --- a/ui/v2.5/src/components/List/FilterTags.tsx +++ b/ui/v2.5/src/components/List/FilterTags.tsx @@ -179,7 +179,7 @@ export const FilterTags: React.FC = ({ return (child as HTMLElement).classList.contains("more-tags"); }); - if (moreTags && !!cutoff) { + if (moreTags && cutoff !== undefined) { return; } @@ -302,8 +302,9 @@ export const FilterTags: React.FC = ({ ); } - const visibleCriteria = cutoff ? filterTags.slice(0, cutoff) : filterTags; - const hiddenCriteria = cutoff ? filterTags.slice(cutoff) : []; + const visibleCriteria = + cutoff !== undefined ? filterTags.slice(0, cutoff) : filterTags; + const hiddenCriteria = cutoff !== undefined ? filterTags.slice(cutoff) : []; return (
diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 8956fd730..6e8832105 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -23,6 +23,7 @@ import { faPencil, faPlay, faPlus, + faSliders, faTimes, faTrash, } from "@fortawesome/free-solid-svg-icons"; @@ -346,7 +347,7 @@ const ListToolbarContent: React.FC<{ operations: IOperations[]; onToggleSidebar: () => void; onSetFilter: (filter: ListFilterModel) => void; - onEditCriterion: (c: Criterion) => void; + onEditCriterion: (c?: Criterion) => void; onRemoveCriterion: (criterion: Criterion, valueIndex?: number) => void; onRemoveAllCriterion: () => void; onEditSearchTerm: () => void; @@ -381,6 +382,17 @@ const ListToolbarContent: React.FC<{ const { criteria, searchTerm } = filter; const hasSelection = selectedIds.size > 0; + const sidebarToggle = ( + + ); + return ( <> {!hasSelection && ( @@ -390,9 +402,8 @@ const ListToolbarContent: React.FC<{
onToggleSidebar()} + onClick={() => onEditCriterion()} count={criteria.length} - title={intl.formatMessage({ id: "actions.sidebar.toggle" })} /> + {sidebarToggle}
)} @@ -421,6 +433,7 @@ const ListToolbarContent: React.FC<{ + {sidebarToggle}
)}
@@ -822,7 +835,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => { selectedIds={selectedIds} operations={otherOperations} onToggleSidebar={() => setShowSidebar(!showSidebar)} - onEditCriterion={(c) => showEditFilter(c.criterionOption.type)} + onEditCriterion={(c) => showEditFilter(c?.criterionOption.type)} onRemoveCriterion={removeCriterion} onRemoveAllCriterion={() => clearAllCriteria(true)} onEditSearchTerm={() => { diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index 09b8d5ea7..2259eab41 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -942,11 +942,6 @@ input[type="range"].blue-slider { transition-property: opacity; } -.scene-list:not(.hide-sidebar) .sidebar-toggle-button { - opacity: 0; - pointer-events: none; -} - .scene-wall, .marker-wall { .wall-item { @@ -1063,11 +1058,17 @@ input[type="range"].blue-slider { } } + .selected-items-info, > div.filter-section { border: 1px solid $secondary; border-radius: 0.25rem; flex-grow: 1; overflow-x: hidden; + } + + > div.filter-toolbar { + border: 1px solid $secondary; + border-radius: 0.25rem; .filter-button { border-bottom-right-radius: 0; @@ -1075,12 +1076,16 @@ input[type="range"].blue-slider { } } + .sidebar-toggle-button { + margin-left: auto; + } + .search-container { border-right: 1px solid $secondary; display: block; margin-right: -0.5rem; + min-width: calc($sidebar-width - 15px); padding-right: 10px; - width: calc($sidebar-width - 15px); .search-term-input { margin-right: 0; @@ -1097,7 +1102,9 @@ input[type="range"].blue-slider { flex-wrap: nowrap; justify-content: flex-start; margin-bottom: 0; - width: calc(100% - 35px - 0.5rem); + + // account for filter button, and toggle sidebar buttons with gaps + width: calc(100% - 70px - 1rem); @include media-breakpoint-down(xs) { overflow-x: auto; @@ -1121,6 +1128,38 @@ input[type="range"].blue-slider { } } +// hide Edit Filter button on larger screens +@include media-breakpoint-up(lg) { + .scene-list .sidebar .edit-filter-button { + display: none; + } +} + +// hide the filter icon button when sidebar is shown on smaller screens +@include media-breakpoint-down(md) { + .sidebar-pane:not(.hide-sidebar) .scene-list-toolbar .filter-button { + display: none; + } + + // adjust the width of the filter-tags as well + .sidebar-pane:not(.hide-sidebar) .scene-list-toolbar .filter-tags { + width: calc(100% - 35px - 0.5rem); + } +} + +// move the sidebar toggle to the left on xl viewports +@include media-breakpoint-up(xl) { + .scene-list .scene-list-toolbar .filter-section { + .sidebar-toggle-button { + margin-left: 0; + } + + .filter-tags { + order: 2; + } + } +} + // hide the search term tag item when the search box is visible @include media-breakpoint-up(lg) { .scene-list-toolbar .filter-tags .search-term-filter-tag { From acddf977715db549eb03fdd381512db3fd59c376 Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Thu, 25 Sep 2025 08:20:30 +0300 Subject: [PATCH 041/157] Refactor issue templates: replace markdown files with YAML configurations for bug reports, feature requests (#6102) --- .github/ISSUE_TEMPLATE/bug_report.md | 40 ------------ .github/ISSUE_TEMPLATE/bug_report.yml | 64 +++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 11 ++++ ...scussion---request-for-commentary--rfc-.md | 24 ------- .github/ISSUE_TEMPLATE/feature_request.md | 20 ------ .github/ISSUE_TEMPLATE/feature_request.yml | 44 +++++++++++++ 6 files changed, 119 insertions(+), 84 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/ISSUE_TEMPLATE/discussion---request-for-commentary--rfc-.md delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index ea06d6d43..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: "[Bug Report] Short Form Subject (50 Chars or less)" -labels: bug report -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem please ensure that your screenshots are SFW or at least appropriately censored. - -**Stash Version: (from Settings -> About):** - -**Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] - -**Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..0dc6d10a8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,64 @@ +name: Bug Report +description: Create a report to help us fix the bug +labels: ["bug report"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: textarea + id: description + attributes: + label: Describe the bug + description: Provide a clear and concise description of what the bug is. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: Detail the steps that would replicate this issue. + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behaviour + description: Provide clear and concise description of what you expected to happen. + validations: + required: true + - type: textarea + id: context + attributes: + label: Screenshots or additional context + description: Provide any additional context and SFW screenshots here to help us solve this issue. + validations: + required: false + - type: input + id: stashversion + attributes: + label: Stash version + description: This can be found in Settings > About. + placeholder: (e.g. v0.28.1) + validations: + required: true + - type: input + id: devicedetails + attributes: + label: Device details + description: | + If this is an issue that occurs when using the Stash interface, please provide details of the device/browser used which presents the reported issue. + placeholder: (e.g. Firefox 97 (64-bit) on Windows 11) + validations: + required: false + - type: textarea + id: logs + 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 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..028fdf8ac --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Community forum + url: https://discourse.stashapp.cc + about: Start a discussion on the community forum. + - name: Community Discord + url: https://discord.gg/Y8MNsvQBvZ + about: Chat with the community on Discord. + - name: Documentation + url: https://docs.stashapp.cc + about: Check out documentation for help and information. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/discussion---request-for-commentary--rfc-.md b/.github/ISSUE_TEMPLATE/discussion---request-for-commentary--rfc-.md deleted file mode 100644 index b79564f83..000000000 --- a/.github/ISSUE_TEMPLATE/discussion---request-for-commentary--rfc-.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -name: Discussion / Request for Commentary [RFC] -about: This is for issues that will be discussed and won't necessarily result directly - in commits or pull requests. -title: "[RFC] Short Form Title" -labels: help wanted -assignees: '' - ---- - - - -## Long Form - - -## Examples - - -## Reference Reading - diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index db5df9d8b..000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: "[Feature] Short Form Title (50 chars or less.)" -labels: feature request -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..f139433c5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,44 @@ +name: Feature Request +description: Request a new feature or idea to be added to Stash +labels: ["feature request"] +body: + - type: textarea + id: description + attributes: + label: Describe the feature you'd like + description: Provide a clear description of the feature you'd like implemented + validations: + required: true + - type: textarea + id: benefits + attributes: + label: Describe the benefits this would bring to existing users + description: | + Explain the measurable benefits this feature would achieve for existing users. + The benefits should be described in terms of outcomes for users, not specific implementations. + validations: + required: true + - type: textarea + id: already_possible + attributes: + label: Is there an existing way to achieve this goal? + description: | + Yes/No. If Yes, describe how your proposed feature differs from or improves upon the current method + validations: + required: true + - type: checkboxes + id: confirm-search + attributes: + label: Have you searched for an existing open/closed issue? + description: | + To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/stashapp/stash/issues?q=is%3Aissue) for any existing issues that cover the core request or benefit of your proposal. + options: + - label: I have searched for existing issues and none cover the core request of my proposal + required: true + - type: textarea + id: context + attributes: + label: Additional context + description: Add any other context or screenshots about the feature request here. + validations: + required: false \ No newline at end of file From 724d4387213254ad08f00f0fb7780afb30589920 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 25 Sep 2025 15:26:01 +1000 Subject: [PATCH 042/157] Wall item height fix (#6101) * Fix scene wall item height with fewer items * Fix for marker wall * Fix for image wall * Provide some allowance for items to go over height --- ui/v2.5/src/components/Images/ImageList.tsx | 49 +++++++++---- .../src/components/Images/ImageWallItem.tsx | 28 +++----- .../Scenes/SceneMarkerWallPanel.tsx | 69 +++++++++++++------ .../src/components/Scenes/SceneWallPanel.tsx | 69 +++++++++++++------ 4 files changed, 146 insertions(+), 69 deletions(-) diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index a468c2815..4149970b5 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -20,7 +20,7 @@ import { ImageWallItem } from "./ImageWallItem"; import { EditImagesDialog } from "./EditImagesDialog"; import { DeleteImagesDialog } from "./DeleteImagesDialog"; import "flexbin/flexbin.css"; -import Gallery from "react-photo-gallery"; +import Gallery, { RenderImageProps } from "react-photo-gallery"; import { ExportDialog } from "../Shared/ExportDialog"; import { objectTitle } from "src/core/files"; import { ConfigurationContext } from "src/hooks/Config"; @@ -54,6 +54,8 @@ const ImageWall: React.FC = ({ const { configuration } = useContext(ConfigurationContext); const uiConfig = configuration?.ui; + const containerRef = React.useRef(null); + let photos: { src: string; srcSet?: string | string[] | undefined; @@ -94,22 +96,45 @@ const ImageWall: React.FC = ({ return Math.round(columnCount); } - function targetRowHeight(containerWidth: number) { - let zoomHeight = 280; - breakpointZoomHeights.forEach((e) => { - if (containerWidth >= e.minWidth) { - zoomHeight = e.heights[zoomIndex]; - } - }); - return zoomHeight; - } + const targetRowHeight = useCallback( + (containerWidth: number) => { + let zoomHeight = 280; + breakpointZoomHeights.forEach((e) => { + if (containerWidth >= e.minWidth) { + zoomHeight = e.heights[zoomIndex]; + } + }); + return zoomHeight; + }, + [zoomIndex] + ); + + // set the max height as a factor of the targetRowHeight + // this allows some images to be taller than the target row height + // but prevents images from becoming too tall when there is a small number of items + const maxHeightFactor = 1.3; + + const renderImage = useCallback( + (props: RenderImageProps) => { + return ( + + ); + }, + [targetRowHeight] + ); return ( -
+
{photos.length ? ( = ( - props: IImageWallProps +export const ImageWallItem: React.FC = ( + props: RenderImageProps & IExtraProps ) => { + const height = Math.min(props.maxHeight, props.photo.height); + const zoomFactor = height / props.photo.height; + const width = props.photo.width * zoomFactor; + type style = Record; var imgStyle: style = { margin: props.margin, @@ -49,8 +43,8 @@ export const ImageWallItem: React.FC = ( key={props.photo.key} style={imgStyle} src={props.photo.src} - width={props.photo.width} - height={props.photo.height} + width={width} + height={height} alt={props.photo.alt} onClick={handleClick} /> diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx index 5202b94d1..a1879a027 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx @@ -39,15 +39,23 @@ interface IMarkerPhoto { onError?: (photo: PhotoProps) => void; } -export const MarkerWallItem: React.FC> = ( - props: RenderImageProps -) => { +interface IExtraProps { + maxHeight: number; +} + +export const MarkerWallItem: React.FC< + RenderImageProps & IExtraProps +> = (props: RenderImageProps & IExtraProps) => { const { configuration } = useContext(ConfigurationContext); const playSound = configuration?.interface.soundOnPreview ?? false; const showTitle = configuration?.interface.wallShowTitle ?? false; const [active, setActive] = useState(false); + const height = Math.min(props.maxHeight, props.photo.height); + const zoomFactor = height / props.photo.height; + const width = props.photo.width * zoomFactor; + type style = Record; var divStyle: style = { margin: props.margin, @@ -79,8 +87,8 @@ export const MarkerWallItem: React.FC> = ( role="button" style={{ ...divStyle, - width: props.photo.width, - height: props.photo.height, + width, + height, }} > > = ( autoPlay={video} key={props.photo.key} src={props.photo.src} - width={props.photo.width} - height={props.photo.height} + width={width} + height={height} alt={props.photo.alt} onMouseEnter={() => setActive(true)} onMouseLeave={() => setActive(false)} @@ -163,6 +171,8 @@ const breakpointZoomHeights = [ const MarkerWall: React.FC = ({ markers, zoomIndex }) => { const history = useHistory(); + const containerRef = React.useRef(null); + const margin = 3; const direction = "row"; @@ -208,22 +218,41 @@ const MarkerWall: React.FC = ({ markers, zoomIndex }) => { return Math.round(columnCount); } - function targetRowHeight(containerWidth: number) { - let zoomHeight = 280; - breakpointZoomHeights.forEach((e) => { - if (containerWidth >= e.minWidth) { - zoomHeight = e.heights[zoomIndex]; - } - }); - return zoomHeight; - } + const targetRowHeight = useCallback( + (containerWidth: number) => { + let zoomHeight = 280; + breakpointZoomHeights.forEach((e) => { + if (containerWidth >= e.minWidth) { + zoomHeight = e.heights[zoomIndex]; + } + }); + return zoomHeight; + }, + [zoomIndex] + ); - const renderImage = useCallback((props: RenderImageProps) => { - return ; - }, []); + // set the max height as a factor of the targetRowHeight + // this allows some images to be taller than the target row height + // but prevents images from becoming too tall when there is a small number of items + const maxHeightFactor = 1.3; + + const renderImage = useCallback( + (props: RenderImageProps) => { + return ( + + ); + }, + [targetRowHeight] + ); return ( -
+
{photos.length ? ( ) => void; } -export const SceneWallItem: React.FC> = ( - props: RenderImageProps -) => { +interface IExtraProps { + maxHeight: number; +} + +export const SceneWallItem: React.FC< + RenderImageProps & IExtraProps +> = (props: RenderImageProps & IExtraProps) => { const intl = useIntl(); const { configuration } = useContext(ConfigurationContext); const playSound = configuration?.interface.soundOnPreview ?? false; const showTitle = configuration?.interface.wallShowTitle ?? false; + const height = Math.min(props.maxHeight, props.photo.height); + const zoomFactor = height / props.photo.height; + const width = props.photo.width * zoomFactor; + const [active, setActive] = useState(false); type style = Record; @@ -72,8 +80,8 @@ export const SceneWallItem: React.FC> = ( role="button" style={{ ...divStyle, - width: props.photo.width, - height: props.photo.height, + width, + height, }} > > = ( autoPlay={video} key={props.photo.key} src={props.photo.src} - width={props.photo.width} - height={props.photo.height} + width={width} + height={height} alt={props.photo.alt} onMouseEnter={() => setActive(true)} onMouseLeave={() => setActive(false)} @@ -146,6 +154,8 @@ const SceneWall: React.FC = ({ }) => { const history = useHistory(); + const containerRef = React.useRef(null); + const margin = 3; const direction = "row"; @@ -196,22 +206,41 @@ const SceneWall: React.FC = ({ return Math.round(columnCount); } - function targetRowHeight(containerWidth: number) { - let zoomHeight = 280; - breakpointZoomHeights.forEach((e) => { - if (containerWidth >= e.minWidth) { - zoomHeight = e.heights[zoomIndex]; - } - }); - return zoomHeight; - } + const targetRowHeight = useCallback( + (containerWidth: number) => { + let zoomHeight = 280; + breakpointZoomHeights.forEach((e) => { + if (containerWidth >= e.minWidth) { + zoomHeight = e.heights[zoomIndex]; + } + }); + return zoomHeight; + }, + [zoomIndex] + ); - const renderImage = useCallback((props: RenderImageProps) => { - return ; - }, []); + // set the max height as a factor of the targetRowHeight + // this allows some images to be taller than the target row height + // but prevents images from becoming too tall when there is a small number of items + const maxHeightFactor = 1.3; + + const renderImage = useCallback( + (props: RenderImageProps) => { + return ( + + ); + }, + [targetRowHeight] + ); return ( -
+
{photos.length ? ( Date: Wed, 24 Sep 2025 22:26:24 -0700 Subject: [PATCH 043/157] Show gallery cover on the edit panel (#5935) --- internal/api/urlbuilders/gallery.go | 4 ++- .../GalleryDetails/GalleryEditPanel.tsx | 21 ++++++++++++++ ui/v2.5/src/components/Galleries/styles.scss | 15 ++++++++++ ui/v2.5/src/core/StashService.ts | 29 ++++++++++++------- ui/v2.5/src/docs/en/Manual/Images.md | 2 ++ 5 files changed, 60 insertions(+), 11 deletions(-) diff --git a/internal/api/urlbuilders/gallery.go b/internal/api/urlbuilders/gallery.go index 3e6c5ef08..2723781f2 100644 --- a/internal/api/urlbuilders/gallery.go +++ b/internal/api/urlbuilders/gallery.go @@ -9,12 +9,14 @@ import ( type GalleryURLBuilder struct { BaseURL string GalleryID string + UpdatedAt string } func NewGalleryURLBuilder(baseURL string, gallery *models.Gallery) GalleryURLBuilder { return GalleryURLBuilder{ BaseURL: baseURL, GalleryID: strconv.Itoa(gallery.ID), + UpdatedAt: strconv.FormatInt(gallery.UpdatedAt.Unix(), 10), } } @@ -23,5 +25,5 @@ func (b GalleryURLBuilder) GetPreviewURL() string { } func (b GalleryURLBuilder) GetCoverURL() string { - return b.BaseURL + "/gallery/" + b.GalleryID + "/cover" + return b.BaseURL + "/gallery/" + b.GalleryID + "/cover?t=" + b.UpdatedAt } diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index b9eea8f5d..5b9fa9da1 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -162,6 +162,21 @@ export const GalleryEditPanel: React.FC = ({ ); }, [scrapers]); + const cover = useMemo(() => { + if (gallery?.paths?.cover) { + return ( +
+ {intl.formatMessage({ +
+ ); + } + + return
; + }, [gallery?.paths?.cover, intl]); + async function onSave(input: InputValues) { setIsLoading(true); try { @@ -463,6 +478,12 @@ export const GalleryEditPanel: React.FC = ({ {renderDetailsField()} + + + + + {cover} + diff --git a/ui/v2.5/src/components/Galleries/styles.scss b/ui/v2.5/src/components/Galleries/styles.scss index 58116e936..5e8618326 100644 --- a/ui/v2.5/src/components/Galleries/styles.scss +++ b/ui/v2.5/src/components/Galleries/styles.scss @@ -206,6 +206,21 @@ $galleryTabWidth: 450px; } } +.gallery-cover { + aspect-ratio: 4 / 3; + display: block; + height: auto; + width: 100%; +} + +.gallery-cover img { + height: auto; + max-height: 100%; + max-width: 100%; + object-fit: contain; + width: auto; +} + div.GalleryWall { display: flex; flex-wrap: wrap; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index a7679a5d5..8db471792 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -1613,17 +1613,30 @@ export const mutateAddGalleryImages = (input: GQL.GalleryAddInput) => }, }); +function evictCover(cache: ApolloCache, gallery_id: string) { + const fields: Pick, "paths" | "cover"> = {}; + fields.paths = (paths) => { + if (!("cover" in paths)) { + return paths; + } + const coverUrl = new URL(paths.cover); + coverUrl.search = "?t=" + Math.floor(Date.now() / 1000); + return { ...paths, cover: coverUrl.toString() }; + }; + fields.cover = (_value, { DELETE }) => DELETE; + cache.modify({ + id: cache.identify({ __typename: "Gallery", id: gallery_id }), + fields, + }); +} + export const mutateSetGalleryCover = (input: GQL.GallerySetCoverInput) => client.mutate({ mutation: GQL.SetGalleryCoverDocument, variables: input, update(cache, result) { if (!result.data?.setGalleryCover) return; - - cache.evict({ - id: cache.identify({ __typename: "Gallery", id: input.gallery_id }), - fieldName: "cover", - }); + evictCover(cache, input.gallery_id); }, }); @@ -1633,11 +1646,7 @@ export const mutateResetGalleryCover = (input: GQL.GalleryResetCoverInput) => variables: input, update(cache, result) { if (!result.data?.resetGalleryCover) return; - - cache.evict({ - id: cache.identify({ __typename: "Gallery", id: input.gallery_id }), - fieldName: "cover", - }); + evictCover(cache, input.gallery_id); }, }); diff --git a/ui/v2.5/src/docs/en/Manual/Images.md b/ui/v2.5/src/docs/en/Manual/Images.md index 7b384596b..e5733fc2e 100644 --- a/ui/v2.5/src/docs/en/Manual/Images.md +++ b/ui/v2.5/src/docs/en/Manual/Images.md @@ -13,6 +13,8 @@ For best results, images in zip file should be stored without compression (copy, If a filename of an image in the gallery zip file ends with `cover.jpg`, it will be treated like a cover and presented first in the gallery view page and as a gallery cover in the gallery list view. If more than one images match the name the first one found in natural sort order is selected. +You can also manually select any image from a gallery as its cover. On the gallery details page, select the desired cover image, and then select **Set as Cover** in the ⋯ menu. + ## Image clips/gifs Images can also be clips/gifs. These are meant to be short video loops. Right now they are not possible in zipfiles. To declare video files to be images, there are two ways: From 15bf28d5be349407790be87d3b516f39525624eb Mon Sep 17 00:00:00 2001 From: xtc1337 <107708032+xtc1337@users.noreply.github.com> Date: Thu, 25 Sep 2025 00:27:58 -0500 Subject: [PATCH 044/157] Adding the ability to support different Haptic Devices (#5856) * refactored `Interactive` class to allow more HapticDevice devices * simplified api hooks * update creation of `interactive` to pass `stashConfig` * updated UIPluginApi to mention `PluginApi.InteractiveUtils` --- ui/v2.5/src/components/ScenePlayer/util.ts | 5 +- ui/v2.5/src/docs/en/Manual/UIPluginApi.md | 70 +++++++++++++++++++- ui/v2.5/src/hooks/Interactive/context.tsx | 54 ++++++++++++--- ui/v2.5/src/hooks/Interactive/interactive.ts | 17 +++++ ui/v2.5/src/hooks/Interactive/utils.ts | 51 ++++++++++++++ ui/v2.5/src/pluginApi.tsx | 4 +- 6 files changed, 186 insertions(+), 15 deletions(-) create mode 100644 ui/v2.5/src/hooks/Interactive/utils.ts diff --git a/ui/v2.5/src/components/ScenePlayer/util.ts b/ui/v2.5/src/components/ScenePlayer/util.ts index a63ab6a2e..8c6fb8010 100644 --- a/ui/v2.5/src/components/ScenePlayer/util.ts +++ b/ui/v2.5/src/components/ScenePlayer/util.ts @@ -2,5 +2,6 @@ import videojs from "video.js"; export const VIDEO_PLAYER_ID = "VideoJsPlayer"; -export const getPlayerPosition = () => - videojs.getPlayer(VIDEO_PLAYER_ID)?.currentTime(); +export const getPlayer = () => videojs.getPlayer(VIDEO_PLAYER_ID); + +export const getPlayerPosition = () => getPlayer()?.currentTime(); diff --git a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md index f010deb38..23cd3fd64 100644 --- a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md +++ b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md @@ -66,7 +66,7 @@ This namespace contains all of the components available to plugins. These includ ### `utils` -This namespace provides access to the `NavUtils` and `StashService` namespaces. It also provides access to the `loadComponents` method. +This namespace provides access to the `NavUtils` , `StashService` and `InteractiveUtils` namespaces. It also provides access to the `loadComponents` method. #### `PluginApi.utils.loadComponents` @@ -80,6 +80,72 @@ In general, `PluginApi.hooks.useLoadComponents` hook should be used instead. Returns a `Promise` that resolves when all of the components have been loaded. +#### `PluginApi.utils.InteractiveUtils` +This namespace provides access to `interactiveClientProvider` and `getPlayer` + - `getPlayer` returns the current `videojs` player object + - `interactiveClientProvider` takes `IInteractiveClientProvider` which allows a developer to hook into the lifecycle of funscripts. +```ts + export interface IDeviceSettings { + connectionKey: string; + scriptOffset: number; + estimatedServerTimeOffset?: number; + useStashHostedFunscript?: boolean; + [key: string]: unknown; +} + +export interface IInteractiveClientProviderOptions { + handyKey: string; + scriptOffset: number; + defaultClientProvider?: IInteractiveClientProvider; + stashConfig?: GQL.ConfigDataFragment; +} +export interface IInteractiveClientProvider { + (options: IInteractiveClientProviderOptions): IInteractiveClient; +} + +/** + * Interface that is used for InteractiveProvider + */ +export interface IInteractiveClient { + connect(): Promise; + handyKey: string; + uploadScript: (funscriptPath: string, apiKey?: string) => Promise; + sync(): Promise; + configure(config: Partial): Promise; + play(position: number): Promise; + pause(): Promise; + ensurePlaying(position: number): Promise; + setLooping(looping: boolean): Promise; + readonly connected: boolean; + readonly playing: boolean; +} + +``` +##### Example +For instance say I wanted to add extra logging when `IInteractiveClient.connect()` is called. +In my plugin you would install your own client provider as seen below + +```ts +InteractiveUtils.interactiveClientProvider = ( + opts +) => { + if (!opts.defaultClientProvider) { + throw new Error('invalid setup'); + } + + const client = opts.defaultClientProvider(opts); + const connect = client.connect; + client.connect = async () => { + console.log('patching connect method'); + return connect.call(client); + }; + + return client; +}; + +``` + + ### `hooks` This namespace provides access to the following core utility hooks: @@ -251,3 +317,5 @@ Allows plugins to listen for Stash's events. ```js PluginApi.Event.addEventListener("stash:location", (e) => console.log("Page Changed", e.detail.data.location.pathname)) ``` + + diff --git a/ui/v2.5/src/hooks/Interactive/context.tsx b/ui/v2.5/src/hooks/Interactive/context.tsx index a42f0aa7b..9e7194d6a 100644 --- a/ui/v2.5/src/hooks/Interactive/context.tsx +++ b/ui/v2.5/src/hooks/Interactive/context.tsx @@ -2,6 +2,10 @@ import React, { useCallback, useContext, useEffect, useState } from "react"; import { ConfigurationContext } from "../Config"; import { useLocalForage } from "../LocalForage"; import { Interactive as InteractiveAPI } from "./interactive"; +import InteractiveUtils, { + IInteractiveClient, + IInteractiveClientProvider, +} from "./utils"; export enum ConnectionState { Missing, @@ -34,7 +38,7 @@ export function connectionStateLabel(s: ConnectionState) { } export interface IState { - interactive: InteractiveAPI; + interactive: IInteractiveClient; state: ConnectionState; serverOffset: number; initialised: boolean; @@ -69,6 +73,13 @@ interface IInteractiveState { lastSyncTime: number; } +export const defaultInteractiveClientProvider: IInteractiveClientProvider = ({ + handyKey, + scriptOffset, +}): IInteractiveClient => { + return new InteractiveAPI(handyKey, scriptOffset); +}; + export const InteractiveProvider: React.FC = ({ children }) => { const [{ data: config }, setConfig] = useLocalForage( LOCAL_FORAGE_KEY, @@ -85,7 +96,22 @@ export const InteractiveProvider: React.FC = ({ children }) => { const [scriptOffset, setScriptOffset] = useState(0); const [useStashHostedFunscript, setUseStashHostedFunscript] = useState(false); - const [interactive] = useState(new InteractiveAPI("", 0)); + + const resolveInteractiveClient = useCallback(() => { + const interactiveClientProvider = + InteractiveUtils.interactiveClientProvider ?? + defaultInteractiveClientProvider; + + return interactiveClientProvider({ + handyKey: "", + scriptOffset: 0, + defaultClientProvider: defaultInteractiveClientProvider, + stashConfig, + }); + }, [stashConfig]); + + // fetch client provider from PluginApi if not found use default provider + const [interactive] = useState(resolveInteractiveClient); const [initialised, setInitialised] = useState(false); const [error, setError] = useState(); @@ -104,7 +130,9 @@ export const InteractiveProvider: React.FC = ({ children }) => { } if (config?.serverOffset) { - interactive.setServerTimeOffset(config.serverOffset); + await interactive.configure({ + estimatedServerTimeOffset: config.serverOffset, + }); setState(ConnectionState.Connecting); try { await interactive.connect(); @@ -138,13 +166,17 @@ export const InteractiveProvider: React.FC = ({ children }) => { const oldKey = interactive.handyKey; - interactive.handyKey = handyKey ?? ""; - interactive.scriptOffset = scriptOffset; - interactive.useStashHostedFunscript = useStashHostedFunscript; - - if (oldKey !== interactive.handyKey && interactive.handyKey) { - initialise(); - } + interactive + .configure({ + connectionKey: handyKey ?? "", + offset: scriptOffset, + useStashHostedFunscript, + }) + .then(() => { + if (oldKey !== interactive.handyKey && interactive.handyKey) { + initialise(); + } + }); }, [ handyKey, scriptOffset, @@ -171,7 +203,7 @@ export const InteractiveProvider: React.FC = ({ children }) => { const uploadScript = useCallback( async (funscriptPath: string) => { - interactive.pause(); + await interactive.pause(); if ( !interactive.handyKey || !funscriptPath || diff --git a/ui/v2.5/src/hooks/Interactive/interactive.ts b/ui/v2.5/src/hooks/Interactive/interactive.ts index ef34bd2ef..4ca59b25b 100644 --- a/ui/v2.5/src/hooks/Interactive/interactive.ts +++ b/ui/v2.5/src/hooks/Interactive/interactive.ts @@ -5,6 +5,7 @@ import { CsvUploadResponse, HandyFirmwareStatus, } from "thehandy/lib/types"; +import { IDeviceSettings } from "./utils"; interface IFunscript { actions: Array; @@ -108,6 +109,13 @@ export class Interactive { this._playing = false; } + get connected() { + return this._connected; + } + get playing() { + return this._playing; + } + async connect() { const connected = await this._handy.getConnected(); if (!connected) { @@ -180,6 +188,15 @@ export class Interactive { this._handy.estimatedServerTimeOffset = offset; } + async configure(config: Partial) { + this._scriptOffset = config.scriptOffset ?? this._scriptOffset; + this.handyKey = config.connectionKey ?? this.handyKey; + this._handy.estimatedServerTimeOffset = + config.estimatedServerTimeOffset ?? this._handy.estimatedServerTimeOffset; + this.useStashHostedFunscript = + config.useStashHostedFunscript ?? this.useStashHostedFunscript; + } + async play(position: number) { if (!this._connected) { return; diff --git a/ui/v2.5/src/hooks/Interactive/utils.ts b/ui/v2.5/src/hooks/Interactive/utils.ts new file mode 100644 index 000000000..c1d066e86 --- /dev/null +++ b/ui/v2.5/src/hooks/Interactive/utils.ts @@ -0,0 +1,51 @@ +import { getPlayer } from "src/components/ScenePlayer/util"; +import type { VideoJsPlayer } from "video.js"; +import * as GQL from "src/core/generated-graphql"; + +export interface IDeviceSettings { + connectionKey: string; + scriptOffset: number; + estimatedServerTimeOffset?: number; + useStashHostedFunscript?: boolean; + [key: string]: unknown; +} + +export interface IInteractiveClientProviderOptions { + handyKey: string; + scriptOffset: number; + defaultClientProvider?: IInteractiveClientProvider; + stashConfig?: GQL.ConfigDataFragment; +} +export interface IInteractiveClientProvider { + (options: IInteractiveClientProviderOptions): IInteractiveClient; +} + +/** + * Interface that is used for InteractiveProvider + */ +export interface IInteractiveClient { + connect(): Promise; + handyKey: string; + uploadScript: (funscriptPath: string, apiKey?: string) => Promise; + sync(): Promise; + configure(config: Partial): Promise; + play(position: number): Promise; + pause(): Promise; + ensurePlaying(position: number): Promise; + setLooping(looping: boolean): Promise; + readonly connected: boolean; + readonly playing: boolean; +} + +export interface IInteractiveUtils { + getPlayer: () => VideoJsPlayer | undefined; + interactiveClientProvider: IInteractiveClientProvider | undefined; +} +const InteractiveUtils = { + // hook to allow to customize the interactive client + interactiveClientProvider: undefined, + // returns the active player + getPlayer, +}; + +export default InteractiveUtils; diff --git a/ui/v2.5/src/pluginApi.tsx b/ui/v2.5/src/pluginApi.tsx index 99d4a5992..e534dddef 100644 --- a/ui/v2.5/src/pluginApi.tsx +++ b/ui/v2.5/src/pluginApi.tsx @@ -16,9 +16,10 @@ import * as ReactSelect from "react-select"; import { useSpriteInfo } from "./hooks/sprite"; import { useToast } from "./hooks/Toast"; import Event from "./hooks/event"; -import { before, instead, after, components, RegisterComponent } from "./patch"; +import { after, before, components, instead, RegisterComponent } from "./patch"; import { useSettings } from "./components/Settings/context"; import { useInteractive } from "./hooks/Interactive/context"; +import InteractiveUtils from "./hooks/Interactive/utils"; import { useLightbox, useGalleryLightbox } from "./hooks/Lightbox/hooks"; // due to code splitting, some components may not have been loaded when a plugin @@ -152,6 +153,7 @@ export const PluginApi = { }, components, utils: { + InteractiveUtils, NavUtils, StashService, loadComponents, From af76f4a24aacdf36694cb39016e355778a27953e Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 6 Oct 2025 07:44:59 +1100 Subject: [PATCH 045/157] Prevent input field from focusing on touch devices rather than mobile (#6105) --- ui/v2.5/src/components/List/Filters/FilterSidebar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/List/Filters/FilterSidebar.tsx b/ui/v2.5/src/components/List/Filters/FilterSidebar.tsx index 9ad2fe152..1623c83d7 100644 --- a/ui/v2.5/src/components/List/Filters/FilterSidebar.tsx +++ b/ui/v2.5/src/components/List/Filters/FilterSidebar.tsx @@ -30,9 +30,9 @@ export const FilteredSidebarHeader: React.FC<{ const [, setFocus] = focus; // Set the focus on the input field when the sidebar is opened - // Don't do this on mobile devices + // Don't do this on touch devices useEffect(() => { - if (sidebarOpen && !ScreenUtils.isMobile()) { + if (sidebarOpen && !ScreenUtils.isTouch()) { setFocus(); } }, [sidebarOpen, setFocus]); From c5bad48ece56378a1c3fdc0658efbb63a1c656c9 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 6 Oct 2025 07:45:36 +1100 Subject: [PATCH 046/157] Scene list cleanup (#6104) * Generalise and cleanup list toolbar * Generalise ListResultsHeader * Fix padding on sub-pages --- .../components/List/FilteredListToolbar.tsx | 23 +- ui/v2.5/src/components/List/ListFilter.tsx | 54 ++- .../src/components/List/ListResultsHeader.tsx | 66 ++++ ui/v2.5/src/components/List/ListToolbar.tsx | 120 +++++++ ui/v2.5/src/components/List/styles.scss | 231 +++++++++++++ ui/v2.5/src/components/Scenes/SceneList.tsx | 321 ++++++------------ ui/v2.5/src/components/Scenes/styles.scss | 251 -------------- ui/v2.5/src/components/Shared/Sidebar.tsx | 69 +--- ui/v2.5/src/components/Shared/styles.scss | 8 +- 9 files changed, 560 insertions(+), 583 deletions(-) create mode 100644 ui/v2.5/src/components/List/ListResultsHeader.tsx create mode 100644 ui/v2.5/src/components/List/ListToolbar.tsx diff --git a/ui/v2.5/src/components/List/FilteredListToolbar.tsx b/ui/v2.5/src/components/List/FilteredListToolbar.tsx index 44ce94453..656b07470 100644 --- a/ui/v2.5/src/components/List/FilteredListToolbar.tsx +++ b/ui/v2.5/src/components/List/FilteredListToolbar.tsx @@ -8,11 +8,9 @@ import { IListFilterOperation, ListOperationButtons, } from "./ListOperationButtons"; -import { Button, ButtonGroup, ButtonToolbar } from "react-bootstrap"; +import { ButtonGroup, ButtonToolbar } from "react-bootstrap"; import { View } from "./views"; import { IListSelect, useFilterOperations } from "./util"; -import { SidebarIcon } from "../Shared/Sidebar"; -import { useIntl } from "react-intl"; export interface IItemListOperation { text: string; @@ -43,7 +41,6 @@ export interface IFilteredListToolbar { onDelete?: () => void; operations?: IListFilterOperation[]; zoomable?: boolean; - onToggleSidebar?: () => void; } export const FilteredListToolbar: React.FC = ({ @@ -56,9 +53,7 @@ export const FilteredListToolbar: React.FC = ({ onDelete, operations, zoomable = false, - onToggleSidebar, }) => { - const intl = useIntl(); const filterOptions = filter.options; const { setDisplayMode, setZoom } = useFilterOperations({ filter, @@ -68,21 +63,6 @@ export const FilteredListToolbar: React.FC = ({ return ( - - {onToggleSidebar && ( - - - - )} - - {showEditFilter && ( = ({ filter={filter} openFilterDialog={() => showEditFilter()} view={view} - withSidebar={!!onToggleSidebar} /> )} void; - withSidebar?: boolean; } export const ListFilter: React.FC = ({ @@ -332,7 +331,6 @@ export const ListFilter: React.FC = ({ filter, openFilterDialog, view, - withSidebar, }) => { const filterOptions = filter.options; @@ -379,36 +377,32 @@ export const ListFilter: React.FC = ({ function render() { return ( <> - {!withSidebar && ( -
- -
- )} +
+ +
- {!withSidebar && ( - - { - onFilterUpdate(f); - }} - view={view} + + { + onFilterUpdate(f); + }} + view={view} + /> + + + + } + > + openFilterDialog()} + count={filter.count()} /> - - - - } - > - openFilterDialog()} - count={filter.count()} - /> - - - )} + + void; +}> = ({ + className, + loading, + filter, + totalCount, + metadataByline, + onChangeFilter, +}) => { + return ( + +
+ +
+
+ + onChangeFilter(filter.setSortBy(s ?? undefined)) + } + onChangeSortDirection={() => + onChangeFilter(filter.toggleSortDirection()) + } + onReshuffleRandomSort={() => + onChangeFilter(filter.reshuffleRandomSort()) + } + /> + onChangeFilter(filter.setPageSize(s))} + /> + + onChangeFilter(filter.setDisplayMode(mode)) + } + onSetZoom={(zoom) => onChangeFilter(filter.setZoom(zoom))} + /> +
+
+ ); +}; diff --git a/ui/v2.5/src/components/List/ListToolbar.tsx b/ui/v2.5/src/components/List/ListToolbar.tsx new file mode 100644 index 000000000..31ef7f7ee --- /dev/null +++ b/ui/v2.5/src/components/List/ListToolbar.tsx @@ -0,0 +1,120 @@ +import React from "react"; +import { FormattedMessage, useIntl } from "react-intl"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { faTimes } from "@fortawesome/free-solid-svg-icons"; +import { FilterTags } from "../List/FilterTags"; +import cx from "classnames"; +import { Button, ButtonToolbar } from "react-bootstrap"; +import { FilterButton } from "../List/Filters/FilterButton"; +import { Icon } from "../Shared/Icon"; +import { SearchTermInput } from "../List/ListFilter"; +import { Criterion } from "src/models/list-filter/criteria/criterion"; +import { SidebarToggleButton } from "../Shared/Sidebar"; +import { PatchComponent } from "src/patch"; + +export const ToolbarFilterSection: React.FC<{ + filter: ListFilterModel; + onToggleSidebar: () => void; + onSetFilter: (filter: ListFilterModel) => void; + onEditCriterion: (c?: Criterion) => void; + onRemoveCriterion: (criterion: Criterion, valueIndex?: number) => void; + onRemoveAllCriterion: () => void; + onEditSearchTerm: () => void; + onRemoveSearchTerm: () => void; +}> = PatchComponent( + "ToolbarFilterSection", + ({ + filter, + onToggleSidebar, + onSetFilter, + onEditCriterion, + onRemoveCriterion, + onRemoveAllCriterion, + onEditSearchTerm, + onRemoveSearchTerm, + }) => { + const { criteria, searchTerm } = filter; + + return ( + <> +
+ +
+
+ onEditCriterion()} + count={criteria.length} + /> + + +
+ + ); + } +); + +export const ToolbarSelectionSection: React.FC<{ + selected: number; + onToggleSidebar: () => void; + onSelectAll: () => void; + onSelectNone: () => void; +}> = PatchComponent( + "ToolbarSelectionSection", + ({ selected, onToggleSidebar, onSelectAll, onSelectNone }) => { + const intl = useIntl(); + + return ( +
+ + {selected} selected + + +
+ ); + } +); + +// TODO - rename to FilteredListToolbar once all list components have been updated +// TODO - and expose to plugins +export const FilteredListToolbar2: React.FC<{ + className?: string; + hasSelection: boolean; + filterSection: React.ReactNode; + selectionSection: React.ReactNode; + operationSection: React.ReactNode; +}> = ({ + className, + hasSelection, + filterSection, + selectionSection, + operationSection, +}) => { + return ( + + {!hasSelection ? filterSection : selectionSection} +
{operationSection}
+
+ ); +}; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 96a1e4c26..a4194a832 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -1046,3 +1046,234 @@ input[type="range"].zoom-slider { } } } + +// hide sidebar Edit Filter button on larger screens +@include media-breakpoint-up(lg) { + .sidebar .edit-filter-button { + display: none; + } +} + +// the following refers to the new FilteredListToolbar2 component +// ensure the rules here don't conflict with the original filtered-list-toolbar above +// TODO - replace with only .filtered-list-toolbar once all lists use the new toolbar +.scene-list-toolbar { + &.filtered-list-toolbar { + align-items: center; + background-color: $body-bg; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + row-gap: 1rem; + + > div { + align-items: center; + display: flex; + gap: 0.5rem; + justify-content: flex-start; + + &:last-child { + flex-shrink: 0; + justify-content: flex-end; + } + } + } + + &.filtered-list-toolbar { + flex-wrap: nowrap; + gap: 1rem; + // offset the main padding + margin-top: -0.5rem; + padding-bottom: 0.5rem; + padding-top: 0.5rem; + position: sticky; + top: $navbar-height; + z-index: 10; + + @include media-breakpoint-down(xs) { + top: 0; + } + + .selected-items-info .btn { + margin-right: 0.5rem; + } + + // hide drop down menu items for play and create new + // when the buttons are visible + @include media-breakpoint-up(sm) { + .scene-list-operations { + .play-item, + .create-new-item { + display: none; + } + } + } + + // hide play and create new buttons on xs screens + // show these in the drop down menu instead + @include media-breakpoint-down(xs) { + .play-button, + .create-new-button { + display: none; + } + } + + .selected-items-info, + div.filter-section { + border: 1px solid $secondary; + border-radius: 0.25rem; + flex-grow: 1; + overflow-x: hidden; + } + + .sidebar-toggle-button { + margin-left: auto; + } + + .search-container { + border-right: 1px solid $secondary; + display: block; + margin-right: -0.5rem; + min-width: calc($sidebar-width - 15px); + padding-right: 10px; + + .search-term-input { + margin-right: 0; + width: 100%; + + .clearable-text-field { + height: 100%; + } + } + } + + .filter-tags { + flex-grow: 1; + flex-wrap: nowrap; + justify-content: flex-start; + margin-bottom: 0; + + // account for filter button, and toggle sidebar buttons with gaps + width: calc(100% - 70px - 1rem); + + @include media-breakpoint-down(xs) { + overflow-x: auto; + scrollbar-width: thin; + } + + .tag-item { + white-space: nowrap; + } + } + } +} + +@include media-breakpoint-up(xl) { + .sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar .search-container { + display: none; + } +} +@include media-breakpoint-down(md) { + .sidebar-pane.hide-sidebar .filtered-list-toolbar .search-container { + display: none; + } +} + +// hide the filter icon button when sidebar is shown on smaller screens +@include media-breakpoint-down(md) { + .sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar .filter-button { + display: none; + } + + // adjust the width of the filter-tags as well + .sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar .filter-tags { + width: calc(100% - 35px - 0.5rem); + } +} + +// move the sidebar toggle to the left on xl viewports +@include media-breakpoint-up(xl) { + .filtered-list-toolbar .filter-section { + .sidebar-toggle-button { + margin-left: 0; + } + + .filter-tags { + order: 2; + } + } +} + +// hide the search term tag item when the search box is visible +@include media-breakpoint-up(lg) { + // TODO - remove scene-list-toolbar when all lists use the new toolbar + .scene-list-toolbar.filtered-list-toolbar + .filter-tags + .search-term-filter-tag { + display: none; + } +} +@include media-breakpoint-down(md) { + // TODO - remove scene-list-toolbar when all lists use the new toolbar + .sidebar-pane:not(.hide-sidebar) + .scene-list-toolbar.filtered-list-toolbar + .filter-tags + .search-term-filter-tag { + display: none; + } +} + +// TODO - remove scene-list-toolbar when all lists use the new toolbar +.detail-body .scene-list-toolbar.filtered-list-toolbar { + top: calc($sticky-detail-header-height + $navbar-height); + + @include media-breakpoint-down(xs) { + top: 0; + } +} + +#more-criteria-popover { + box-shadow: 0 8px 10px 2px rgb(0 0 0 / 30%); + max-width: 400px; + padding: 0.25rem; +} + +.list-results-header { + align-items: center; + background-color: $body-bg; + display: flex; + justify-content: space-between; + + > div { + align-items: center; + display: flex; + gap: 0.5rem; + justify-content: flex-start; + + &:last-child { + flex-shrink: 0; + justify-content: flex-end; + } + } +} + +.list-results-header { + flex-wrap: wrap-reverse; + gap: 0.5rem; + margin-bottom: 0.5rem; + + .paginationIndex { + margin: 0; + } + + // center the header on smaller screens + @include media-breakpoint-down(sm) { + & > div, + & > div:last-child { + flex-basis: 100%; + justify-content: center; + margin-left: auto; + margin-right: auto; + } + } +} diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 6e8832105..1154f384e 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -23,8 +23,6 @@ import { faPencil, faPlay, faPlus, - faSliders, - faTimes, faTrash, } from "@fortawesome/free-solid-svg-icons"; import { SceneMergeModal } from "./SceneMergeDialog"; @@ -39,7 +37,6 @@ import { OperationDropdownItem, } from "../List/ListOperationButtons"; import { useFilteredItemList } from "../List/ItemList"; -import { FilterTags } from "../List/FilterTags"; import { Sidebar, SidebarPane, useSidebarState } from "../Shared/Sidebar"; import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter"; import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter"; @@ -57,18 +54,16 @@ import { useFilteredSidebarKeybinds, } from "../List/Filters/FilterSidebar"; import { PatchContainerComponent } from "src/patch"; -import { Pagination, PaginationIndex } from "../List/Pagination"; -import { Button, ButtonGroup, ButtonToolbar } from "react-bootstrap"; -import { FilterButton } from "../List/Filters/FilterButton"; +import { Pagination } from "../List/Pagination"; +import { Button, ButtonGroup } from "react-bootstrap"; import { Icon } from "../Shared/Icon"; -import { ListViewOptions } from "../List/ListViewOptions"; -import { - PageSizeSelector, - SearchTermInput, - SortBySelect, -} from "../List/ListFilter"; -import { Criterion } from "src/models/list-filter/criteria/criterion"; import useFocus from "src/utils/focus"; +import { + FilteredListToolbar2, + ToolbarFilterSection, + ToolbarSelectionSection, +} from "../List/ListToolbar"; +import { ListResultsHeader } from "../List/ListResultsHeader"; function renderMetadataByline(result: GQL.FindScenesQueryResult) { const duration = result?.data?.findScenes?.duration; @@ -340,38 +335,18 @@ interface IOperations { className?: string; } -const ListToolbarContent: React.FC<{ - filter: ListFilterModel; - items: GQL.SlimSceneDataFragment[]; - selectedIds: Set; +const SceneListOperations: React.FC<{ + items: number; + hasSelection: boolean; operations: IOperations[]; - onToggleSidebar: () => void; - onSetFilter: (filter: ListFilterModel) => void; - onEditCriterion: (c?: Criterion) => void; - onRemoveCriterion: (criterion: Criterion, valueIndex?: number) => void; - onRemoveAllCriterion: () => void; - onEditSearchTerm: () => void; - onRemoveSearchTerm: () => void; - onSelectAll: () => void; - onSelectNone: () => void; onEdit: () => void; onDelete: () => void; onPlay: () => void; onCreateNew: () => void; }> = ({ - filter, items, - selectedIds, + hasSelection, operations, - onToggleSidebar, - onSetFilter, - onEditCriterion, - onRemoveCriterion, - onRemoveAllCriterion, - onEditSearchTerm, - onRemoveSearchTerm, - onSelectAll, - onSelectNone, onEdit, onDelete, onPlay, @@ -379,174 +354,66 @@ const ListToolbarContent: React.FC<{ }) => { const intl = useIntl(); - const { criteria, searchTerm } = filter; - const hasSelection = selectedIds.size > 0; - - const sidebarToggle = ( - - ); - return ( - <> - {!hasSelection && ( - <> -
- -
-
- onEditCriterion()} - count={criteria.length} - /> - - {sidebarToggle} -
- - )} - {hasSelection && ( -
+
+ + {!!items && ( - {selectedIds.size} selected - - {sidebarToggle} -
- )} -
- - {!!items.length && ( - - )} - {!hasSelection && ( - )} + + )} - {hasSelection && ( - <> - - - - )} + + {operations.map((o) => { + if (o.isDisplayed && !o.isDisplayed()) { + return null; + } - - {operations.map((o) => { - if (o.isDisplayed && !o.isDisplayed()) { - return null; - } - - return ( - - ); - })} - - -
- - ); -}; - -const ListResultsHeader: React.FC<{ - loading: boolean; - filter: ListFilterModel; - totalCount: number; - metadataByline?: React.ReactNode; - onChangeFilter: (filter: ListFilterModel) => void; -}> = ({ loading, filter, totalCount, metadataByline, onChangeFilter }) => { - return ( - -
- -
-
- - onChangeFilter(filter.setSortBy(s ?? undefined)) - } - onChangeSortDirection={() => - onChangeFilter(filter.toggleSortDirection()) - } - onReshuffleRandomSort={() => - onChangeFilter(filter.reshuffleRandomSort()) - } - /> - onChangeFilter(filter.setPageSize(s))} - /> - - onChangeFilter(filter.setDisplayMode(mode)) - } - onSetZoom={(zoom) => onChangeFilter(filter.setZoom(zoom))} - /> -
-
+ return ( + + ); + })} + + +
); }; @@ -823,34 +690,46 @@ export const FilteredSceneList = (props: IFilteredScenes) => { />
- - setShowSidebar(!showSidebar)} - onEditCriterion={(c) => showEditFilter(c?.criterionOption.type)} - onRemoveCriterion={removeCriterion} - onRemoveAllCriterion={() => clearAllCriteria(true)} - onEditSearchTerm={() => { - setShowSidebar(true); - setSearchFocus(true); - }} - onRemoveSearchTerm={() => setFilter(filter.clearSearchTerm())} - onSelectAll={() => onSelectAll()} - onSelectNone={() => onSelectNone()} - onEdit={onEdit} - onDelete={onDelete} - onCreateNew={onCreateNew} - onPlay={onPlay} - /> - + setShowSidebar(!showSidebar)} + onEditCriterion={(c) => + showEditFilter(c?.criterionOption.type) + } + onRemoveCriterion={removeCriterion} + onRemoveAllCriterion={() => clearAllCriteria(true)} + onEditSearchTerm={() => { + setShowSidebar(true); + setSearchFocus(true); + }} + onRemoveSearchTerm={() => setFilter(filter.clearSearchTerm())} + /> + } + selectionSection={ + setShowSidebar(!showSidebar)} + onSelectAll={() => onSelectAll()} + onSelectNone={() => onSelectNone()} + /> + } + operationSection={ + + } + /> div { - display: flex; - flex: 1; - flex-wrap: nowrap; - - &:first-child { - justify-content: flex-start; - } - - &:nth-child(2) { - justify-content: center; - } - - &:last-child { - justify-content: flex-end; - } - - @include media-breakpoint-down(xs) { - &:nth-child(2) { - justify-content: flex-end; - } - - &:last-child { - display: none; - } - } - } -} - -.scene-list.hide-sidebar .sidebar-toggle-button { - transition-delay: 0.1s; - transition-duration: 0; - transition-property: opacity; -} - .scene-wall, .marker-wall { .wall-item { @@ -998,214 +958,3 @@ input[type="range"].blue-slider { } } } - -.scene-list-toolbar, -.scene-list-header { - align-items: center; - background-color: $body-bg; - display: flex; - justify-content: space-between; - - > div { - align-items: center; - display: flex; - gap: 0.5rem; - justify-content: flex-start; - - &:last-child { - flex-shrink: 0; - justify-content: flex-end; - } - } -} - -.scene-list-toolbar { - flex-wrap: nowrap; - gap: 1rem; - // offset the main padding - margin-top: -0.5rem; - padding-bottom: 0.5rem; - padding-top: 0.5rem; - position: sticky; - top: $navbar-height; - z-index: 10; - - @include media-breakpoint-down(xs) { - top: 0; - } - - .selected-items-info .btn { - margin-right: 0.5rem; - } - - // hide drop down menu items for play and create new - // when the buttons are visible - @include media-breakpoint-up(sm) { - .scene-list-operations { - .play-item, - .create-new-item { - display: none; - } - } - } - - // hide play and create new buttons on xs screens - // show these in the drop down menu instead - @include media-breakpoint-down(xs) { - .play-button, - .create-new-button { - display: none; - } - } - - .selected-items-info, - > div.filter-section { - border: 1px solid $secondary; - border-radius: 0.25rem; - flex-grow: 1; - overflow-x: hidden; - } - - > div.filter-toolbar { - border: 1px solid $secondary; - border-radius: 0.25rem; - - .filter-button { - border-bottom-right-radius: 0; - border-top-right-radius: 0; - } - } - - .sidebar-toggle-button { - margin-left: auto; - } - - .search-container { - border-right: 1px solid $secondary; - display: block; - margin-right: -0.5rem; - min-width: calc($sidebar-width - 15px); - padding-right: 10px; - - .search-term-input { - margin-right: 0; - width: 100%; - - .clearable-text-field { - height: 100%; - } - } - } - - .filter-tags { - flex-grow: 1; - flex-wrap: nowrap; - justify-content: flex-start; - margin-bottom: 0; - - // account for filter button, and toggle sidebar buttons with gaps - width: calc(100% - 70px - 1rem); - - @include media-breakpoint-down(xs) { - overflow-x: auto; - scrollbar-width: thin; - } - - .tag-item { - white-space: nowrap; - } - } -} - -@include media-breakpoint-up(xl) { - .sidebar-pane:not(.hide-sidebar) .scene-list-toolbar .search-container { - display: none; - } -} -@include media-breakpoint-down(md) { - .sidebar-pane.hide-sidebar .scene-list-toolbar .search-container { - display: none; - } -} - -// hide Edit Filter button on larger screens -@include media-breakpoint-up(lg) { - .scene-list .sidebar .edit-filter-button { - display: none; - } -} - -// hide the filter icon button when sidebar is shown on smaller screens -@include media-breakpoint-down(md) { - .sidebar-pane:not(.hide-sidebar) .scene-list-toolbar .filter-button { - display: none; - } - - // adjust the width of the filter-tags as well - .sidebar-pane:not(.hide-sidebar) .scene-list-toolbar .filter-tags { - width: calc(100% - 35px - 0.5rem); - } -} - -// move the sidebar toggle to the left on xl viewports -@include media-breakpoint-up(xl) { - .scene-list .scene-list-toolbar .filter-section { - .sidebar-toggle-button { - margin-left: 0; - } - - .filter-tags { - order: 2; - } - } -} - -// hide the search term tag item when the search box is visible -@include media-breakpoint-up(lg) { - .scene-list-toolbar .filter-tags .search-term-filter-tag { - display: none; - } -} -@include media-breakpoint-down(md) { - .sidebar-pane:not(.hide-sidebar) - .scene-list-toolbar - .filter-tags - .search-term-filter-tag { - display: none; - } -} - -.scene-list-header { - flex-wrap: wrap-reverse; - gap: 0.5rem; - margin-bottom: 0.5rem; - - .paginationIndex { - margin: 0; - } - - // center the header on smaller screens - @include media-breakpoint-down(sm) { - & > div, - & > div:last-child { - flex-basis: 100%; - justify-content: center; - margin-left: auto; - margin-right: auto; - } - } -} - -.detail-body .scene-list-toolbar { - top: calc($sticky-detail-header-height + $navbar-height); - - @include media-breakpoint-down(xs) { - top: 0; - } -} - -#more-criteria-popover { - box-shadow: 0 8px 10px 2px rgb(0 0 0 / 30%); - max-width: 400px; - padding: 0.25rem; -} diff --git a/ui/v2.5/src/components/Shared/Sidebar.tsx b/ui/v2.5/src/components/Shared/Sidebar.tsx index cd4848dab..52f9328f0 100644 --- a/ui/v2.5/src/components/Shared/Sidebar.tsx +++ b/ui/v2.5/src/components/Shared/Sidebar.tsx @@ -11,8 +11,10 @@ import ScreenUtils, { useMediaQuery } from "src/utils/screen"; import { IViewConfig, useInterfaceLocalForage } from "src/hooks/LocalForage"; import { View } from "../List/views"; import cx from "classnames"; -import { Button, ButtonToolbar, CollapseProps } from "react-bootstrap"; +import { Button, CollapseProps } from "react-bootstrap"; import { useIntl } from "react-intl"; +import { Icon } from "./Icon"; +import { faSliders } from "@fortawesome/free-solid-svg-icons"; const fixedSidebarMediaQuery = "only screen and (max-width: 1199px)"; @@ -79,62 +81,19 @@ export const SidebarSection: React.FC< ); }; -export const SidebarIcon: React.FC = () => ( - <> - {/* From: https://iconduck.com/icons/19707/sidebar -MIT License -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. */} - - - - - -); - -export const SidebarToolbar: React.FC<{ - onClose?: () => void; -}> = ({ onClose, children }) => { +export const SidebarToggleButton: React.FC<{ + onClick: () => void; +}> = ({ onClick }) => { const intl = useIntl(); - return ( - - {onClose ? ( - - ) : null} - {children} - + ); }; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 881018566..6ccef5aaf 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -941,11 +941,11 @@ $sticky-header-height: calc(50px + 3.3rem); margin-left: 0; } } + } - .sidebar-pane.hide-sidebar { - > :nth-child(2) { - padding-left: 15px; - } + .sidebar-pane.hide-sidebar { + > :nth-child(2) { + padding-left: 15px; } } } From 2ed9e5332d7bfeb08877e38af5bd633ba0fd3a37 Mon Sep 17 00:00:00 2001 From: fancydancers <235832478+fancydancers@users.noreply.github.com> Date: Thu, 9 Oct 2025 02:35:11 +0000 Subject: [PATCH 047/157] add content-disposition filename header to streams (#6119) * add content-disposition filename header to streams * Fix filename generation on windows --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- internal/manager/running_streams.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/manager/running_streams.go b/internal/manager/running_streams.go index c6b0c4665..18ac3b042 100644 --- a/internal/manager/running_streams.go +++ b/internal/manager/running_streams.go @@ -3,7 +3,9 @@ package manager import ( "context" "errors" + "mime" "net/http" + "path/filepath" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/internal/static" @@ -46,14 +48,17 @@ func (s *SceneServer) StreamSceneDirect(scene *models.Scene, w http.ResponseWrit sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()) - filepath := GetInstance().Paths.Scene.GetStreamPath(scene.Path, sceneHash) + fp := GetInstance().Paths.Scene.GetStreamPath(scene.Path, sceneHash) streamRequestCtx := ffmpeg.NewStreamRequestContext(w, r) // #2579 - hijacking and closing the connection here causes video playback to fail in Safari // We trust that the request context will be closed, so we don't need to call Cancel on the // returned context here. - _ = GetInstance().ReadLockManager.ReadLock(streamRequestCtx, filepath) - http.ServeFile(w, r, filepath) + _ = GetInstance().ReadLockManager.ReadLock(streamRequestCtx, fp) + _, filename := filepath.Split(fp) + contentDisposition := mime.FormatMediaType("inline", map[string]string{"filename": filename}) + w.Header().Set("Content-Disposition", contentDisposition) + http.ServeFile(w, r, fp) } func (s *SceneServer) ServeScreenshot(scene *models.Scene, w http.ResponseWriter, r *http.Request) { From 72c9c436be4cd495b5096fa54ac44f41c4edaa01 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 13 Oct 2025 13:13:23 +1100 Subject: [PATCH 048/157] Fix groups not transferring when merging tags (#6127) * Add test for group when merging tags * Fix groups not reallocated when merging tags --- pkg/sqlite/tag.go | 1 + pkg/sqlite/tag_test.go | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 08337616e..ede1fcc2e 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -790,6 +790,7 @@ func (qb *TagStore) Merge(ctx context.Context, source []int, destination int) er imagesTagsTable: imageIDColumn, "performers_tags": "performer_id", "studios_tags": "studio_id", + groupsTagsTable: "group_id", } args = append(args, destination) diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index 5359be785..770f39782 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -931,6 +931,8 @@ func TestTagMerge(t *testing.T) { tagIdxWithGallery, tagIdx1WithGallery, tagIdx2WithGallery, + tagIdx1WithGroup, + tagIdx2WithGroup, } var srcIDs []int for _, idx := range srcIdxs { @@ -1024,6 +1026,18 @@ func TestTagMerge(t *testing.T) { assert.Contains(studioTagIDs, destID) + // ensure group points to new tag + group, err := db.Group.Find(ctx, groupIDs[groupIdxWithTwoTags]) + if err != nil { + return err + } + if err := group.LoadTagIDs(ctx, db.Group); err != nil { + return err + } + groupTagIDs := group.TagIDs.List() + + assert.Contains(groupTagIDs, destID) + return nil }); err != nil { t.Error(err.Error()) From d3f630110135978d6568ced3e151a53d8e6498df Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 13 Oct 2025 13:13:45 +1100 Subject: [PATCH 049/157] Use natural sort for related tags (#6128) --- pkg/sqlite/tables.go | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 3b0cbe094..8d798d913 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -76,7 +76,7 @@ var ( }, fkColumn: imagesTagsJoinTable.Col(tagIDColumn), foreignTable: tagTableMgr, - orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(), + orderBy: tagTableSort, } imagesPerformersTableMgr = &joinTable{ @@ -116,7 +116,7 @@ var ( }, fkColumn: galleriesTagsJoinTable.Col(tagIDColumn), foreignTable: tagTableMgr, - orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(), + orderBy: tagTableSort, } galleriesPerformersTableMgr = &joinTable{ @@ -174,7 +174,7 @@ var ( }, fkColumn: scenesTagsJoinTable.Col(tagIDColumn), foreignTable: tagTableMgr, - orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(), + orderBy: tagTableSort, } scenesPerformersTableMgr = &joinTable{ @@ -282,7 +282,7 @@ var ( }, fkColumn: performersTagsJoinTable.Col(tagIDColumn), foreignTable: tagTableMgr, - orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(), + orderBy: tagTableSort, } performersStashIDsTableMgr = &stashIDTable{ @@ -314,7 +314,7 @@ var ( }, fkColumn: studiosTagsJoinTable.Col(tagIDColumn), foreignTable: tagTableMgr, - orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(), + orderBy: tagTableSort, } studiosStashIDsTableMgr = &stashIDTable{ @@ -331,6 +331,9 @@ var ( idColumn: goqu.T(tagTable).Col(idColumn), } + // formerly: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc() + tagTableSort = goqu.L("COALESCE(tags.sort_name, tags.name) COLLATE NATURAL_CI").Asc() + tagsAliasesTableMgr = &stringTable{ table: table{ table: tagsAliasesJoinTable, @@ -346,7 +349,7 @@ var ( }, fkColumn: tagRelationsJoinTable.Col(tagParentIDColumn), foreignTable: tagTableMgr, - orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(), + orderBy: tagTableSort, } tagsChildTagsTableMgr = *tagsParentTagsTableMgr.invert() @@ -373,7 +376,7 @@ var ( }, fkColumn: groupsTagsJoinTable.Col(tagIDColumn), foreignTable: tagTableMgr, - orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(), + orderBy: tagTableSort, } groupRelationshipTableMgr = &table{ From 6d76fe690b80f0495185e4ee7cca6c0873a6680a Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 13 Oct 2025 13:13:58 +1100 Subject: [PATCH 050/157] Add padding to tag links (#6129) --- ui/v2.5/src/components/Shared/TagLink.tsx | 4 ++-- ui/v2.5/src/index.scss | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/Shared/TagLink.tsx b/ui/v2.5/src/components/Shared/TagLink.tsx index a59ac83cb..e572a76f8 100644 --- a/ui/v2.5/src/components/Shared/TagLink.tsx +++ b/ui/v2.5/src/components/Shared/TagLink.tsx @@ -36,7 +36,7 @@ const SortNameLinkComponent: React.FC = ({ {children} @@ -55,7 +55,7 @@ const CommonLinkComponent: React.FC = ({ children, }) => { return ( - + {children} ); diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 4209abc2c..eedc84c01 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -710,6 +710,15 @@ div.dropdown-menu { margin: 5px; padding: 2px 6px; + // if link, move padding to link to make full tag clickable + &.tag-link { + padding: 0; + + a { + padding: 2px 6px; + } + } + &:hover { cursor: pointer; } From 2e8bc3536f75dc015937eca7bb4bd6f8198123c4 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:29:51 +1100 Subject: [PATCH 051/157] Null check image visual_files (#6136) --- ui/v2.5/src/components/Galleries/GalleryViewer.tsx | 4 ++-- ui/v2.5/src/components/Images/ImageList.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryViewer.tsx b/ui/v2.5/src/components/Galleries/GalleryViewer.tsx index 7ebb679fd..f570f9990 100644 --- a/ui/v2.5/src/components/Galleries/GalleryViewer.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryViewer.tsx @@ -67,8 +67,8 @@ export const GalleryViewer: React.FC = ({ galleryId }) => { images.forEach((image, index) => { let imageData = { src: image.paths.thumbnail!, - width: image.visual_files[0].width, - height: image.visual_files[0].height, + width: image.visual_files[0]?.width ?? 0, + height: image.visual_files[0]?.height ?? 0, tabIndex: index, key: image.id ?? index, loading: "lazy", diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index 4149970b5..093567613 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -72,8 +72,8 @@ const ImageWall: React.FC = ({ image.paths.preview != "" ? image.paths.preview! : image.paths.thumbnail!, - width: image.visual_files[0].width, - height: image.visual_files[0].height, + width: image.visual_files?.[0]?.width ?? 0, + height: image.visual_files?.[0]?.height ?? 0, tabIndex: index, key: image.id, loading: "lazy", From 7b182ac04b997b14954e70397cd0901d620d2dec Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:30:06 +1100 Subject: [PATCH 052/157] Vacuum into database directory then move file if backup dir different (#6137) If the backup directory is not the same directory as the database, then vacuum into the same directory then move it to its destination. This is to prevent issues vacuuming over a network share. --- pkg/sqlite/database.go | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index fce8190d8..fa5d1e877 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -350,12 +350,30 @@ func (db *Database) Backup(backupPath string) (err error) { defer thisDB.Close() } - logger.Infof("Backing up database into: %s", backupPath) - _, err = thisDB.Exec(`VACUUM INTO "` + backupPath + `"`) + // if backup path is not in the same directory as the database, + // then backup to the same directory first, then move to the final location. + // This is to prevent errors if the backup directory is over a network share. + dbDir := filepath.Dir(db.dbPath) + moveAfter := filepath.Dir(backupPath) != dbDir + vacuumOut := backupPath + if moveAfter { + vacuumOut = filepath.Join(dbDir, filepath.Base(backupPath)) + } + + logger.Infof("Backing up database into: %s", vacuumOut) + _, err = thisDB.Exec(`VACUUM INTO "` + vacuumOut + `"`) if err != nil { return fmt.Errorf("vacuum failed: %w", err) } + if moveAfter { + logger.Infof("Moving database backup to: %s", backupPath) + err = os.Rename(vacuumOut, backupPath) + if err != nil { + return fmt.Errorf("moving database backup failed: %w", err) + } + } + return nil } From 05e2fb26be069ddaae98c7e4a3792b1f28a76310 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:31:52 +1100 Subject: [PATCH 053/157] Fix setup wizard issues (#6138) * Correct paths in confirm step * Maintain paths when going back from confirm step --- ui/v2.5/src/components/Setup/Setup.tsx | 41 +++++++++++++++++++------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/ui/v2.5/src/components/Setup/Setup.tsx b/ui/v2.5/src/components/Setup/Setup.tsx index 00228f9b0..ab5411fe1 100644 --- a/ui/v2.5/src/components/Setup/Setup.tsx +++ b/ui/v2.5/src/components/Setup/Setup.tsx @@ -511,16 +511,28 @@ const BlobsSection: React.FC<{ }; const SetPathsStep: React.FC = ({ goBack, next }) => { - const { configuration } = useSetupContext(); + const { configuration, setupState } = useSetupContext(); const [showStashAlert, setShowStashAlert] = useState(false); - const [stashes, setStashes] = useState([]); - const [databaseFile, setDatabaseFile] = useState(""); - const [generatedLocation, setGeneratedLocation] = useState(""); - const [cacheLocation, setCacheLocation] = useState(""); - const [storeBlobsInDatabase, setStoreBlobsInDatabase] = useState(false); - const [blobsLocation, setBlobsLocation] = useState(""); + const [stashes, setStashes] = useState( + setupState.stashes ?? [] + ); + const [databaseFile, setDatabaseFile] = useState( + setupState.databaseFile ?? "" + ); + const [generatedLocation, setGeneratedLocation] = useState( + setupState.generatedLocation ?? "" + ); + const [cacheLocation, setCacheLocation] = useState( + setupState.cacheLocation ?? "" + ); + const [storeBlobsInDatabase, setStoreBlobsInDatabase] = useState( + setupState.storeBlobsInDatabase ?? false + ); + const [blobsLocation, setBlobsLocation] = useState( + setupState.blobsLocation ?? "" + ); const overrideDatabase = configuration?.general.databasePath; const overrideGenerated = configuration?.general.generatedPath; @@ -640,12 +652,19 @@ const StashExclusions: React.FC<{ stash: GQL.StashConfig }> = ({ stash }) => { }; const ConfirmStep: React.FC = ({ goBack, next }) => { - const { configuration, pathDir, pathJoin, pwd, setupState } = - useSetupContext(); + const { + configuration, + pathDir, + pathJoin, + setupState, + homeDirPath, + workingDir, + } = useSetupContext(); + // if unset, means use homeDirPath const cfgFile = setupState.configLocation - ? setupState.configLocation - : pathJoin(pwd, "config.yml"); + ? pathJoin(workingDir, setupState.configLocation) + : pathJoin(homeDirPath, "config.yml"); const cfgDir = pathDir(cfgFile); const stashes = setupState.stashes ?? []; const { From eb816d2e4fa3be7fbee5b27e451c95060aba4b4d Mon Sep 17 00:00:00 2001 From: underprovisioned <234677721+underprovisioned@users.noreply.github.com> Date: Wed, 15 Oct 2025 01:52:40 -0400 Subject: [PATCH 054/157] Sort duplicate scene groups by total filesize descending (#6133) --- .../SceneDuplicateChecker.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx index 2d8114935..d396a01f4 100644 --- a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx +++ b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx @@ -79,7 +79,24 @@ export const SceneDuplicateChecker: React.FC = () => { }, }); - const scenes = data?.findDuplicateScenes ?? []; + const getGroupTotalSize = (group: GQL.SlimSceneDataFragment[]) => { + // Sum all file sizes across all scenes in the group + return group.reduce((groupTotal, scene) => { + const sceneTotal = scene.files.reduce( + (fileTotal, file) => fileTotal + file.size, + 0 + ); + return groupTotal + sceneTotal; + }, 0); + }; + + const scenes = useMemo(() => { + const groups = data?.findDuplicateScenes ?? []; + // Sort by total file size descending (largest groups first) + return [...groups].sort((a, b) => { + return getGroupTotalSize(b) - getGroupTotalSize(a); + }); + }, [data?.findDuplicateScenes]); const { data: missingPhash } = GQL.useFindScenesQuery({ variables: { From e1b3b33c24b968d09c536146a2e01b667b0df4f8 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:52:54 +1100 Subject: [PATCH 055/157] Correctly load generate options when generating from tasks page (#6139) --- .../Settings/Tasks/LibraryTasks.tsx | 49 +++++++++---------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx b/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx index 1cab7cfb6..cb60891fd 100644 --- a/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx @@ -160,30 +160,6 @@ export const LibraryTasks: React.FC = () => { setGenerateOptions(withoutTypename(generate)); } - if (configuration?.general) { - const { general } = configuration; - setGenerateOptions((existing) => ({ - ...existing, - previewOptions: { - ...existing.previewOptions, - previewSegments: - general.previewSegments ?? - existing.previewOptions?.previewSegments, - previewSegmentDuration: - general.previewSegmentDuration ?? - existing.previewOptions?.previewSegmentDuration, - previewExcludeStart: - general.previewExcludeStart ?? - existing.previewOptions?.previewExcludeStart, - previewExcludeEnd: - general.previewExcludeEnd ?? - existing.previewOptions?.previewExcludeEnd, - previewPreset: - general.previewPreset ?? existing.previewOptions?.previewPreset, - }, - })); - } - setConfigRead(true); } }, [configuration, configRead, taskDefaults, loading]); @@ -291,7 +267,30 @@ export const LibraryTasks: React.FC = () => { async function onGenerateClicked() { try { - await mutateMetadataGenerate(generateOptions); + // insert preview options here instead of loading them + const general = configuration?.general; + + await mutateMetadataGenerate({ + ...generateOptions, + previewOptions: { + ...generateOptions.previewOptions, + previewSegments: + general?.previewSegments ?? + generateOptions.previewOptions?.previewSegments, + previewSegmentDuration: + general?.previewSegmentDuration ?? + generateOptions.previewOptions?.previewSegmentDuration, + previewExcludeStart: + general?.previewExcludeStart ?? + generateOptions.previewOptions?.previewExcludeStart, + previewExcludeEnd: + general?.previewExcludeEnd ?? + generateOptions.previewOptions?.previewExcludeEnd, + previewPreset: + general?.previewPreset ?? + generateOptions.previewOptions?.previewPreset, + }, + }); Toast.success( intl.formatMessage( { id: "config.tasks.added_job_to_queue" }, From fbba4f06a97b2cb93dc858ccf88550ebd8e460d9 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:53:08 +1100 Subject: [PATCH 056/157] Correct movies to groups in default menu items (#6140) Fixes unnecessary config migration artifact in new systems --- internal/manager/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 1b627cbdd..9b3b5bf21 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -287,7 +287,7 @@ var ( defaultVideoExtensions = []string{"m4v", "mp4", "mov", "wmv", "avi", "mpg", "mpeg", "rmvb", "rm", "flv", "asf", "mkv", "webm", "f4v"} defaultImageExtensions = []string{"png", "jpg", "jpeg", "gif", "webp"} defaultGalleryExtensions = []string{"zip", "cbz"} - defaultMenuItems = []string{"scenes", "images", "movies", "markers", "galleries", "performers", "studios", "tags"} + defaultMenuItems = []string{"scenes", "images", "groups", "markers", "galleries", "performers", "studios", "tags"} ) type MissingConfigError struct { From 0c5285c949e2c7825903152f79c9ad0b69250ee1 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 15 Oct 2025 17:55:05 +1100 Subject: [PATCH 057/157] Add 0.29 changelog --- .../src/components/Changelog/Changelog.tsx | 10 +++- ui/v2.5/src/docs/en/Changelog/v0290.md | 54 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 ui/v2.5/src/docs/en/Changelog/v0290.md diff --git a/ui/v2.5/src/components/Changelog/Changelog.tsx b/ui/v2.5/src/components/Changelog/Changelog.tsx index ae7937588..ea831d35a 100644 --- a/ui/v2.5/src/components/Changelog/Changelog.tsx +++ b/ui/v2.5/src/components/Changelog/Changelog.tsx @@ -33,6 +33,7 @@ import V0250 from "src/docs/en/Changelog/v0250.md"; import V0260 from "src/docs/en/Changelog/v0260.md"; import V0270 from "src/docs/en/Changelog/v0270.md"; import V0280 from "src/docs/en/Changelog/v0280.md"; +import V0290 from "src/docs/en/Changelog/v0290.md"; import { MarkdownPage } from "../Shared/MarkdownPage"; const Changelog: React.FC = () => { @@ -68,9 +69,9 @@ const Changelog: React.FC = () => { // after new release: // add entry to releases, using the current* fields // then update the current fields. - const currentVersion = stashVersion || "v0.28.1"; + const currentVersion = stashVersion || "v0.29.0"; const currentDate = buildDate; - const currentPage = V0280; + const currentPage = V0290; const releases: IStashRelease[] = [ { @@ -79,6 +80,11 @@ const Changelog: React.FC = () => { page: currentPage, defaultOpen: true, }, + { + version: "v0.28.1", + date: "2025-03-20", + page: V0280, + }, { version: "v0.27.2", date: "2024-10-16", diff --git a/ui/v2.5/src/docs/en/Changelog/v0290.md b/ui/v2.5/src/docs/en/Changelog/v0290.md new file mode 100644 index 000000000..f2c99de4a --- /dev/null +++ b/ui/v2.5/src/docs/en/Changelog/v0290.md @@ -0,0 +1,54 @@ +### ✨ New Features +* Redesigned the scenes page with filter sidebar. ([#5714](https://github.com/stashapp/stash/pull/5714)) +* Added Performers tab to Group details page. ([#5895](https://github.com/stashapp/stash/pull/5895)) +* Added configurable rate limit to stash-box connection options. ([#5764](https://github.com/stashapp/stash/pull/5764)) + + +### 🎨 Improvements +* Revamped the scene and marker wall views. ([#5816](https://github.com/stashapp/stash/pull/5816)) +* Added zoom functionality to wall views. ([#6011](https://github.com/stashapp/stash/pull/6011)) +* Added search term field to the Edit Filter dialog. ([#6082](https://github.com/stashapp/stash/pull/6082)) +* Added load and save filter buttons to the Edit Filter dialog. ([#6092](https://github.com/stashapp/stash/pull/6092)) +* Restyled UI error messages. ([#5813](https://github.com/stashapp/stash/pull/5813)) +* Changed default modifier of `path` criterion to `includes` instead of `equals`. ([#5968](https://github.com/stashapp/stash/pull/5968)) +* Added internationalisation to login page. ([#5765](https://github.com/stashapp/stash/pull/5765)) +* Added Performer and Tag popovers to scene edit page. ([#5739](https://github.com/stashapp/stash/pull/5739)) +* Tags are now sorted by name in scrape and merge dialogs. ([#5752](https://github.com/stashapp/stash/pull/5752)) +* Related stash-box is now shown with IDs in tagger view. ([#5879](https://github.com/stashapp/stash/pull/5879)) +* UI now navigates to previous page when deleting an item. ([#5818](https://github.com/stashapp/stash/pull/5818)) +* All URLs will now be submitted when submitting a draft to stash-box. ([#5894](https://github.com/stashapp/stash/pull/5894)) +* Made funscript parsing more fault tolerant. ([#5978](https://github.com/stashapp/stash/pull/5978)) +* Added link to gallery in image lightbox. ([#6012](https://github.com/stashapp/stash/pull/6012)) +* Provide correct filename when downloading scene video. ([#6119](https://github.com/stashapp/stash/pull/6119)) +* Support hardware next/previous keys for scene navigation. ([#5553](https://github.com/stashapp/stash/pull/5553)) +* Duplicate checker now sorts largest file groups first. ([#6133](https://github.com/stashapp/stash/pull/6133)) +* Show gallery cover in Gallery edit panel. ([#5935](https://github.com/stashapp/stash/pull/5935)) +* Backups will now be created in the same directory as the database, then moved to the configured backup directory. This avoids potential corruption when backing up over a network share. ([#6137](https://github.com/stashapp/stash/pull/6137)) +* Added graphql playground link to tools panel. ([#5807](https://github.com/stashapp/stash/pull/5807)) +* Include IP address in login errors in log. ([#5760](https://github.com/stashapp/stash/pull/5760)) + +### 🐛 Bug fixes +* Fixed ordering studios by tag count returning error. ([#5776](https://github.com/stashapp/stash/pull/5776)) +* Fixed error when submitting fingerprints for scenes that have been deleted. ([#5799](https://github.com/stashapp/stash/pull/5799)) +* Fixed errors when scraping groups. ([#5793](https://github.com/stashapp/stash/pull/5793), [#5974](https://github.com/stashapp/stash/pull/5974)) +* Fixed UI crash when viewing a gallery in the Performer details page. ([#5824](https://github.com/stashapp/stash/pull/5824)) +* Fixed scraped performer stash ID being saved when cancelling scrape operation. ([#5839](https://github.com/stashapp/stash/pull/5839)) +* Fixed groups not transferring when merging tags. ([#6127](https://github.com/stashapp/stash/pull/6127)) +* Fixed empty exclusion patterns being applied when scanning and cleaning. ([#6023](https://github.com/stashapp/stash/pull/6023)) +* Fixed login page being included in browser history. ([#5747](https://github.com/stashapp/stash/pull/5747)) +* Fixed gallery card resizing while scrubbing. ([#5844](https://github.com/stashapp/stash/pull/5844)) +* Fixed incorrectly positioned scene markers in the scene player timeline. ([#5801](https://github.com/stashapp/stash/pull/5801), [#5804](https://github.com/stashapp/stash/pull/5804)) +* Fixed custom fields not being displayed in Performer page with `Compact Expanded Details` enabled. ([#5833](https://github.com/stashapp/stash/pull/5833)) +* Fixed issue in tagger where creating a parent studio would not map it to the other results. ([#5810](https://github.com/stashapp/stash/pull/5810), [#5996](https://github.com/stashapp/stash/pull/5996)) +* Fixed generation options not being respected when generating using the Tasks page. ([#6139](https://github.com/stashapp/stash/pull/6139)) +* Related tags are now ordered by name. ([#5945](https://github.com/stashapp/stash/pull/5945)) +* Fixed error message not being displayed when failing at startup. ([#5798](https://github.com/stashapp/stash/pull/5798)) +* Fixed incorrect paths in confirm step of the setup wizard. ([#6138](https://github.com/stashapp/stash/pull/6138)) +* Fixed values being lost when navigating back from the confirmation step of the setup wizard. ([#6138](https://github.com/stashapp/stash/pull/6138)) +* Fixed incorrect paths generated in HLS when using a reverse proxy prefix. ([#5791](https://github.com/stashapp/stash/pull/5791)) +* Fixed marker preview being deleted when modifying a marker with a duration. ([#5800](https://github.com/stashapp/stash/pull/5800)) +* Fixed marker end seconds not being included in import/export. ([#5777](https://github.com/stashapp/stash/pull/5777)) +* Fixed parent tags missing in export if including dependencies. ([#5780](https://github.com/stashapp/stash/pull/5780)) +* Add short hash of basename when generating export file names to prevent the same filename being generated. ([#5780](https://github.com/stashapp/stash/pull/5780)) +* Fixed invalid studio and performer links in the tagger view. ([#5876](https://github.com/stashapp/stash/pull/5876)) +* Fixed clickable area for tag links. ([#6129](https://github.com/stashapp/stash/pull/6129)) \ No newline at end of file From ce4b86daf5e9088c3c03df16422798f41dfdccab Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 16 Oct 2025 13:15:09 +1100 Subject: [PATCH 058/157] Fix tag order on details pages (#6143) * Fix related tag order * Fix unit tests --- pkg/sqlite/gallery.go | 2 +- pkg/sqlite/gallery_test.go | 2 +- pkg/sqlite/group.go | 2 +- pkg/sqlite/image.go | 2 +- pkg/sqlite/performer.go | 2 +- pkg/sqlite/performer_test.go | 6 +++--- pkg/sqlite/scene.go | 2 +- pkg/sqlite/studio.go | 2 +- pkg/sqlite/tables.go | 3 ++- 9 files changed, 12 insertions(+), 11 deletions(-) diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index ec9b7ae2e..9cfe38b1f 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -155,7 +155,7 @@ var ( }, fkColumn: "tag_id", foreignTable: tagTable, - orderBy: "COALESCE(tags.sort_name, tags.name) ASC", + orderBy: tagTableSortSQL, }, images: joinRepository{ repository: repository{ diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index be1edb687..06d7daf17 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -481,7 +481,7 @@ func Test_galleryQueryBuilder_UpdatePartial(t *testing.T) { CreatedAt: createdAt, UpdatedAt: updatedAt, SceneIDs: models.NewRelatedIDs([]int{sceneIDs[sceneIdxWithGallery]}), - TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithGallery], tagIDs[tagIdx1WithDupName]}), + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGallery]}), PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithGallery], performerIDs[performerIdx1WithDupName]}), }, false, diff --git a/pkg/sqlite/group.go b/pkg/sqlite/group.go index 7f0ff72ca..686bf4e1e 100644 --- a/pkg/sqlite/group.go +++ b/pkg/sqlite/group.go @@ -122,7 +122,7 @@ var ( }, fkColumn: tagIDColumn, foreignTable: tagTable, - orderBy: "COALESCE(tags.sort_name, tags.name) ASC", + orderBy: tagTableSortSQL, }, } ) diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 840720c50..6575ebb91 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -177,7 +177,7 @@ var ( }, fkColumn: tagIDColumn, foreignTable: tagTable, - orderBy: "COALESCE(tags.sort_name, tags.name) ASC", + orderBy: tagTableSortSQL, }, } ) diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index bcb984ffd..1b1f103da 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -189,7 +189,7 @@ var ( }, fkColumn: tagIDColumn, foreignTable: tagTable, - orderBy: "COALESCE(tags.sort_name, tags.name) ASC", + orderBy: tagTableSortSQL, }, stashIDs: stashIDRepository{ repository{ diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index eb1dfbad2..d5d8ce2fa 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -282,7 +282,7 @@ func Test_PerformerStore_Update(t *testing.T) { Weight: &weight, IgnoreAutoTag: ignoreAutoTag, Aliases: models.NewRelatedStrings(aliases), - TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithPerformer], tagIDs[tagIdx1WithDupName]}), + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithPerformer]}), StashIDs: models.NewRelatedStashIDs([]models.StashID{ { StashID: stashID1, @@ -516,7 +516,7 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { Weight: models.NewOptionalInt(weight), IgnoreAutoTag: models.NewOptionalBool(ignoreAutoTag), TagIDs: &models.UpdateIDs{ - IDs: []int{tagIDs[tagIdx1WithPerformer], tagIDs[tagIdx1WithDupName]}, + IDs: []int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithPerformer]}, Mode: models.RelationshipUpdateModeSet, }, StashIDs: &models.UpdateStashIDs{ @@ -563,7 +563,7 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { HairColor: hairColor, Weight: &weight, IgnoreAutoTag: ignoreAutoTag, - TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithPerformer], tagIDs[tagIdx1WithDupName]}), + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithPerformer]}), StashIDs: models.NewRelatedStashIDs([]models.StashID{ { StashID: stashID1, diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index c4a46b23c..0a7829f28 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -201,7 +201,7 @@ var ( }, fkColumn: tagIDColumn, foreignTable: tagTable, - orderBy: "COALESCE(tags.sort_name, tags.name) ASC", + orderBy: tagTableSortSQL, }, performers: joinRepository{ repository: repository{ diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 9217fbcdb..7d93eee28 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -133,7 +133,7 @@ var ( }, fkColumn: tagIDColumn, foreignTable: tagTable, - orderBy: "COALESCE(tags.sort_name, tags.name) ASC", + orderBy: tagTableSortSQL, }, } ) diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 8d798d913..35845d8f5 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -332,7 +332,8 @@ var ( } // formerly: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc() - tagTableSort = goqu.L("COALESCE(tags.sort_name, tags.name) COLLATE NATURAL_CI").Asc() + tagTableSort = goqu.L("COALESCE(tags.sort_name, tags.name) COLLATE NATURAL_CI").Asc() + tagTableSortSQL = "COALESCE(tags.sort_name, tags.name) COLLATE NATURAL_CI ASC" tagsAliasesTableMgr = &stringTable{ table: table{ From 479ad49e81b585ffe486d6ba29a8c81269132c96 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 16 Oct 2025 14:45:29 +1100 Subject: [PATCH 059/157] Add 0.29 release notes (#6144) * Add 0.29 release notes * Add optional release notes to changelog entries --- ui/v2.5/src/components/Changelog/Changelog.tsx | 14 ++++++++++++++ ui/v2.5/src/docs/en/ReleaseNotes/index.ts | 6 ++++++ ui/v2.5/src/docs/en/ReleaseNotes/v0290.md | 3 +++ 3 files changed, 23 insertions(+) create mode 100644 ui/v2.5/src/docs/en/ReleaseNotes/v0290.md diff --git a/ui/v2.5/src/components/Changelog/Changelog.tsx b/ui/v2.5/src/components/Changelog/Changelog.tsx index ea831d35a..5b2732977 100644 --- a/ui/v2.5/src/components/Changelog/Changelog.tsx +++ b/ui/v2.5/src/components/Changelog/Changelog.tsx @@ -34,7 +34,10 @@ import V0260 from "src/docs/en/Changelog/v0260.md"; import V0270 from "src/docs/en/Changelog/v0270.md"; import V0280 from "src/docs/en/Changelog/v0280.md"; import V0290 from "src/docs/en/Changelog/v0290.md"; + +import V020ReleaseNotes from "src/docs/en/ReleaseNotes/v0290.md"; import { MarkdownPage } from "../Shared/MarkdownPage"; +import { FormattedMessage } from "react-intl"; const Changelog: React.FC = () => { const [{ data, loading }, setOpenState] = useChangelogStorage(); @@ -64,6 +67,7 @@ const Changelog: React.FC = () => { date?: string; page: string; defaultOpen?: boolean; + releaseNotes?: string; } // after new release: @@ -79,6 +83,7 @@ const Changelog: React.FC = () => { date: currentDate, page: currentPage, defaultOpen: true, + releaseNotes: V020ReleaseNotes, }, { version: "v0.28.1", @@ -254,6 +259,15 @@ const Changelog: React.FC = () => { setOpenState={setVersionOpenState} defaultOpen={r.defaultOpen} > + {r.releaseNotes && ( +
+

+ +

+ +
+
+ )} ))} diff --git a/ui/v2.5/src/docs/en/ReleaseNotes/index.ts b/ui/v2.5/src/docs/en/ReleaseNotes/index.ts index 8e2f503d4..78e5e4b37 100644 --- a/ui/v2.5/src/docs/en/ReleaseNotes/index.ts +++ b/ui/v2.5/src/docs/en/ReleaseNotes/index.ts @@ -4,6 +4,7 @@ import v0240 from "./v0240.md"; import v0250 from "./v0250.md"; import v0260 from "./v0260.md"; import v0270 from "./v0270.md"; +import v0290 from "./v0290.md"; export interface IReleaseNotes { // handle should be in the form of YYYYMMDD @@ -13,6 +14,11 @@ export interface IReleaseNotes { } export const releaseNotes: IReleaseNotes[] = [ + { + date: 20251026, + version: "v0.29.0", + content: v0290, + }, { date: 20240826, version: "v0.27.0", diff --git a/ui/v2.5/src/docs/en/ReleaseNotes/v0290.md b/ui/v2.5/src/docs/en/ReleaseNotes/v0290.md new file mode 100644 index 000000000..4a816edd4 --- /dev/null +++ b/ui/v2.5/src/docs/en/ReleaseNotes/v0290.md @@ -0,0 +1,3 @@ +The Scenes page and related scene list views have been updated with a filter sidebar and a toolbar for filtering and other actions. This design is intended to be applied to other query pages in the following release. The design will be refined based on user feedback. + +You can help steer the direction of this design by providing feedback in the [forum thread](https://discourse.stashapp.cc/t/query-page-redesign-feedback-thread-0-29/3935). \ No newline at end of file From 13953c2fbd048c34bc51050257a422cd0868f6ff Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 16 Oct 2025 18:31:33 +1100 Subject: [PATCH 060/157] Codeberg weblate update (#6145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Indonesian) Currently translated at 44.8% (537 of 1198 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/id/ * Translated using Weblate (Norwegian Bokmål) Currently translated at 20.6% (247 of 1198 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/nb_NO/ * Translated using Weblate (Japanese) Currently translated at 83.9% (1006 of 1198 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ja/ * Translated using Weblate (Turkish) Currently translated at 92.2% (1105 of 1198 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/tr/ * Translated using Weblate (French) Currently translated at 100.0% (1205 of 1205 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/ * Translated using Weblate (Swedish) Currently translated at 100.0% (1205 of 1205 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/sv/ * Translated using Weblate (German) Currently translated at 99.9% (1204 of 1205 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1205 of 1205 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/ * Translated using Weblate (Norwegian Nynorsk) Currently translated at 15.6% (188 of 1205 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/nn/ * Translated using Weblate (Dutch) Currently translated at 70.8% (854 of 1205 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/nl/ * Translated using Weblate (Estonian) Currently translated at 100.0% (1205 of 1205 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/ * Added translation using Weblate (Urdu) * Translated using Weblate (Czech) Currently translated at 100.0% (1205 of 1205 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/cs/ * Translated using Weblate (Turkish) Currently translated at 94.5% (1139 of 1205 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/tr/ * Translated using Weblate (Korean) Currently translated at 100.0% (1205 of 1205 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/ * Translated using Weblate (Catalan) Currently translated at 37.1% (448 of 1205 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ca/ * Translated using Weblate (Dutch) Currently translated at 71.0% (856 of 1205 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/nl/ * Translated using Weblate (Vietnamese) Currently translated at 22.5% (272 of 1205 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/vi/ * Translated using Weblate (English (United States)) Currently translated at 28.4% (343 of 1205 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/en_US/ * Translated using Weblate (Russian) Currently translated at 95.9% (1156 of 1205 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ru/ * Translated using Weblate (Japanese) Currently translated at 84.4% (1018 of 1205 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ja/ * Translated using Weblate (Vietnamese) Currently translated at 34.1% (412 of 1205 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/vi/ * Translated using Weblate (Vietnamese) Currently translated at 56.2% (678 of 1205 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/vi/ * Translated using Weblate (Urdu) Currently translated at 0.2% (3 of 1205 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ur/ * Translated using Weblate (French) Currently translated at 100.0% (1205 of 1205 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/ * Update translation files Updated by "Cleanup translation files" add-on in Weblate. Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ * Translated using Weblate (French) Currently translated at 100.0% (1208 of 1208 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/ * Translated using Weblate (Norwegian Bokmål) Currently translated at 21.2% (257 of 1208 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/nb_NO/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1208 of 1208 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/ * Translated using Weblate (French) Currently translated at 100.0% (1209 of 1209 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/ * Translated using Weblate (Korean) Currently translated at 100.0% (1209 of 1209 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/ * Translated using Weblate (Norwegian Bokmål) Currently translated at 23.2% (281 of 1209 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/nb_NO/ * Translated using Weblate (Dutch) Currently translated at 72.7% (880 of 1209 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/nl/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1209 of 1209 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/ * Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 99.1% (1199 of 1209 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hant/ * Translated using Weblate (Norwegian Bokmål) Currently translated at 24.3% (294 of 1209 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/nb_NO/ * Translated using Weblate (Norwegian Bokmål) Currently translated at 24.3% (294 of 1209 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/nb_NO/ * Translated using Weblate (Latvian) Currently translated at 11.9% (145 of 1209 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/lv/ * Translated using Weblate (Romanian) Currently translated at 36.0% (436 of 1209 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ro/ * Translated using Weblate (Czech) Currently translated at 100.0% (1209 of 1209 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/cs/ * Translated using Weblate (French) Currently translated at 100.0% (1213 of 1213 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/ * Translated using Weblate (Japanese) Currently translated at 84.3% (1023 of 1213 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ja/ * Translated using Weblate (French) Currently translated at 100.0% (1215 of 1215 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/ * Translated using Weblate (Swedish) Currently translated at 100.0% (1215 of 1215 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/sv/ * Translated using Weblate (Korean) Currently translated at 100.0% (1215 of 1215 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/ * Translated using Weblate (French) Currently translated at 100.0% (1216 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1216 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/ * Translated using Weblate (Korean) Currently translated at 100.0% (1216 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/ * Translated using Weblate (Vietnamese) Currently translated at 59.6% (725 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/vi/ * Translated using Weblate (Vietnamese) Currently translated at 59.6% (725 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/vi/ * Translated using Weblate (German) Currently translated at 99.0% (1204 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/ * Translated using Weblate (Vietnamese) Currently translated at 60.0% (730 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/vi/ * Translated using Weblate (Estonian) Currently translated at 100.0% (1216 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/ * Translated using Weblate (Turkish) Currently translated at 96.0% (1168 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/tr/ * Translated using Weblate (Polish) Currently translated at 82.3% (1001 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/pl/ * Translated using Weblate (Norwegian Bokmål) Currently translated at 25.0% (304 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/nb_NO/ * Translated using Weblate (German) Currently translated at 100.0% (1216 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/ * Translated using Weblate (Czech) Currently translated at 100.0% (1216 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/cs/ * Translated using Weblate (English (United States)) Currently translated at 28.0% (341 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/en_US/ * Translated using Weblate (Russian) Currently translated at 97.7% (1189 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ru/ * Translated using Weblate (Swedish) Currently translated at 100.0% (1216 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/sv/ * Translated using Weblate (Russian) Currently translated at 100.0% (1216 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ru/ * Translated using Weblate (Persian) Currently translated at 2.5% (31 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fa/ * Translated using Weblate (Dutch) Currently translated at 75.7% (921 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/nl/ * Translated using Weblate (Vietnamese) Currently translated at 60.2% (733 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/vi/ * Translated using Weblate (Japanese) Currently translated at 84.2% (1024 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ja/ * Translated using Weblate (Norwegian Bokmål) Currently translated at 94.6% (1151 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/nb_NO/ * Translated using Weblate (Norwegian Bokmål) Currently translated at 100.0% (1216 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/nb_NO/ * Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (1216 of 1216 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hant/ * Translated using Weblate (Estonian) Currently translated at 100.0% (1219 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/ * Translated using Weblate (French) Currently translated at 100.0% (1219 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/ * Added translation using Weblate (Lithuanian) * Translated using Weblate (Korean) Currently translated at 100.0% (1219 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/ * Translated using Weblate (Lithuanian) Currently translated at 8.6% (105 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/lt/ * Translated using Weblate (German) Currently translated at 100.0% (1219 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/ * Translated using Weblate (Croatian) Currently translated at 20.6% (252 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/hr/ * Translated using Weblate (Czech) Currently translated at 100.0% (1219 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/cs/ * Translated using Weblate (Vietnamese) Currently translated at 64.5% (787 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/vi/ * Translated using Weblate (Indonesian) Currently translated at 47.1% (575 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/id/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1219 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/ * Translated using Weblate (Indonesian) Currently translated at 49.6% (605 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/id/ * Translated using Weblate (Italian) Currently translated at 75.7% (924 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/it/ * Translated using Weblate (Finnish) Currently translated at 80.3% (979 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fi/ * Translated using Weblate (Spanish) Currently translated at 96.8% (1180 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/ * Translated using Weblate (Urdu) Currently translated at 0.8% (10 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ur/ * Translated using Weblate (Czech) Currently translated at 100.0% (1219 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/cs/ * Translated using Weblate (Turkish) Currently translated at 95.8% (1168 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/tr/ * Added translation using Weblate (Bulgarian) * Translated using Weblate (Finnish) Currently translated at 80.5% (982 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fi/ * Translated using Weblate (Bulgarian) Currently translated at 4.1% (51 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/bg/ * Correct new locale filenames * Update language options * Correct error in de-DE * Filter en-US to only different strings --------- Co-authored-by: fafafafa Co-authored-by: boy3satiable Co-authored-by: ynclt Co-authored-by: slickdaddy Co-authored-by: doodoo Co-authored-by: AlpacaSerious Co-authored-by: tzuuuss Co-authored-by: wql219 Co-authored-by: throbbing Co-authored-by: youri Co-authored-by: Zesty6249 Co-authored-by: Lambert99 Co-authored-by: NymeriaCZ Co-authored-by: yec Co-authored-by: burrisol Co-authored-by: Cindicent Co-authored-by: nitromelon Co-authored-by: boxcrunch Co-authored-by: Fl0master1337 Co-authored-by: tobakumap Co-authored-by: dragoncrazy2011 Co-authored-by: CrypticGlycolic Co-authored-by: Codeberg Translate Co-authored-by: bwithnewcast Co-authored-by: COTMO Co-authored-by: danny60718 Co-authored-by: noTranslator Co-authored-by: ℂ𝕠𝕠𝕠𝕝 (𝕘𝕚𝕥𝕙𝕦𝕓.𝕔𝕠𝕞/ℂ𝕠𝕠𝕠𝕝) Co-authored-by: noqqyg Co-authored-by: DJSweder Co-authored-by: m4549071758 Co-authored-by: lugged9922 Co-authored-by: phanh Co-authored-by: krohnoz Co-authored-by: AngryPikachu_025 Co-authored-by: certivian Co-authored-by: Marly21 Co-authored-by: OtterBotSociety Co-authored-by: Schmitd Co-authored-by: mmovahedi Co-authored-by: DNArjen Co-authored-by: nguyenhuy158 Co-authored-by: furinkazan Co-authored-by: Phrotan Co-authored-by: TWNO1 Co-authored-by: Troink Co-authored-by: zo3n Co-authored-by: manhtuanphoto Co-authored-by: om_Yanto Co-authored-by: shanpai Co-authored-by: Uskonalle Co-authored-by: gallegonovato Co-authored-by: jirkacapek123 Co-authored-by: theqwertyqwert Co-authored-by: Ricky-Tigg --- .../SettingsInterfacePanel.tsx | 5 +- ui/v2.5/src/locales/bg-BG.json | 56 + ui/v2.5/src/locales/ca-ES.json | 3 +- ui/v2.5/src/locales/cs-CZ.json | 67 +- ui/v2.5/src/locales/da-DK.json | 1 - ui/v2.5/src/locales/de-DE.json | 52 +- ui/v2.5/src/locales/en-US.json | 17 +- ui/v2.5/src/locales/es-ES.json | 7 +- ui/v2.5/src/locales/et-EE.json | 54 +- ui/v2.5/src/locales/fa-IR.json | 27 +- ui/v2.5/src/locales/fi-FI.json | 109 +- ui/v2.5/src/locales/fr-FR.json | 43 +- ui/v2.5/src/locales/hr-HR.json | 191 ++- ui/v2.5/src/locales/id-ID.json | 169 ++- ui/v2.5/src/locales/index.ts | 6 + ui/v2.5/src/locales/it-IT.json | 4 +- ui/v2.5/src/locales/ja-JP.json | 48 +- ui/v2.5/src/locales/ko-KR.json | 104 +- ui/v2.5/src/locales/lt-LT.json | 112 ++ ui/v2.5/src/locales/lv-LV.json | 71 +- ui/v2.5/src/locales/nb-NO.json | 1325 ++++++++++++++++- ui/v2.5/src/locales/nl-NL.json | 181 ++- ui/v2.5/src/locales/nn-NO.json | 24 +- ui/v2.5/src/locales/pl-PL.json | 8 +- ui/v2.5/src/locales/pt-BR.json | 1 - ui/v2.5/src/locales/ro-RO.json | 52 +- ui/v2.5/src/locales/ru-RU.json | 109 +- ui/v2.5/src/locales/sv-SE.json | 45 +- ui/v2.5/src/locales/th-TH.json | 1 - ui/v2.5/src/locales/tr-TR.json | 165 +- ui/v2.5/src/locales/uk-UA.json | 1 - ui/v2.5/src/locales/ur-PK.json | 14 + ui/v2.5/src/locales/vi-VN.json | 893 ++++++++++- ui/v2.5/src/locales/zh-CN.json | 41 +- ui/v2.5/src/locales/zh-TW.json | 50 +- 35 files changed, 3739 insertions(+), 317 deletions(-) create mode 100644 ui/v2.5/src/locales/bg-BG.json create mode 100644 ui/v2.5/src/locales/lt-LT.json create mode 100644 ui/v2.5/src/locales/ur-PK.json diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 1802fefe7..7d63f9df9 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -200,6 +200,7 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( onChange={(v) => saveInterface({ language: v })} > + @@ -219,7 +220,8 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( - + + @@ -232,6 +234,7 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( + diff --git a/ui/v2.5/src/locales/bg-BG.json b/ui/v2.5/src/locales/bg-BG.json new file mode 100644 index 000000000..3f7e71f3d --- /dev/null +++ b/ui/v2.5/src/locales/bg-BG.json @@ -0,0 +1,56 @@ +{ + "actions": { + "add": "Добави", + "add_directory": "Добави Директория", + "add_entity": "Добави {entityType}", + "add_manual_date": "Добави дата ръчно", + "add_sub_groups": "Добави Подгрупа", + "add_o": "Добави О", + "add_play": "Добави Гледане", + "add_to_entity": "Добави към {entityType}", + "allow": "Позволи", + "allow_temporarily": "Позволи временно", + "anonymise": "Анонимизирай", + "apply": "Приложи", + "auto_tag": "Автоматично Тагване", + "backup": "Резервно копие", + "browse_for_image": "Преглеждане за картина…", + "cancel": "Отмяна", + "choose_date": "Избери дата", + "clean": "Изчисти", + "clean_generated": "Изчисти генерирани файлове", + "clear": "Изчисти", + "clear_back_image": "Изчисти задна картина", + "clear_date_data": "Изчисти дата на данни", + "clear_front_image": "Изчисти предна картина", + "clear_image": "Изичисти картина", + "close": "Затвори", + "confirm": "Потвърди", + "continue": "Продължи", + "copy_to_clipboard": "Копиране в буфера", + "create": "Създай", + "create_chapters": "Създай Глава", + "create_entity": "Създай {entityType}", + "create_marker": "Създай Маркер", + "create_parent_studio": "Създай родителско студио", + "created_entity": "Създаден {entity_type}: {entity_name}", + "customise": "Персонализирай", + "delete": "Изтрий", + "delete_entity": "Изтрий {entityType}", + "delete_file": "Изтрий файла", + "delete_file_and_funscript": "Изтрий файл (и funscript)", + "delete_generated_supporting_files": "Изтрий генерирани поддържащи файлове", + "disable": "Изключи", + "disallow": "Забрани", + "download": "Свали", + "download_anonymised": "Свали анонимизирана", + "download_backup": "Свали резевно копие", + "edit": "Редакция", + "edit_entity": "Редакция {entityType}", + "enable": "Активирай", + "encoding_image": "Кодиране на картина…", + "export": "Експортиране", + "export_all": "Експортирай всичко…", + "reshuffle": "Пренареди" + } +} diff --git a/ui/v2.5/src/locales/ca-ES.json b/ui/v2.5/src/locales/ca-ES.json index 9ad457bc1..3c2452671 100644 --- a/ui/v2.5/src/locales/ca-ES.json +++ b/ui/v2.5/src/locales/ca-ES.json @@ -136,7 +136,8 @@ "add_play": "Agregar reproduir", "add_to_entity": "Afegir a {entityType}", "create_entity": "Crear {entityType}", - "add_sub_groups": "Afegeix subgrups" + "add_sub_groups": "Afegeix subgrups", + "remove_from_containing_group": "Suprimeix del grup" }, "appears_with": "Apareix amb", "career_length": "Durada de la carrera", diff --git a/ui/v2.5/src/locales/cs-CZ.json b/ui/v2.5/src/locales/cs-CZ.json index 622e57706..951c06e26 100644 --- a/ui/v2.5/src/locales/cs-CZ.json +++ b/ui/v2.5/src/locales/cs-CZ.json @@ -6,15 +6,15 @@ "add_to_entity": "Přidat do {entityType}", "allow": "Povolit", "allow_temporarily": "Povolit dočasně", - "apply": "Potvrdit", + "apply": "Použít", "auto_tag": "Auto Tag", "backup": "Záloha", "browse_for_image": "Vybrat obrázek…", "cancel": "Zrušit", "clean": "Vyčistit", "clear": "Vyčistit", - "clear_back_image": "Vymazat zadní obrázek", - "clear_front_image": "Vymazat přední obrázek", + "clear_back_image": "Vymazat obrázek zadní strany", + "clear_front_image": "Vymazat obrázek přední strany", "clear_image": "Vymazat obrázek", "close": "Zavřít", "confirm": "Potvrdit", @@ -106,9 +106,9 @@ "submit_update": "Publikovat aktualizaci", "swap": "Prohodit", "tasks": { - "clean_confirm_message": "Jste si jistý, že chcete vyčistit databázi? Tato operace vymaže informace z databáze a generovaný obsah pro všechny scény a galerie, které se již nenacházejí v souborovém systému.", + "clean_confirm_message": "Chcete doopravdy provést vyčištění databáze? Tato operace vymaže informace z databáze a generovaný obsah pro všechny scény a galerie, které se již nenacházejí v souborovém systému.", "dry_mode_selected": "Vybrán \"Dry Mode\". Nic nebude smazáno, pouze logováno.", - "import_warning": "Jste si jisti, že chcete importovat? Tato operace smaže databázi a znovu importuje z Vašich exportovaných metadat." + "import_warning": "Chcete doopravdy provést import? Tato operace smaže databázi a znovu naimportuje Vaše exportovaná metadata." }, "temp_disable": "Zakázat dočasně…", "temp_enable": "Povolit dočasně…", @@ -129,9 +129,9 @@ "assign_stashid_to_parent_studio": "Přiřaď Stash ID k existujícímu nadřazenému studiu a aktualizuj metadata", "choose_date": "Vyberte datum", "create_chapters": "Vytvořit kapitolu", - "clear_date_data": "Vymazat data datumu", + "clear_date_data": "Vymazat informace o datumech", "reload": "Načíst znovu", - "clean_generated": "Vyčistěte vygenerované soubory", + "clean_generated": "Vyčistit vygenerované soubory", "remove_date": "Odstranit datum", "add_manual_date": "Přidat datum ručně", "add_play": "Přidat přehrávání", @@ -139,9 +139,19 @@ "reset_cover": "Obnovit výchozí obal", "add_sub_groups": "Přidat podskupinu", "set_cover": "Nastavit jako obal", - "remove_from_containing_group": "Odebrat ze skupiny", + "remove_from_containing_group": "Odstranit ze skupiny", "reset_resume_time": "Obnovit čas pokračování", - "reset_play_duration": "Obnovit dobu přehrávání" + "reset_play_duration": "Obnovit dobu přehrávání", + "sidebar": { + "close": "Zavřít postranní panel", + "open": "Zobrazit postranní panel", + "toggle": "Boční panel" + }, + "play": "Přehrát", + "show_results": "Zobrazit výsledky", + "show_count_results": "Zobrazit {count} výsledků", + "load": "Načíst", + "load_filter": "Načíst filtr" }, "actions_name": "Akce", "age": "Věk", @@ -333,7 +343,7 @@ "description": "Cesta ke spustitelnému souboru pythonu (nejen ke složce). Používá se pro script scrappery a pluginy. Pokud je prázdné, python bude vyřešen z prostředí", "heading": "Cesta k Pythonu" }, - "scraper_user_agent": "Scraper User Agent", + "scraper_user_agent": "User Agent Scraperu", "scraper_user_agent_desc": "User-Agent řetězec používaný při scrapování http požadavků", "scrapers_path": { "description": "Adresář konfiguračních souborů scraperů", @@ -435,7 +445,9 @@ "endpoint": "Koncový bod", "graphql_endpoint": "Koncový bod GraphQL", "name": "Název", - "title": "Stash-box Endpointy" + "title": "Stash-box Endpointy", + "max_requests_per_minute": "Maximální počet dotazů za minutu", + "max_requests_per_minute_description": "Používá základní hodnotu {defaultValue} pokud je nastaven na 0" }, "system": { "transcoding": "Překódování" @@ -561,7 +573,9 @@ "whitespace_chars": "Whitespace znaky", "whitespace_chars_desc": "Tyto znaky v názvu budou nahrazeny prázdným znakem (mezerou)" }, - "scene_tools": "Nástroje pro scény" + "scene_tools": "Nástroje pro scény", + "graphql_playground": "GraphQL hřiště", + "heading": "Nástroje" }, "ui": { "abbreviate_counters": { @@ -710,7 +724,7 @@ "heading": "Automaticky spustit video při přehrávání vybraného" }, "continue_playlist_default": { - "description": "Přehrát následující scénu na seznamu při skončení vida", + "description": "Přehrát následující scénu na seznamu při skončení videa", "heading": "(Výchozí nastavení) Pokračovat v playlistu" }, "show_scrubber": "Zobrazit Scrubber", @@ -840,12 +854,12 @@ "developmentVersion": "Vývojářská verze", "dialogs": { "delete_alert": "Následující {count, plural, one {{singularEntity}} other {{pluralEntity}}} budou permanentně smazány:", - "delete_confirm": "Jste si jisti, že chcete smazat {entityName}?", - "delete_entity_desc": "{count, plural, one {Jste si jisti, že checete smazat toto {singularEntity}? Pokud není soubor rovněž smazán, tato {singularEntity} bude znovu přidána při příštím skenování.} other {Jste si jisti, že chcete smazat tyto {pluralEntity}? Pokud nejsou soubory rovněž smazány, tyto {pluralEntity} budou znovu přidány při příštím skenování.}}", + "delete_confirm": "Chcete doopravdy smazat {entityName}?", + "delete_entity_desc": "{count, plural, one {Doopravdy chcete smazat {singularEntity}? Pokud nebude smazán i soubor, {singularEntity} bude znovu přidán při příštím skenování.} other {Doopravdy chcete smazat tyto {pluralEntity}? Pokud nebudou smazány i soubory, tyto {pluralEntity} budou znovu přidány při příštím skenování.}}", "delete_entity_title": "{count, plural, one {Smazat {singularEntity}} other {Smazat {pluralEntity}}}", "delete_galleries_extra": "…navíc všechny obrazové soubory, které nejsou připojeny k žádné jiné galerii.", "delete_gallery_files": "Smazat složku galerie/zip soubor a jakékoliv obrázky nenapojené na jinou galerii.", - "delete_object_desc": "Jste si jisti, že chcete smazat {count, plural, one {tuto {singularEntity}} other {tyto {pluralEntity}}}?", + "delete_object_desc": "Doopravdy chcete smazat {count, plural, one {{singularEntity}} other {tyto {pluralEntity}}}?", "delete_object_overflow": "…a {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.", "delete_object_title": "Smazat {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "dont_show_until_updated": "Skrýt do příští aktualizace", @@ -878,7 +892,6 @@ "destination": "Cíl", "source": "Zdroj" }, - "overwrite_filter_confirm": "JSte si jisti, že chcete přepsat aktuálně uložený dotaz {entityName}?", "scene_gen": { "force_transcodes": "Vynutit generování Transkódu", "force_transcodes_tooltip": "Ve výchozím nastavení, transkódy jsou generovány pouze tehdy, když video soubor není podporován prohlížečem. V případě aktivování, transkódy budou generovány i v ostatních případech.", @@ -946,7 +959,9 @@ "clear_o_history_confirm": "Opravdu chcete vymazat historii O?", "clear_play_history_confirm": "Opravdu chcete vymazat historii přehrávání?", "delete_entity_simple_desc": "{count, plural, one {Opravdu chcete smazat tuto {singularEntity}?} other {Opravdu chcete smazat tyto {pluralEntity}?}}", - "performers_found": "{count} nalezených účinkujících" + "performers_found": "{count} nalezených účinkujících", + "overwrite_filter_warning": "Uložený filtr \"{entityName}\" bude přepsán.", + "set_default_filter_confirm": "Chcete doopravdy nastavit tento filtr jako výchozí?" }, "chapters": "Kapitoly", "circumcised": "Obřezán", @@ -1012,7 +1027,8 @@ "grid": "Mřížka", "tagger": "Tagger", "wall": "Stěna", - "unknown": "Neznámý" + "unknown": "Neznámý", + "label_current": "Mód zobrazení: {current}" }, "effect_filters": { "aspect": "Aspekt", @@ -1315,7 +1331,9 @@ "saved_filters": "Uložené filtry", "update_filter": "Aktualizovat filtr", "edit_filter": "Upravit filtr", - "name": "Filtr" + "name": "Filtr", + "more_filter_criteria": "+{count} navíc", + "search_term": "Hledaný výraz" }, "ethnicity": "Etnická příslušnost", "existing_value": "Existujicí hodnota", @@ -1522,5 +1540,12 @@ }, "age_on_date": "{age} během produkce", "sort_name": "Seřadit jména", - "eta": "Přibližný čas dokončení" + "eta": "Přibližný čas dokončení", + "login": { + "password": "Heslo", + "invalid_credentials": "Neplatné uživatelské jméno nebo heslo", + "login": "Přihlášení", + "username": "Uživatelské jméno", + "internal_error": "Neočekávaná interní chyba. Podívej se do logu pro více detailů" + } } diff --git a/ui/v2.5/src/locales/da-DK.json b/ui/v2.5/src/locales/da-DK.json index 033f1896e..bdb60fe67 100644 --- a/ui/v2.5/src/locales/da-DK.json +++ b/ui/v2.5/src/locales/da-DK.json @@ -821,7 +821,6 @@ "destination": "Destination", "source": "Kilde" }, - "overwrite_filter_confirm": "Er du sikker på, at du vil overskrive eksisterende gemte forespørgsel {entityName}?", "scene_gen": { "force_transcodes": "Tving omkodningsgenerering", "force_transcodes_tooltip": "Som standard genereres omkoder kun, når videofilen ikke understøttes i browseren. Når det er aktiveret, genereres omkoder, selv når videofilen ser ud til at være understøttet i browseren.", diff --git a/ui/v2.5/src/locales/de-DE.json b/ui/v2.5/src/locales/de-DE.json index df7d089db..045cd46e3 100644 --- a/ui/v2.5/src/locales/de-DE.json +++ b/ui/v2.5/src/locales/de-DE.json @@ -141,7 +141,17 @@ "remove_from_containing_group": "Von Gruppe entfernen", "reset_play_duration": "Spieldauer zurücksetzten", "reset_resume_time": "Fortschritt zurücksetzten", - "add_sub_groups": "Untergruppen hinzufügen" + "add_sub_groups": "Untergruppen hinzufügen", + "play": "Abspielen", + "sidebar": { + "toggle": "Seitenleiste umschalten", + "close": "Seitenleiste schließen", + "open": "Seitenleiste öffnen" + }, + "show_count_results": "Zeige {count} Ergebnisse", + "show_results": "Ergebnisse anzeigen", + "load_filter": "Filter laden", + "load": "Laden" }, "actions_name": "Aktionen", "age": "Alter", @@ -446,7 +456,9 @@ "endpoint": "Endpunkt", "graphql_endpoint": "GraphQL-Endpunkt", "name": "Name", - "title": "Stash-Box-Endpunkte" + "title": "Stash-Box-Endpunkte", + "max_requests_per_minute": "Max requests pro Minute", + "max_requests_per_minute_description": "Verwendet default Wert {defaultValue} wenn auf 0 gesetzt" }, "system": { "transcoding": "Transcodierung" @@ -572,7 +584,9 @@ "whitespace_chars": "Zwischenraumzeichen", "whitespace_chars_desc": "Diese Zeichen werden im Titel durch Zwischenraumzeichen ersetzt" }, - "scene_tools": "Szenen-Tools" + "scene_tools": "Szenen-Tools", + "heading": "Werkzeuge", + "graphql_playground": "GraphQL-Spielplatz" }, "ui": { "abbreviate_counters": { @@ -737,7 +751,8 @@ }, "show_ab_loop_controls": "Die Steuerungselemente des AB-Loop-Plugins anzeigen", "disable_mobile_media_auto_rotate": "Deaktiviere das automatische Drehen von Vollbildmedien auf Mobilgeräten", - "enable_chromecast": "Chromecast aktivieren" + "enable_chromecast": "Chromecast aktivieren", + "show_range_markers": "Zeige Range der Markierungen" } }, "scene_wall": { @@ -905,7 +920,6 @@ "destination": "Ziel", "source": "Quelle" }, - "overwrite_filter_confirm": "Möchten Sie die vorhandene gespeicherte Anfrage {entityName} wirklich überschreiben?", "reassign_entity_title": "{count, plural, one {Weise {singularEntity} neu zu} other {Weise {pluralEntity} neu zu}}}", "reassign_files": { "destination": "Neu zuweisen an" @@ -958,7 +972,9 @@ "unsaved_changes": "Nicht gespeicherte Änderungen. Bist du sicher dass du die Seite verlassen willst?", "clear_play_history_confirm": "Bist du sicher, dass du den Wiedergabeverlauf löschen möchtest?", "performers_found": "{count} Darsteller:innen gefunden", - "clear_o_history_confirm": "Möchten Sie wirklich den O-Verlauf löschen?" + "clear_o_history_confirm": "Möchten Sie wirklich den O-Verlauf löschen?", + "overwrite_filter_warning": "Der gespeicherte Filter \"{entityName}\" wird überschrieben.", + "set_default_filter_confirm": "Sind Sie sicher, dass Sie diesen Filter als Standard festlegen möchten?" }, "dimensions": "Maße", "director": "Regisseur", @@ -968,7 +984,8 @@ "list": "Liste", "tagger": "Tagger", "unknown": "Unbekannt", - "wall": "Wand" + "wall": "Wand", + "label_current": "Anzeigemodus: {current}" }, "donate": "Spenden", "dupe_check": { @@ -1115,7 +1132,7 @@ "interactive_speed": "Interaktive Geschwindigkeit", "performer_card": { "age": "{age} {years_old}", - "age_context": "{age} {years_old} in dieser Szene" + "age_context": "{age} {years_old} bei der Produktion" }, "phash": "PHashwert", "play_count": "Anzahl Wiedergaben", @@ -1218,7 +1235,9 @@ "edit_filter": "Filter editieren", "name": "Filter", "saved_filters": "Gespeicherte Filter", - "update_filter": "Filter aktualisieren" + "update_filter": "Filter aktualisieren", + "more_filter_criteria": "+{count} mehr", + "search_term": "Suchbegriff" }, "second": "Sekunde", "seconds": "Sekunden", @@ -1484,7 +1503,9 @@ "custom_fields": { "title": "Benutzerdefinierte Felder", "value": "Wert", - "field": "Feld" + "field": "Feld", + "criteria_format_string": "{criterion} (custom field) {modifierString} {valueString}", + "criteria_format_string_others": "{criterion} (custom field) {modifierString} {valueString} (+{others} others)" }, "distance": "Distanz", "group_count": "Gruppenanzahl", @@ -1517,5 +1538,14 @@ "tag_sub_tag_tooltip": "Hat Untertags", "include_sub_groups": "Untergruppen einbeziehen", "studio_and_parent": "Studio & Mutterstudio", - "eta": "Edited to add" + "eta": "Edited to add", + "login": { + "login": "Login", + "internal_error": "Unvorhergesehener interner Fehler. Weitere Details in den Logs", + "password": "Passwort", + "invalid_credentials": "Ungültiger Nutzername oder Passwort", + "username": "Benutzername" + }, + "age_on_date": "bei Produktion", + "sort_name": "Namen sortieren" } diff --git a/ui/v2.5/src/locales/en-US.json b/ui/v2.5/src/locales/en-US.json index 1f4f31fc5..7d730601c 100644 --- a/ui/v2.5/src/locales/en-US.json +++ b/ui/v2.5/src/locales/en-US.json @@ -1,13 +1,9 @@ { "actions": { + "anonymise": "Anonymize", + "download_anonymised": "Download anonymized", "customise": "Customize", - "add_sub_groups": "Add Sub-Groups", - "add": "Add", - "add_directory": "Add Directory", - "add_entity": "Add {entityType}", - "add_manual_date": "Add manual date", - "add_o": "Add O", - "add_play": "Add play" + "optimise_database": "Optimize Database" }, "config": { "tools": { @@ -26,5 +22,10 @@ "favourite": "Favorite", "hair_color": "Hair Color", "organized": "Organized", - "performer_favorite": "Performer Favorited" + "performer_favorite": "Performer Favorited", + "component_tagger": { + "config": { + "mark_organized_label": "Mark as Organized on save" + } + } } diff --git a/ui/v2.5/src/locales/es-ES.json b/ui/v2.5/src/locales/es-ES.json index d9585f46e..3fd9ef089 100644 --- a/ui/v2.5/src/locales/es-ES.json +++ b/ui/v2.5/src/locales/es-ES.json @@ -138,7 +138,11 @@ "view_history": "Ver historial", "add_sub_groups": "Añadir subgrupos", "remove_from_containing_group": "Eliminar del grupo", - "reset_play_duration": "Reiniciar la duración de la reproducción" + "reset_play_duration": "Reiniciar la duración de la reproducción", + "load": "Cargar", + "load_filter": "Cargar el filtro", + "play": "Jugar", + "reset_resume_time": "Restablecer el tiempo de reanudación" }, "actions_name": "Acciones", "age": "Edad", @@ -874,7 +878,6 @@ "destination": "Destino", "source": "Fuente" }, - "overwrite_filter_confirm": "¿Estás seguro de sobreescribir la consulta guardada {entityName}?", "scene_gen": { "force_transcodes": "Forzar generación de transcodificación", "force_transcodes_tooltip": "Por defecto las transcodificaciones son solo generadas cuando el archivo de vídeo no es soportado por el navegador. Cuando están habilitadas, las transcodificaciones se generarán incluso cuando el fichero de vídeo sea soportado por el navegador.", diff --git a/ui/v2.5/src/locales/et-EE.json b/ui/v2.5/src/locales/et-EE.json index 0a6173691..d835e54bc 100644 --- a/ui/v2.5/src/locales/et-EE.json +++ b/ui/v2.5/src/locales/et-EE.json @@ -141,7 +141,17 @@ "add_play": "Lisa mängimine", "clear_date_data": "Eemalda kuupäeva andmed", "view_history": "Vaata ajalugu", - "remove_from_containing_group": "Eemalda Grupist" + "remove_from_containing_group": "Eemalda Grupist", + "sidebar": { + "open": "Ava külgriba", + "close": "Sulge külgriba", + "toggle": "Lülita külgriba sisse/välja" + }, + "play": "Esita", + "show_results": "Näita tulemusi", + "show_count_results": "Näita {count} tulemust", + "load": "Lae", + "load_filter": "Lae filter" }, "actions_name": "Tegevused", "age": "Vanus", @@ -441,7 +451,9 @@ "endpoint": "Lõpp-punkt", "graphql_endpoint": "GraphQL lõpp-punkt", "name": "Nimi", - "title": "Stash-kasti Lõpp-punktid" + "title": "Stash-kasti Lõpp-punktid", + "max_requests_per_minute": "Maksimaalne arv päringuid minutis", + "max_requests_per_minute_description": "Kasutab vaikimisi väärtust {defaultValue}, kui on 0" }, "system": { "transcoding": "Ümbertöötlemine" @@ -567,7 +579,9 @@ "whitespace_chars": "Tühikumärgid", "whitespace_chars_desc": "Need märgid asendatakse pealkirjas tühikutega" }, - "scene_tools": "Stseeni Tööriistad" + "scene_tools": "Stseeni Tööriistad", + "graphql_playground": "GraphQL mänguplats", + "heading": "Tööriistad" }, "ui": { "abbreviate_counters": { @@ -732,7 +746,8 @@ "vr_tag": { "description": "VR-nupp kuvatakse ainult selle sildiga stseenide puhul.", "heading": "VR Silt" - } + }, + "show_range_markers": "Näita Vahemiku Märke" } }, "scene_wall": { @@ -900,7 +915,6 @@ "destination": "Sihtkoht", "source": "Allikas" }, - "overwrite_filter_confirm": "Oled kindel, et tahad üle kirjutada juba eksisteerivat päringut {entityName}?", "reassign_entity_title": "{count, plural, one {Määra Ümber {singularEntity}} other {Määra Ümber {pluralEntity}-d/id}}", "reassign_files": { "destination": "Määra Ümber" @@ -953,7 +967,9 @@ "unsaved_changes": "Salvestamata muudatused. Kas soovid kindlasti lahkuda?", "performers_found": "Leiti {count} esinejat", "clear_o_history_confirm": "Kas oled kindel, et soovid puhastada O ajaloo?", - "clear_play_history_confirm": "Kas oled kindel, et soovid puhastada vaatamise ajaloo?" + "clear_play_history_confirm": "Kas oled kindel, et soovid puhastada vaatamise ajaloo?", + "set_default_filter_confirm": "Kas oled kindel, et soovid määrata seda filtrit vaikimisi valikuks?", + "overwrite_filter_warning": "Salvestatud filter \"{entityName}\" kirjutatakse üle." }, "dimensions": "Dimensioonid", "director": "Režissöör", @@ -963,7 +979,8 @@ "list": "Nimekiri", "tagger": "Sildistaja", "unknown": "Teadmata", - "wall": "Sein" + "wall": "Sein", + "label_current": "Kuvarežiim: {current}" }, "donate": "Anneta", "dupe_check": { @@ -1047,7 +1064,7 @@ "filters": "Filtrid", "folder": "Kaust", "framerate": "Kaadrisagedus", - "frames_per_second": "{value} fps", + "frames_per_second": "{value} kaadrit sekundis", "front_page": { "types": { "premade_filter": "Eelsätestatud Filter", @@ -1110,7 +1127,7 @@ "interactive_speed": "Interaktiivne kiirus", "performer_card": { "age": "{age} {years_old}", - "age_context": "{age} selles stseenis {years_old}" + "age_context": "{age} {years_old} filmimisel" }, "phash": "PHash", "play_count": "Esituste Arv", @@ -1210,7 +1227,9 @@ "edit_filter": "Muuda Filtrit", "name": "Filter", "saved_filters": "Salvestatud filtrid", - "update_filter": "Uuenda Filtrit" + "update_filter": "Uuenda Filtrit", + "more_filter_criteria": "+{count} rohkem", + "search_term": "Otsi sõnet" }, "second": "Sekund", "seconds": "Sekundit", @@ -1515,7 +1534,18 @@ "custom_fields": { "field": "Väli", "title": "Kohandatud Väli", - "value": "Väärtus" + "value": "Väärtus", + "criteria_format_string_others": "{criterion} (kohandatud väli) {modifierString} {valueString} (+{others} teist)", + "criteria_format_string": "{criterion} (kohandatud väli) {modifierString} {valueString}" }, - "eta": "ETA" + "eta": "ETA", + "login": { + "username": "Kasutajanimi", + "password": "Parool", + "internal_error": "Ootamatu sisemine viga. Vaata logisid rohkemate detailide jaoks", + "invalid_credentials": "Vale kasutajanimi või parool", + "login": "Logi Sisse" + }, + "age_on_date": "{age} filmimisel", + "sort_name": "Sorteeritud Nimi" } diff --git a/ui/v2.5/src/locales/fa-IR.json b/ui/v2.5/src/locales/fa-IR.json index ed9914f98..25a57a44c 100644 --- a/ui/v2.5/src/locales/fa-IR.json +++ b/ui/v2.5/src/locales/fa-IR.json @@ -5,6 +5,31 @@ "add_entity": "افزودن {entityType}", "add_to_entity": "اضافه‌کردن به {entityType}", "allow": "اجازه دادن", - "allow_temporarily": "به طور موقت اجازه دهید" + "allow_temporarily": "موقتا اجازه دهید", + "add_sub_groups": "افزودن زیرگروه", + "browse_for_image": "پیدا کردن عکس…", + "cancel": "لغو", + "choose_date": "انتخاب تاریخ", + "clean": "پاکسازی", + "create_entity": "ایجاد {entityType}", + "add_manual_date": "افزودن دستی تاریخ", + "add_play": "افزودن نمایش", + "apply": "اعمال", + "backup": "پشتیبان گیری", + "auto_tag": "تگ زدن خودکار", + "clear_front_image": "پاک کردن عکس جلویی", + "clean_generated": "پاکسازی فایل های تولید شده", + "clear": "پاک کردن", + "clear_back_image": "پاک کردن عکس پشتی", + "clear_date_data": "پاک کردن دیتای تاریخ", + "clear_image": "پاک کردن عکس", + "close": "بستن", + "confirm": "تایید", + "continue": "ادامه", + "copy_to_clipboard": "کپی به کلیپبورد", + "create": "ایجاد", + "create_parent_studio": "ایجاد استادیو والد", + "anonymise": "بی نام کردن", + "create_chapters": "ایجاد فصل" } } diff --git a/ui/v2.5/src/locales/fi-FI.json b/ui/v2.5/src/locales/fi-FI.json index f90e06076..d1636230b 100644 --- a/ui/v2.5/src/locales/fi-FI.json +++ b/ui/v2.5/src/locales/fi-FI.json @@ -141,7 +141,17 @@ "add_sub_groups": "Lisää aliryhmiä", "migrate_blobs": "Siirrä blobit", "migrate_scene_screenshots": "Siirrä kohtauksen kuvakaappaukset", - "reset_play_duration": "Nollaa toiston kesto" + "reset_play_duration": "Nollaa toiston kesto", + "load": "Ladataan", + "load_filter": "Lataa filtteri", + "play": "Toista", + "show_results": "Näytä tulokset", + "show_count_results": "Näytä {count} tulosta", + "sidebar": { + "close": "Sulje sivupalkki", + "open": "Avaa sivupalkki", + "toggle": "näytä/piilota sivupalkki" + } }, "actions_name": "Toiminnot", "age": "Ikä", @@ -367,10 +377,12 @@ }, "transcode": { "output_args": { - "desc": "Edistynyt: Lisäargumentit, jotka välitetään ffmpegille ennen tuloskenttää videota luotaessa." + "desc": "Edistynyt: Lisäargumentit, jotka välitetään ffmpegille ennen tuloskenttää videota luotaessa.", + "heading": "FFmpeg:n muunnoksen ulostuloparametrit" }, "input_args": { - "desc": "Edistynyt: Lisäargumentit, jotka välitetään ffmpegille ennen syöttökenttää videota luotaessa." + "desc": "Edistynyt: Lisäargumentit, jotka välitetään ffmpegille ennen syöttökenttää videota luotaessa.", + "heading": "FFmpeg:n muunnoksen syöteparametrit" } }, "ffmpeg_path": { @@ -383,10 +395,12 @@ }, "live_transcode": { "input_args": { - "desc": "Edistynyt: Lisäargumentit, jotka välitetään ffmpegille ennen syöttökenttää, kun videota muutetaan livenä." + "desc": "Edistynyt: Lisäargumentit, jotka välitetään ffmpegille ennen syöttökenttää, kun videota muutetaan livenä.", + "heading": "FFmpeg:n live-muunnoksen syöteparametrit" }, "output_args": { - "desc": "Edistynyt: Lisäargumentit, jotka siirretään ffmpegille ennen lähtökenttää, kun videota muutetaan livenä." + "desc": "Edistynyt: Lisäargumentit, jotka siirretään ffmpegille ennen lähtökenttää, kun videota muutetaan livenä.", + "heading": "FFmpeg:n live-muunnoksen ulostuloparametrit" } } }, @@ -405,7 +419,8 @@ "funscript_heatmap_draw_range_desc": "Piirrä liikealue generoidun lämpökartan y-akselille. Olemassa olevat lämpökartat on luotava uudelleen vaihtamisen jälkeen.", "funscript_heatmap_draw_range": "Sisällytä alue luotuihin lämpökarttoihin", "gallery_cover_regex_desc": "Regexp käytetään kuvaamaan gallerian kansikuvaa", - "gallery_cover_regex_label": "Gallerian kansikuvio" + "gallery_cover_regex_label": "Gallerian kansikuvio", + "heatmap_generation": "Funscript-lämpökarttageneraattori" }, "library": { "exclusions": "Poisjättäminen", @@ -435,7 +450,9 @@ "endpoint": "Päätepiste", "graphql_endpoint": "GraphQL päätepiste", "name": "Nimi", - "title": "Stash-box päätepisteet" + "title": "Stash-box päätepisteet", + "max_requests_per_minute": "Enimmäispyynnöt minuutissa", + "max_requests_per_minute_description": "Käyttää oletusarvoa {defaultValue}, jos se on asetettu 0:ksi" }, "system": { "transcoding": "Transkoodaus" @@ -491,14 +508,21 @@ "sources": "Lähteet", "strategy": "Strategia", "skip_multiple_matches": "Ohita vastaavat, joilla on useampi kuin yksi tulos", - "skip_multiple_matches_tooltip": "Jos tämä ei ole otettu käyttöön ja palautetaan useampi kuin yksi tulos, yksi valitaan satunnaisesti vastaamaan" + "skip_multiple_matches_tooltip": "Jos tämä ei ole otettu käyttöön ja palautetaan useampi kuin yksi tulos, yksi valitaan satunnaisesti vastaamaan", + "skip_single_name_performers": "Ohita yksinimiset esiintyjät ilman tarkennusta", + "skip_single_name_performers_tooltip": "Jos tämä ei ole käytössä, usein geneeriset esiintyjät kuten Samantha tai Olga yhdistetään", + "tag_skipped_matches": "Merkitse ohitetut osumat seuraavalla", + "tag_skipped_matches_tooltip": "Luo tunniste, kuten 'Tunnista: Useita osumia', jota voit suodattaa Scene Tagger -näkymässä ja valita oikean osuman käsin", + "tag_skipped_performer_tooltip": "Luo tunniste, kuten 'Identify: Single Name Performer', jota voit suodattaa Scene Tagger -näkymässä ja valita, miten haluat käsitellä näitä esiintyjiä", + "tag_skipped_performers": "Merkitse ohitetut esiintyjät seuraavalla" }, "import_from_exported_json": "Tuo viedystä JSON -tiedoista, jotka ovat samassa kansiossa kuin metadata. Pyyhkii olemassaolevan tietokannan.", "incremental_import": "Lisäävä tuonti valitusta zip -tiedostosta.", "job_queue": "Tehtäväjono", "maintenance": "Ylläpito", "migrate_blobs": { - "delete_old": "Poista vanhat tiedot" + "delete_old": "Poista vanhat tiedot", + "description": "Siirrä blobit nykyiseen blob-tallennusjärjestelmään. Tämä siirto tulisi suorittaa blob-tallennusjärjestelmän vaihdon jälkeen. Vanhojen tietojen poistaminen siirron jälkeen on valinnainen." }, "migrate_hash_files": "Käytetään kun muutetaan generoitua tiedoston nimeämistiivistettä jo generoitujen tiedostojen uudelleennimeämiseen uuteen muotoon.", "migrations": "Migraatiot", @@ -516,12 +540,26 @@ "previews_desc": "Kohtauksien esikatselut ja pienoiskuvat", "blob_files": "Blob-tiedostot", "description": "Poistaa luodut tiedostot ilman vastaavaa tietokantatietuetta.", - "image_thumbnails": "Kuvien pikkukuvat" + "image_thumbnails": "Kuvien pikkukuvat", + "image_thumbnails_desc": "Kuvien pikkukuvat ja pätkät", + "sprites": "Kohtauksien sprite-kuvat", + "transcodes": "Kohtauksien muunnokset" }, "anonymising_database": "Anonymisoidaan tietokantaa", "anonymise_database": "Tekee kopion tietokannasta varmuuskopioiden hakemistoon anonymisoimalla kaikki arkaluontoiset tiedot. Tämä voidaan sitten tarjota muille vianmääritys- ja viankorjaustarkoituksiin. Alkuperäistä tietokantaa ei ole muokattu. Anonymisoitu tietokanta käyttää tiedostonimimuotoa {filename_format}.", "generate_sprites_during_scan_tooltip": "Videosoittimen alla näkyvät kuvat navigoinnin helpottamiseksi.", - "generate_video_covers_during_scan": "Luo kohtausten kannet" + "generate_video_covers_during_scan": "Luo kohtausten kannet", + "generate_clip_previews_during_scan": "Luo esikatselukuvat kuvaklippejä varten", + "generate_sprites_during_scan": "Luo pyyhkijän kuvasarjat", + "migrate_scene_screenshots": { + "delete_files": "Poista näyttötallenteiden tiedostot", + "description": "Siirrä kohtauksen näyttökuvat uuteen blob-tallennusjärjestelmään. Tämä siirto tulisi suorittaa olemassa olevan järjestelmän päivittämisen jälkeen versioon 0.20. Vanhojen näyttökuvien poistaminen siirron jälkeen on valinnainen.", + "overwrite_existing": "Korvaa olemassa olevat blobit näyttötallennetiedoilla" + }, + "optimise_database": "Yritä parantaa suorituskykyä analysoimalla ja koko tietokantatiedoston uudelleen rakentamalla.", + "optimise_database_warning": "Varoitus: tämän tehtävän ollessa käynnissä kaikki tietokantaa muokkaavat toiminnot epäonnistuvat, ja tietokannan koosta riippuen suoritus voi kestää useita minuutteja. Tarvitset lisäksi vähintään yhtä paljon vapaata levytilaa kuin tietokantasi koko on, mutta 1,5-kertainen määrä on suositeltavaa.", + "rescan": "Uudelleenskannaa tiedostot", + "rescan_tooltip": "Uudelleenskannaa kaikki tiedostot polussa. Käytetään tiedoston metatietojen pakolliseen päivitykseen ja zip-tiedostojen uudelleenskannaukseen." }, "tools": { "scene_duplicate_checker": "Kohtauksien kaksoiskappaleiden tarkistus", @@ -533,9 +571,16 @@ "ignore_organized": "Jätä järjestellyt kohtaukset huomiotta", "ignored_words": "Huomiotta jätetyt sanat", "matches_with": "Täsmää seuraavan kanssa {i}", - "whitespace_chars_desc": "Nämä merkit korvataan välilyönnillä otsikossa" + "whitespace_chars_desc": "Nämä merkit korvataan välilyönnillä otsikossa", + "escape_chars": "Käytä \\ merkin edessä, kun haluat käsitellä merkin kirjaimellisena merkkinä", + "filename_pattern": "Tiedostonimen malli", + "select_parser_recipe": "Valitse jäsentämisen ohjeistus, joka määrittää tiedon purkamisen ja käsittelyn", + "title": "Kohteen tiedostonimen jäsentäjä", + "whitespace_chars": "välilyöntimerkit" }, - "scene_tools": "Kohtauksen työkalut" + "scene_tools": "Kohtauksen työkalut", + "graphql_playground": "GraphQL-kokeiluympäristö", + "heading": "Työkalut" }, "ui": { "basic_settings": "Perusasetukset", @@ -551,7 +596,8 @@ }, "custom_locales": { "heading": "Mukautettu lokalisointi", - "option_label": "Mukautettu lokalisointi käytössä" + "option_label": "Mukautettu lokalisointi käytössä", + "description": "Ylikirjoita yksittäisiä paikallisia merkkijonoja. Katso https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json master-lista. Sivun lataus on tehtävä uudelleen, jotta muutokset tulevat voimaan." }, "delete_options": { "description": "Oletusasetukset kun poistetaan kuvia, gallerioita ja kohtauksia.", @@ -580,7 +626,8 @@ "options": { "full": "Kokonainen", "half": "Puolikas", - "quarter": "Neljäsosa" + "quarter": "Neljäsosa", + "tenth": "Kymmenes" } }, "type": { @@ -590,6 +637,9 @@ "stars": "Tähdet" } } + }, + "max_options_shown": { + "label": "Valintavalikoissa näytettävien kohteiden enimmäismäärä" } }, "funscript_offset": { @@ -687,13 +737,33 @@ } } }, - "title": "Käyttöliittymä" + "title": "Käyttöliittymä", + "abbreviate_counters": { + "description": "Lyhennä lukujen esitystapaa korteissa ja yksityiskohtien näkymissä, esimerkiksi luku \"1831\" esitetään muodossa \"1,8K\".", + "heading": "Lukujen esitysmuodon lyhentäminen" + }, + "detail": { + "compact_expanded_details": { + "description": "Kun tämä asetus on otettu käyttöön, se näyttää laajennetut tiedot säilyttäen samalla kompaktin esityksen.", + "heading": "Tiivistetyt laajennetut tiedot" + }, + "enable_background_image": { + "description": "Näytä taustakuva yksityiskohtasivulla.", + "heading": "Ota taustakuva käyttöön" + }, + "heading": "Lisätietosivu", + "show_all_details": { + "description": "Kun tämä on otettu käyttöön, kaikki sisällön tiedot näytetään oletuksena ja jokainen tietoelementti mahtuu yhden sarakkeen alle.", + "heading": "Näytä kaikki tiedot" + } + } }, "advanced_mode": "Edistynyt tila", "plugins": { "installed_plugins": "Asennetut lisäosat", "available_plugins": "Saatavilla olevat liitännäiset", - "hooks": "Koukut" + "hooks": "Koukut", + "triggers_on": "Aktivoituu kun" } }, "configuration": "Konfiguraatio", @@ -784,7 +854,6 @@ "destination": "Kohde", "source": "Lähde" }, - "overwrite_filter_confirm": "Haluatko varmasti ylikirjoittaa jo olemassaolevan {entityName}?", "scene_gen": { "force_transcodes": "Pakota transkoodaus", "force_transcodes_tooltip": "Oletuksena transkoodaus tehdään vain, mikäli selain ei tue videotiedostoa. Jos tämä valinta on päällä, transkoodaus tehdään vaikka selain näyttäisi tukevan videotiedostoa.", @@ -793,7 +862,7 @@ "marker_image_previews": "Animoidut merkkien esikatselukuvat", "marker_image_previews_tooltip": "Animoidut merkkien WebP esikatselut, vaaditaan vain jos esikatselun tyypiksi on valittu Animoitu kuva.", "marker_screenshots": "Merkkien esikatselukuvat", - "marker_screenshots_tooltip": "Merkkien staattinen JPG -kuva, vaadittu vain jos esikatselutyypiksi on asetettu staattinen kuva.", + "marker_screenshots_tooltip": "Staattiset JPG-kuvat merkintöihin", "markers": "Merkkien esikatselut", "markers_tooltip": "20 sekunnin video jokaisen aikakoodin alusta.", "override_preview_generation_options": "Ohita Esikatselun generoinnin asetukset", @@ -942,7 +1011,7 @@ "interactive_speed": "Interaktiivinen nopeus", "performer_card": { "age": "{age} {years_old}", - "age_context": "{age} {years_old} tässä kohtauksessa" + "age_context": "{age} {years_old} tuotantovaiheessa" }, "phash": "PHash", "play_count": "Toistokerrat", diff --git a/ui/v2.5/src/locales/fr-FR.json b/ui/v2.5/src/locales/fr-FR.json index 3e9e74648..997a672be 100644 --- a/ui/v2.5/src/locales/fr-FR.json +++ b/ui/v2.5/src/locales/fr-FR.json @@ -141,7 +141,17 @@ "reset_resume_time": "Réinitialiser le temps de reprise", "set_cover": "Définir comme vignette", "remove_from_containing_group": "Supprimer du groupe", - "add_sub_groups": "Ajouter des groupes affiliés" + "add_sub_groups": "Ajouter des groupes affiliés", + "sidebar": { + "close": "Fermer la barre latérale", + "open": "Ouvrir la barre latérale", + "toggle": "Barre latérale" + }, + "show_count_results": "Afficher {count} résultats", + "show_results": "Afficher les résultats", + "play": "Lecture", + "load": "Charger", + "load_filter": "Charger un filtre" }, "actions_name": "Actions", "age": "Âge", @@ -447,7 +457,9 @@ "endpoint": "Point de terminaison", "graphql_endpoint": "Point de terminaison GraphQL", "name": "Nom", - "title": "Points de terminaison Stash-Box" + "title": "Points de terminaison Stash-Box", + "max_requests_per_minute": "Requêtes maximales par minute", + "max_requests_per_minute_description": "Utiliser la valeur par défaut de {defaultValue} si définie à 0" }, "system": { "transcoding": "Transcodage" @@ -573,7 +585,9 @@ "whitespace_chars": "Caractères d'espacement", "whitespace_chars_desc": "Ces caractères seront remplacés par un espace dans le titre" }, - "scene_tools": "Outils de scène" + "scene_tools": "Outils de scène", + "heading": "Outils", + "graphql_playground": "Implémentation GraphQL" }, "ui": { "abbreviate_counters": { @@ -907,7 +921,6 @@ "destination": "Destination", "source": "Source" }, - "overwrite_filter_confirm": "Êtes-vous sûr de vouloir remplacer la requête sauvegardée existante {entityName} ?", "performers_found": "{count} performeurs trouvés", "reassign_entity_title": "{count, plural, one {Réaffecté {singularEntity}} other {Réaffectés {pluralEntity}}}", "reassign_files": { @@ -960,7 +973,9 @@ "set_image_url_title": "URL de l'image", "unsaved_changes": "Modifications non sauvegardées. Vous êtes sûr de vouloir quitter ?", "clear_o_history_confirm": "Êtes-vous sûr de vouloir effacer l'historique des O ?", - "clear_play_history_confirm": "Êtes-vous sûr de vouloir effacer l'historique de lecture ?" + "clear_play_history_confirm": "Êtes-vous sûr de vouloir effacer l'historique de lecture ?", + "overwrite_filter_warning": "Le filtre enregistré \"{entityName}\" sera remplacé.", + "set_default_filter_confirm": "Êtes-vous sûr de vouloir définir ce filtre par défaut ?" }, "dimensions": "Dimensions", "director": "Réalisateur", @@ -970,7 +985,8 @@ "list": "Liste", "tagger": "Étiqueteuse", "unknown": "Inconnu", - "wall": "Mur" + "wall": "Mur", + "label_current": "Mode d'affichage : {current}" }, "donate": "Faire un don", "dupe_check": { @@ -1114,7 +1130,7 @@ "audio_codec": "Codec audio", "checksum": "Somme de contrôle", "downloaded_from": "Téléchargé depuis", - "hash": "Hachage", + "hash": "Empreinte", "interactive_speed": "Vitesse interactive", "performer_card": { "age": "{age} {years_old}", @@ -1222,7 +1238,9 @@ "edit_filter": "Modifier le filtre", "name": "Filtre", "saved_filters": "Filtres sauvegardés", - "update_filter": "Filtre actualisé" + "update_filter": "Filtre actualisé", + "more_filter_criteria": "+{count} de plus", + "search_term": "Terme recherché" }, "second": "Deuxième", "seconds": "Secondes", @@ -1522,5 +1540,12 @@ }, "eta": "TAE", "sort_name": "Nom de tri", - "age_on_date": "{age} à la production" + "age_on_date": "{age} à la production", + "login": { + "password": "Mot de passe", + "invalid_credentials": "Nom d'utilisateur ou mot de passe incorrect", + "internal_error": "Erreur interne inattendue. Consulter le journal pour plus de détails", + "login": "Identification", + "username": "Nom d'utilisateur" + } } diff --git a/ui/v2.5/src/locales/hr-HR.json b/ui/v2.5/src/locales/hr-HR.json index dbd8deab3..70a5a49fe 100644 --- a/ui/v2.5/src/locales/hr-HR.json +++ b/ui/v2.5/src/locales/hr-HR.json @@ -91,11 +91,198 @@ "swap": "Zamijeni", "tasks": { "clean_confirm_message": "Jeste li sigurni da želite započeti čišćenje? Ovaj će postupak obrisati podatke iz baze podataka i sav generirani sadržaj za sve scene i galerije čije su izvorne datoteke obrisane.", - "dry_mode_selected": "Odabran je probni način rada. Ništa neće biti obrisano, datoteke koje više ne postoje će se samo zapisati u konzolu." + "dry_mode_selected": "Odabran je probni način rada. Ništa neće biti obrisano, datoteke koje više ne postoje će se samo zapisati u konzolu.", + "import_warning": "Da li ste sigurni da želite uvesti? Ovo će izbrisati bazu podataka i ponovno je uvesti iz vaših izvezenih metapodataka." }, "temp_disable": "Privremeno isključi…", "temp_enable": "Privremeno uključi…", "use_default": "Koristi zadane vrijednosti", - "view_random": "Vidi nasumično" + "view_random": "Vidi nasumično", + "add_manual_date": "Dodaj ručni datum", + "add_sub_groups": "Dodaj podgrupu", + "add_o": "Dodaj O", + "add_play": "Dodaj reproduciranje", + "anonymise": "Anonimiziraj", + "assign_stashid_to_parent_studio": "Dodijeli Stash ID na postojeći matični studio i ažuriraj metapodatke", + "choose_date": "Odaberi datum", + "clean_generated": "Očisti generirane datoteke", + "clear_back_image": "Obriši stražnju sliku", + "clear_date_data": "Očisti podatke o datumu", + "clear_front_image": "Očisti prednju sliku", + "copy_to_clipboard": "Kopiraj u međuspremnik", + "create_chapters": "Stvori poglavlje", + "create_parent_studio": "Napravi matični studio", + "customise": "Prilagodi", + "delete_file_and_funscript": "Izbriši datoteku (i funscript)", + "disable": "Onemogući", + "download_anonymised": "Preuzmi anonimno", + "enable": "Omogući", + "encoding_image": "Kodiranje slike…", + "hash_migration": "migracija hash-a", + "load": "Učitaj", + "load_filter": "Učitaj filter", + "make_primary": "Učini Primarnim", + "migrate_blobs": "Spoji Blobs", + "migrate_scene_screenshots": "Migriraj Snimke Zaslona Scene", + "optimise_database": "Optimiziraj Bazu Podataka", + "overwrite": "Prebriši", + "play": "Pokreni", + "reassign": "Preraspodjeli", + "reload": "Ponovno Učitaj", + "reload_scrapers": "Ponovno učitaj scraper-e", + "remove_date": "Ukloni datum", + "remove_from_containing_group": "Ukloni iz Grupe", + "reset_play_duration": "Resetiraj duljinu reprodukcije", + "reset_resume_time": "Resetiraj vrijeme nastavka", + "reset_cover": "Vrati Zadanu Naslovnicu", + "reshuffle": "Promješaj", + "set_back_image": "Stražnja slika…", + "set_cover": "Postavi kao Naslovnicu", + "set_front_image": "Prednja slika…", + "show_results": "Prikaži rezultate", + "show_count_results": "Prikaži {count} rezultata", + "sidebar": { + "close": "Zatvori bočnu traku", + "open": "Otvori bočnu traku", + "toggle": "Uključi/Isključi bočnu traku" + }, + "view_history": "Vidi povijest" + }, + "actions_name": "Radnje", + "age": "Dob", + "age_on_date": "{age} u produkciji", + "aliases": "Pseudonimi", + "all": "sve", + "also_known_as": "Također poznat kao", + "appears_with": "Pojavljuje se sa", + "ascending": "Uzlazno", + "audio_codec": "Audio Kodek", + "average_resolution": "Prosječna Rezolucija", + "between_and": "i", + "birth_year": "Godina Rođenja", + "birthdate": "Datum rođenja", + "bitrate": "Brzina Prijenosa Podataka", + "blobs_storage_type": { + "database": "Baza podataka", + "filesystem": "Datotečni sustav" + }, + "captions": "Natpisi", + "career_length": "Duljina Karijere", + "chapters": "Poglavlja", + "circumcised": "Obrezan", + "circumcised_types": { + "CUT": "Izrezan", + "UNCUT": "Neizrezan" + }, + "component_tagger": { + "config": { + "active_instance": "Aktivna stash-box instanca:", + "blacklist_desc": "Stavke crne liste isključene su iz upita. Imajte na umu da su to regularni izrazi i da nisu osjetljivi na velika i mala slova. Određeni znakovi moraju se izbjeći obrnutom kosom crtom: {chars_require_escape}", + "blacklist_label": "Crna Lista", + "errors": { + "blacklist_duplicate": "Duplikat stavke crne liste" + }, + "mark_organized_desc": "Odmah označi scenu kao Organiziranu nakon što je tipka Spremi stisnuta.", + "mark_organized_label": "Označi kao organizirano pri spremanju", + "query_mode_auto": "Automatski", + "query_mode_auto_desc": "Koristi metapodatke ako postoje, ili ime datoteke", + "query_mode_dir": "Dir", + "query_mode_dir_desc": "Koristi samo nadređeni direktorij video datoteke", + "query_mode_filename": "Ime datoteke", + "query_mode_filename_desc": "Koristi samo ime datoteke", + "query_mode_label": "Query Mod", + "query_mode_metadata": "Metapodaci", + "query_mode_metadata_desc": "Koristi samo metapodatke", + "query_mode_path": "Putanja", + "query_mode_path_desc": "Koristi cijelu putanju datoteke", + "set_cover_desc": "Zamijeni sliku naslovnice scene ako je pronađena.", + "set_cover_label": "Postavi sliku naslovnice scene", + "set_tag_desc": "Priložite oznake sceni, bilo prepisivanjem ili spajanjem s postojećim oznakama na sceni.", + "set_tag_label": "Postavi oznake", + "show_male_desc": "Uključi/isključi mogućnost označavanja muških izvođača.", + "show_male_label": "Prikaži muške izvođače", + "source": "Izvor" + }, + "noun_query": "Upit", + "results": { + "duration_unknown": "Trajanje nepoznato", + "fp_matches": "Trajanje se podudara", + "fp_matches_multi": "Trajanje odgovara otiscima {matchCount}/{durationsLength}", + "hash_matches": "{hash_type} se podudara", + "match_failed_already_tagged": "Scena već označena", + "match_failed_no_result": "Nisu pronađeni rezultati", + "match_success": "Scena uspješno označena", + "unnamed": "Neimenovano" + }, + "verb_matched": "Podudara se", + "verb_toggle_unmatched": "{toggle} neusklađene scene" + }, + "config": { + "about": { + "build_hash": "Izradi hash:", + "build_time": "Vrijeme izrade:", + "check_for_new_version": "Provjeri za novu verziju", + "latest_version": "Zadnja Verzija", + "latest_version_build_hash": "Izrađen Hash Zadnje Verzije:", + "new_version_notice": "[NOVO]", + "release_date": "Datum izdanja:", + "stash_discord": "Pridruži se našem {url} kanalu", + "stash_home": "Stash početna stranica na {url}", + "stash_open_collective": "Podrži nas kroz {url}", + "stash_wiki": "Stash {url} stranica", + "version": "Verzija" + }, + "advanced_mode": "Napredan Način", + "application_paths": { + "heading": "Putanje Aplikacije" + }, + "categories": { + "about": "O nama", + "changelog": "Zapisnik promjena", + "interface": "Sučelje", + "logs": "Zapisnici", + "metadata_providers": "Pružatelji Metapodataka", + "plugins": "Dodaci", + "security": "Sigurnost", + "services": "Usluge", + "system": "Sistem", + "tasks": "Zadaci", + "tools": "Alati" + }, + "dlna": { + "allow_temp_ip": "Dopusti {tempIP}", + "allowed_ip_addresses": "Dopuštene IP adrese", + "allowed_ip_temporarily": "Dopušten IP privremeno", + "default_ip_whitelist": "Zadana bijela lista IP adresa", + "default_ip_whitelist_desc": "Zadane IP adrese omogućuju pristup DLNA. Koristi {wildcard} za dopuštanje svih IP adresa.", + "disabled_dlna_temporarily": "Onemogućen DLNA privremeno", + "disallowed_ip": "Nedopušen IP", + "enabled_by_default": "Omogućeno prema zadanim postavkama", + "enabled_dlna_temporarily": "Omogućen DLNA privremeno", + "network_interfaces": "Sučelja", + "network_interfaces_desc": "Sučelja na kojima će se izložiti DLNA poslužitelj. Prazan popis rezultira pokretanjem na svim sučeljima. Zahtijeva ponovno pokretanje DLNA nakon promjene.", + "recent_ip_addresses": "Nedavne IP adrese", + "server_display_name": "Prikazni Naziv Poslužitelja", + "server_display_name_desc": "Prikazni naziv za DLNA poslužitelj. Zadano je {server_name} ako je prazno.", + "server_port": "Port Poslužitelja", + "server_port_desc": "Port na kojem će se pokretati DLNA poslužitelj. Zahtijeva ponovno pokretanje DLNA nakon promjene.", + "successfully_cancelled_temporary_behaviour": "Uspješno otkazano privremeno ponašanje", + "until_restart": "do ponovnog pokretanja", + "video_sort_order": "Zadani Redoslijed Sortiranja Videozapisa" + }, + "general": { + "auth": { + "api_key": "API Ključ", + "api_key_desc": "API ključ za vanjske sustave. Potreban je samo kada je konfigurirano korisničko ime/lozinka. Korisničko ime mora biti spremljeno prije generiranja API ključa.", + "authentication": "Autentifikacija", + "clear_api_key": "Obriši API ključ", + "credentials": { + "description": "Vjerodajnice za ograničavanje pristupa zalihama.", + "heading": "Vjerodajnice" + }, + "generate_api_key": "Generiraj API ključ", + "log_file": "Datoteka zapisnika" + } + } } } diff --git a/ui/v2.5/src/locales/id-ID.json b/ui/v2.5/src/locales/id-ID.json index a7327508a..ba0c9bca7 100644 --- a/ui/v2.5/src/locales/id-ID.json +++ b/ui/v2.5/src/locales/id-ID.json @@ -3,7 +3,7 @@ "welcome_to_stash": "Selamat datang di Stash", "paths": { "where_is_your_porn_located": "Di mana lokasi porno Anda?", - "description": "Selanjutnya, kita perlu menentukan di mana lokasi koleksi porno Anda, dan di mana menyimpan pangkalan data Stash, berkas yang dihasilkan, dan berkas tembolok. Pengaturan ini dapat diubah nanti jika diperlukan." + "description": "Selanjutnya, kita perlu menentukan di mana lokasi koleksi bokep Anda, dan di mana menyimpan pangkalan data Stash, berkas yang dihasilkan, dan berkas tembolok. Pengaturan ini dapat diubah nanti jika diperlukan." }, "success": { "thanks_for_trying_stash": "Terima kasih sudah mencoba Stash!", @@ -29,7 +29,7 @@ "continue": "Lanjutkan", "copy_to_clipboard": "Salin ke papan klip", "create": "Buat", - "create_chapters": "Buat Bab", + "create_chapters": "Buat Bagian", "create_entity": "Buat {entityType}", "create_marker": "Buat Tanda", "create_parent_studio": "Buat studio induk", @@ -147,7 +147,23 @@ "use_default": "Gunakan bawaan", "view_random": "Lihat Acak", "unset": "Tidak disetel", - "view_history": "Lihat histori" + "view_history": "Lihat histori", + "reset_cover": "Pulihkan Sampul Bawaan", + "set_cover": "Atur sebagai Sampul", + "reset_resume_time": "Atur ulang waktu lanjut", + "reset_play_duration": "Atur ulang durasi putar", + "add_sub_groups": "Tambah Subgrup", + "remove_from_containing_group": "Hapus dari Grup", + "load": "Muat", + "load_filter": "Muat filter", + "play": "Mainkan", + "show_results": "Lihat hasil", + "show_count_results": "Lihat {count} hasil", + "sidebar": { + "close": "Tutup bilah samping", + "open": "Buka bilah samping", + "toggle": "Alihkan bilah samping" + } }, "circumcised_types": { "CUT": "Disunat", @@ -216,7 +232,7 @@ "query_mode_filename_desc": "Hanya menggunakan nama berkas", "query_mode_label": "Mode Kueri", "query_mode_metadata_desc": "Hanya menggunakan metadata", - "query_mode_path": "Jalur", + "query_mode_path": "Lokasi", "query_mode_path_desc": "Menggunakan seluruh jalur berkas", "set_cover_desc": "Ganti gambar adegan jika cover ditemukan.", "set_cover_label": "Tetapkan gambar cover adegan", @@ -224,7 +240,10 @@ "set_tag_desc": "Lampirkan tag ke adegan, baik dengan menimpa atau menggabungkan dengan tag yang sudah ada.", "show_male_desc": "Baeralih apakah pemain pria dapat diberi tag.", "show_male_label": "Tampilkan pemain pria", - "source": "Sumber" + "source": "Sumber", + "errors": { + "blacklist_duplicate": "Duplikat item daftar hitam" + } }, "results": { "duration_unknown": "Durasi tidak diketahui", @@ -280,7 +299,9 @@ "enabled_dlna_temporarily": "DLNA yang diaktifkan sementara", "network_interfaces_desc": "Antarmuka untuk mengekspos server DLNA. Daftar kosong menghasilkan berjalannya di semua antarmuka. Dibutuhkan mulai ulang DLNA setelah perubahan.", "recent_ip_addresses": "Alamat IP terbaru", - "server_display_name": "Nama Tampilan Server" + "server_display_name": "Nama Tampilan Server", + "server_port_desc": "Port untuk menjalankan server DLNA. Harus memulai ulang DLNA setelah diganti.", + "server_port": "Port Server" }, "general": { "logging": "Pencatatan", @@ -322,7 +343,8 @@ "gallery_ext_desc": "Daftar ekstensi berkas yang dibatasi koma yang akan diidentifikasi sebagai berkas zip galeri.", "gallery_ext_head": "Ekstensi zip galeri", "python_path": { - "heading": "Jalur Eksekusi Python" + "heading": "Jalur Eksekusi Python", + "description": "Lokasi executable Python (bukan hanya folder). Digunakan untuk skrip penggali data dan plugin. Jika kosong, Python akan diambil dari environment" }, "scraper_user_agent": "Agen Pengguna Penggali", "db_path_head": "Jalur Pangkalan Data", @@ -359,10 +381,72 @@ "blobs_path": { "description": "Dimana pada sistem berkas untuk menyimpan data biner. Hanya berlaku saat menggunakan jenis penyimpanan blob Sistem Berkas. PERINGATAN: mengubah ini memerlukan pemindahan data yang ada secara manual.", "heading": "Jalur sistem berkas data biner" - } + }, + "check_for_insecure_certificates": "Cek untuk sertifikat tidak aman", + "cache_location": "Direktori lokasi cache. Harus diisi jika streaming menggunakan HLS (seperti pada perangkat Apple) atau DASH.", + "calculate_md5_and_ohash_label": "Kalkulasi MD5 untuk video", + "cache_path_head": "Lokasi Cache", + "calculate_md5_and_ohash_desc": "Kalkulasi checksum MD5 untuk tambahan oshash. Jika diaktifkan akan memperlambat proses scan awal. Hash penamaan file harus diatur menjadi oshash untuk menonaktifkan kalkulasi MD5.", + "check_for_insecure_certificates_desc": "Beberapa situs menggunakan sertifikat ssl yang tidak aman. Jika dinonaktifkan, scraper akan mengabaikan sertifikat yang tidak aman dan akan melanjutkan scraping. Jika anda mendapatkan error saat scraping, nonaktifkan ini.", + "chrome_cdp_path": "Lokasi Chrome CDP", + "chrome_cdp_path_desc": "Lokasi file eksekutabel Chrome, atau alamat remote (dimulai dengan http:// atau https://, sebagai contoh http://localhost:9222/json/version) sebuah instansi Chrome.", + "ffmpeg": { + "live_transcode": { + "output_args": { + "desc": "Lanjutan: Argumen tambahan untuk ditambahkan pada ffmpeg sebelum isian output saat mentranscode video secara langsung.", + "heading": "Argumen untuk Output Transcode Langsung pada FFmpeg" + }, + "input_args": { + "desc": "Lanjutan: Argumen tambahan untuk diteruskan ke ffmpeg sebelum field input saat melakukan transcoding video langsung.", + "heading": "FFmpeg Live Transcode Input Args" + } + }, + "transcode": { + "input_args": { + "desc": "Lanjutan: Argumen tambahan untuk ditambahkann pada ffmpeg sebelum isian saat menghasilkan video.", + "heading": "Argumen Input Transcode FFmpeg" + }, + "output_args": { + "desc": "Lanjutan: Argumen tambahan untuk ditambahkan pada ffmpeg sebelum output saat menghasilkan video.", + "heading": "Argumen Output Transcode FFmpeg" + } + }, + "download_ffmpeg": { + "description": "Mengunduh FFmpeg ke dalam direktori konfigurasi dan mengosongkan lokasi ffmpeg serta ffprobe untuk diambil dari direktori konfigurasi.", + "heading": "Unduh FFmpeg" + }, + "ffmpeg_path": { + "description": "Lokasi ke executable ffmpeg (bukan hanya folder). Jika kosong, ffmpeg akan diambil dari environment melalui $PATH, direktori konfigurasi, atau dari $HOME/.stash", + "heading": "Lokasi Executable FFmpeg" + }, + "ffprobe_path": { + "description": "Lokasi executable ffprobe (bukan hanya folder). Jika kosong, ffprobe akan diambil dari environment melalui $PATH, direktori konfigurasi, atau dari $HOME/.stash", + "heading": "Lokasi Executable FFprobe" + }, + "hardware_acceleration": { + "desc": "Menggunakan perangkat keras yang tersedia untuk melakukan encoding video dalam transcoding langsung.", + "heading": "Encoding perangkat keras FFmpeg" + } + }, + "create_galleries_from_folders_label": "Buat galeri dari folder yang berisi gambar", + "directory_locations_to_your_content": "Lokasi direktori konten Anda", + "create_galleries_from_folders_desc": "Jika true, secara bawaan membuat galeri dari folder yang berisi gambar. Buat sebuah file bernama .forcegallery atau .nogallery di dalam folder untuk memaksa/mencegah hal ini.", + "excluded_image_gallery_patterns_desc": "Regexp file gambar dan galeri/lokasi yang akan dikecualikan dari Pemindaian dan ditambahkan ke Pembersihan", + "excluded_image_gallery_patterns_head": "Pola Gambar/Galeri yang Dikecualikan", + "excluded_video_patterns_desc": "Regexp file video/lokasi yang akan dikecualikan dari Pemindaian dan ditambahkan ke Pembersihan", + "excluded_video_patterns_head": "Pola Video yang Dikecualikan", + "generated_file_naming_hash_desc": "Gunakan MD5 atau oshash untuk penamaan file yang dihasilkan. Mengubah ini memerlukan semua adegan memiliki nilai MD5/oshash yang sesuai. Setelah mengubah nilai ini, file yang sudah dihasilkan perlu dimigrasikan atau dibuat ulang. Lihat halaman Tugas untuk migrasi.", + "image_ext_desc": "Daftar ekstensi file yang dipisahkan koma dan akan dikenali sebagai gambar.", + "plugins_path": { + "description": "Lokasi direktori file konfigurasi plugin", + "heading": "Lokasi Plugin" + }, + "video_ext_desc": "Daftar ekstensi file yang dipisahkan koma dan akan dikenali sebagai video." }, "library": { - "exclusions": "Pengecualian" + "exclusions": "Pengecualian", + "gallery_and_image_options": "Opsi Galeri dan Gambar", + "media_content_extensions": "Ekstensi konten media" }, "tasks": { "identify": { @@ -387,9 +471,11 @@ "markers": "Pratinjau Penanda", "image_thumbnails": "Keluku Gambar", "image_thumbnails_desc": "Keluku dan klip gambar", - "previews": "Pratinjau Adegan" + "previews": "Pratinjau Adegan", + "previews_desc": "Pratinjau adegan dan keluku" }, - "added_job_to_queue": "{operation_name} ditambahkan ke antrean tugas" + "added_job_to_queue": "{operation_name} ditambahkan ke antrean tugas", + "data_management": "Manajemen data" }, "ui": { "editing": { @@ -456,14 +542,16 @@ "supported_types": "Tipe yang didukung", "search_by_name": "Cari berdasarkan nama", "available_scrapers": "Penggali Tersedia", - "entity_scrapers": "Penggali {entityType}" + "entity_scrapers": "Penggali {entityType}", + "excluded_tag_patterns_head": "Pola Tag Terkecualikan" }, "stashbox": { "endpoint": "Titik akhir", "name": "Nama", "graphql_endpoint": "Titik akhir GraphQL", "title": "Titik Akhir Stash-box", - "api_key": "Kunci API" + "api_key": "Kunci API", + "max_requests_per_minute": "Maks permintaan per menit" }, "system": { "transcoding": "Transkode" @@ -472,6 +560,13 @@ "scene_filename_parser": { "filename": "Nama berkas" } + }, + "logs": { + "log_level": "Level Log" + }, + "plugins": { + "available_plugins": "Plugin Tersedia", + "installed_plugins": "Plugin Terinstal" } }, "criterion": { @@ -499,7 +594,8 @@ "options": "Opsi", "scroll_mode": { "zoom": "Perbesar" - } + }, + "page_header": "Halaman {page} / {total}" }, "merge": { "destination": "Destinasi", @@ -514,7 +610,9 @@ "video_previews": "Pratinjau" }, "scrape_results_existing": "Yang sudah ada", - "scrape_results_scraped": "Digali" + "scrape_results_scraped": "Digali", + "performers_found": "{count} pemain ditemukan", + "dont_show_until_updated": "Sembunyikan hingga pembaruan selanjutnya" }, "effect_filters": { "brightness": "Kecerahan", @@ -548,13 +646,16 @@ "uninstall": "Copot pemasangan", "update": "Perbarui", "version": "Versi", - "unknown": "" + "unknown": "", + "installed_version": "Versi Terpasang", + "latest_version": "Versi Terkini" }, "pagination": { "next": "Berikutnya", "previous": "Sebelumnya", "last": "Terakhir", - "first": "Pertama" + "first": "Pertama", + "current_total": "{current} dari {total}" }, "performer": "Pemain", "performers": "Pemain", @@ -650,7 +751,7 @@ }, "instagram": "Instagram", "interactive": "Interaktif", - "library": "Pustaka", + "library": "Koleksi", "loading": { "generic": "Memuat…" }, @@ -660,5 +761,35 @@ "organized": "Terorganisir", "orientation": "Orientasi", "all": "semua", - "ascending": "Urut naik" + "ascending": "Urut naik", + "age_on_date": "{age} saat produksi", + "performer_age": "Umur Pemain", + "performer_count": "Jumlah Pemain", + "performer_favorite": "Pemain Difavorit", + "performer_image": "Foto Pemain", + "performer_tagger": { + "add_new_performers": "Tambah Pemain Baru" + }, + "part_of": "Bagian dari {parent}", + "cover_image": "Foto Sampul", + "death_date": "Tanggal Kematian", + "death_year": "Tahun Kematian", + "last_o_at": "Terakhir Crot Pada", + "last_played_at": "Terakhir Dimainkan Pada", + "login": { + "username": "Nama Pengguna", + "password": "Kata Sandi", + "login": "Masuk", + "invalid_credentials": "Nama pengguna atau kata sandi salah" + }, + "marker_count": "Jumlah Penanda", + "media_info": { + "o_count": "Jumlah Crot", + "performer_card": { + "age": "{age} {years_old}", + "age_context": "{age} {years_old} saat produksi" + }, + "phash": "PHash" + }, + "o_count": "Jumlah Crot" } diff --git a/ui/v2.5/src/locales/index.ts b/ui/v2.5/src/locales/index.ts index 86c1a607e..0e699b8f7 100644 --- a/ui/v2.5/src/locales/index.ts +++ b/ui/v2.5/src/locales/index.ts @@ -2,6 +2,7 @@ import Countries from "i18n-iso-countries"; export const localeCountries = { af: () => import("i18n-iso-countries/langs/af.json"), + bg: () => import("i18n-iso-countries/langs/bg.json"), bn: () => import("i18n-iso-countries/langs/bn.json"), ca: () => import("i18n-iso-countries/langs/ca.json"), cs: () => import("i18n-iso-countries/langs/cs.json"), @@ -20,6 +21,7 @@ export const localeCountries = { it: () => import("i18n-iso-countries/langs/it.json"), ja: () => import("i18n-iso-countries/langs/ja.json"), ko: () => import("i18n-iso-countries/langs/ko.json"), + lt: () => import("i18n-iso-countries/langs/lt.json"), lv: () => import("i18n-iso-countries/langs/lv.json"), nb: () => import("i18n-iso-countries/langs/nb.json"), nl: () => import("i18n-iso-countries/langs/nl.json"), @@ -32,6 +34,7 @@ export const localeCountries = { sv: () => import("i18n-iso-countries/langs/sv.json"), th: () => import("i18n-iso-countries/langs/th.json"), tr: () => import("i18n-iso-countries/langs/tr.json"), + ur: () => import("i18n-iso-countries/langs/ur.json"), uk: () => import("i18n-iso-countries/langs/uk.json"), vi: () => import("i18n-iso-countries/langs/vi.json"), zh: () => import("i18n-iso-countries/langs/zh.json"), @@ -53,6 +56,7 @@ export async function registerCountry(locale: string) { export const localeLoader = { afZA: () => import("./af-ZA.json"), + bgBG: () => import("./bg-BG.json"), bnBD: () => import("./bn-BD.json"), caES: () => import("./ca-ES.json"), csCZ: () => import("./cs-CZ.json"), @@ -72,6 +76,7 @@ export const localeLoader = { itIT: () => import("./it-IT.json"), jaJP: () => import("./ja-JP.json"), koKR: () => import("./ko-KR.json"), + ltLT: () => import("./lt-LT.json"), lvLV: () => import("./lv-LV.json"), nbNO: () => import("./nb-NO.json"), // neNP: () => import("./ne-NP.json"), @@ -85,6 +90,7 @@ export const localeLoader = { svSE: () => import("./sv-SE.json"), thTH: () => import("./th-TH.json"), trTR: () => import("./tr-TR.json"), + urPK: () => import("./ur-PK.json"), ukUA: () => import("./uk-UA.json"), viVN: () => import("./vi-VN.json"), zhCN: () => import("./zh-CN.json"), diff --git a/ui/v2.5/src/locales/it-IT.json b/ui/v2.5/src/locales/it-IT.json index 98269f6d8..349abaabb 100644 --- a/ui/v2.5/src/locales/it-IT.json +++ b/ui/v2.5/src/locales/it-IT.json @@ -135,7 +135,8 @@ "optimise_database": "Ottimizza il Database", "reload": "Ricarica", "remove_date": "Rimuovi la data", - "view_history": "Visualizza cronologia" + "view_history": "Visualizza cronologia", + "add_sub_groups": "Aggiungi Sottogruppi" }, "actions_name": "Azioni", "age": "Età", @@ -744,7 +745,6 @@ "destination": "Destinazione", "source": "Origine" }, - "overwrite_filter_confirm": "Sei sicuro di voler sovrascrivere le esistenti query salvate {entityName}?", "reassign_entity_title": "{count, plural, one {Riassegna {singularEntity}} other {Riassegna {pluralEntity}}}", "reassign_files": { "destination": "Riassegna a" diff --git a/ui/v2.5/src/locales/ja-JP.json b/ui/v2.5/src/locales/ja-JP.json index c8e95e595..005083d6d 100644 --- a/ui/v2.5/src/locales/ja-JP.json +++ b/ui/v2.5/src/locales/ja-JP.json @@ -141,7 +141,15 @@ "set_cover": "カバーをセット", "view_history": "履歴を表示する", "reset_resume_time": "再開時間をリセットする", - "reset_cover": "標準カバーに復元" + "reset_cover": "標準カバーに復元", + "sidebar": { + "close": "サイドバーを閉じる", + "open": "サイドバーを開く", + "toggle": "サイドバーを切り替え" + }, + "play": "再生", + "show_results": "結果を表示", + "show_count_results": "{count}件の結果を表示" }, "actions_name": "操作", "age": "年齢", @@ -435,7 +443,8 @@ "endpoint": "エンドポイント", "graphql_endpoint": "GraphQL エンドポイント", "name": "名前", - "title": "Stash-box エンドポイント" + "title": "Stash-box エンドポイント", + "max_requests_per_minute": "1分あたりの最大リクエスト数" }, "system": { "transcoding": "トランスコード" @@ -718,8 +727,11 @@ "disable_mobile_media_auto_rotate": "モバイル機器でフル画面再生時の画面回転を無効化", "enable_chromecast": "クロームキャスト機能の有効化", "vr_tag": { - "heading": "VRタッグ" - } + "heading": "VRタッグ", + "description": "VRボタンはこのタグがついたシーンにのみ表示されます。" + }, + "show_ab_loop_controls": "ABループプラグインのコントロールを表示", + "show_range_markers": "範囲マーカーを表示" } }, "scene_wall": { @@ -862,7 +874,8 @@ "label": "スクロールモード", "pan_y": "Yにパン", "zoom": "拡大" - } + }, + "page_header": "ページ {page} / {total}" }, "merge": { "destination": "宛先", @@ -873,7 +886,6 @@ "destination": "場所", "source": "ソース" }, - "overwrite_filter_confirm": "本当に保存されているクエリ「{entityName}」を上書きしてもよろしいですか?", "reassign_entity_title": "{count, plural, one {{singularEntity}を再割り当て} other {{pluralEntity}を再割り当て}}", "reassign_files": { "destination": "次に再割り当て:" @@ -911,7 +923,10 @@ "transcodes": "トランスコード", "transcodes_tooltip": "サポートされていない動画フォーマットをMP4に変換します", "video_previews": "プレビュー", - "video_previews_tooltip": "シーンにマウスカーソルを置いた時に再生されるビデオプレビュー" + "video_previews_tooltip": "シーンにマウスカーソルを置いた時に再生されるビデオプレビュー", + "clip_previews": "画像のプレビュー", + "covers": "シーンカバー", + "image_thumbnails": "サムネ画像" }, "scenes_found": "{count}シーンが見つかりました", "scrape_entity_query": "{entity_type}スクレイプクエリ", @@ -919,7 +934,9 @@ "scrape_results_existing": "存在します", "scrape_results_scraped": "スクレイプ済み", "set_image_url_title": "画像URL", - "unsaved_changes": "変更が保存されていません。本当に移動してよろしいですか?" + "unsaved_changes": "変更が保存されていません。本当に移動してよろしいですか?", + "clear_play_history_confirm": "再生履歴を本当に削除しますか?", + "performers_found": "{count}人の出演者が見つかりました" }, "dimensions": "寸法", "director": "監督", @@ -1299,6 +1316,17 @@ "date_format": "YYYY -MM-DD", "datetime_format": "YYYY-MM-DD HH:MM", "criterion_modifier_values": { - "none": "ない" - } + "none": "ない", + "only": "のみ" + }, + "connection_monitor": { + "websocket_connection_failed": "WebSocket接続ができません:詳細はブラウザのコンソールを確認してください" + }, + "custom_fields": { + "field": "フィールド", + "title": "カスタムフィールド", + "value": "値" + }, + "distance": "距離", + "age_on_date": "撮影時の年齢 {age}歳" } diff --git a/ui/v2.5/src/locales/ko-KR.json b/ui/v2.5/src/locales/ko-KR.json index 60f942e82..f022b7790 100644 --- a/ui/v2.5/src/locales/ko-KR.json +++ b/ui/v2.5/src/locales/ko-KR.json @@ -51,7 +51,7 @@ "hash_migration": "해쉬 값 마이그레이션", "hide": "숨기기", "hide_configuration": "설정 숨기기", - "identify": "인증", + "identify": "식별", "ignore": "무시", "import": "불러오기…", "import_from_file": "파일 불러오기", @@ -141,7 +141,17 @@ "reset_play_duration": "재생 시간 초기화", "reset_resume_time": "마지막 재생 위치 초기화", "add_sub_groups": "서브그룹 추가", - "remove_from_containing_group": "그룹에서 제거" + "remove_from_containing_group": "그룹에서 제거", + "sidebar": { + "close": "사이드바 닫기", + "open": "사이드바 열기", + "toggle": "사이드바 토글" + }, + "play": "재생", + "show_results": "결과 표시", + "show_count_results": "{count}개 결과 표시", + "load": "불러오기", + "load_filter": "필터 불러오기" }, "actions_name": "액션", "age": "나이", @@ -191,7 +201,10 @@ "show_male_label": "남성 배우 보여주기", "source": "출처", "mark_organized_desc": "저장 버튼을 클릭하면 곧바로 영상을 '정리됨' 상태로 만듭니다.", - "mark_organized_label": "저장 시 '정리됨' 상태로 만들기" + "mark_organized_label": "저장 시 '정리됨' 상태로 만들기", + "errors": { + "blacklist_duplicate": "블랙리스트 항목이 중복되었습니다" + } }, "noun_query": "쿼리", "results": { @@ -306,7 +319,7 @@ "heading": "바이너리 데이터 저장 타입" }, "cache_location": "캐시 폴더 경로입니다. HLS(애플 기기 등)이나 DASH로 스트리밍할 때 필요합니다.", - "cache_path_head": "캐쉬 경로", + "cache_path_head": "캐시 경로", "calculate_md5_and_ohash_desc": "oshash 외에 MD5 체크섬도 계산합니다. 활성화하면 초기 스캔을 더 느리게 만들 것입니다. MD5 계산을 사용하지 않으려면 파일 이름 해쉬를 oshash로 설정해야 합니다.", "calculate_md5_and_ohash_label": "비디오 MD5 계산하기", "check_for_insecure_certificates": "안전하지 않은 자격증명 검사", @@ -443,7 +456,9 @@ "endpoint": "엔드포인트", "graphql_endpoint": "GraphQL 엔드포인트", "name": "이름", - "title": "Stash-box 엔드포인트" + "title": "Stash-box 엔드포인트", + "max_requests_per_minute": "분당 최대 요청 수", + "max_requests_per_minute_description": "0으로 설정할 경우 기본값({defaultValue})을 사용합니다" }, "system": { "transcoding": "트랜스코딩" @@ -569,7 +584,9 @@ "whitespace_chars": "공백 문자", "whitespace_chars_desc": "이 문자들은 제목에서 공백으로 대체됩니다" }, - "scene_tools": "영상 도구" + "scene_tools": "영상 도구", + "heading": "도구", + "graphql_playground": "GraphQL 플레이그라운드" }, "ui": { "abbreviate_counters": { @@ -726,7 +743,7 @@ "description": "비디오가 끝나면 대기열에 있는 다음 영상을 재생합니다", "heading": "플레이리스트 이어보기" }, - "show_scrubber": "스크러버 보이기", + "show_scrubber": "스크러버 표시", "track_activity": "영상 재생 기록 활성화", "vr_tag": { "description": "VR 버튼은 이 태그를 가진 영상에서만 보여질 것입니다.", @@ -734,7 +751,8 @@ }, "enable_chromecast": "크롬캐스트 활성화", "disable_mobile_media_auto_rotate": "모바일 환경에서 전체화면 시 자동 방향 회전 비활성화", - "show_ab_loop_controls": "구간반복 기능 활성화" + "show_ab_loop_controls": "구간반복 기능 활성화", + "show_range_markers": "범위 마커 표시" } }, "scene_wall": { @@ -834,7 +852,7 @@ "not_null": "값 존재함", "format_string_excludes": "{criterion} {modifierString} {valueString} ({excludedString} 제외)", "format_string_excludes_depth": "{criterion} {modifierString} {valueString} ({excludedString} 제외) (+{depth, plural, =-1 {all} other {{depth}}})", - "format_string_depth": "{criterion} {modifierString} {valueString} (+{depth, plural, =-1 {all} other {{depth}}})" + "format_string_depth": "{criterion} {modifierString} {valueString} (+{depth, plural, =-1 {all} other {{depth}}}수준)" }, "custom": "커스텀", "date": "날짜", @@ -897,7 +915,6 @@ "destination": "다른 태그와 합쳐질 태그", "source": "다른 태그로 합쳐질 태그" }, - "overwrite_filter_confirm": "정말 원래 저장되어 있었던 쿼리 {entityName}을 덮어쓰시겠습니까?", "reassign_entity_title": "{count, plural, one {{singularEntity} 재할당} other {{pluralEntity} 재할당}}", "scene_gen": { "clip_previews": "이미지 클립 미리보기", @@ -910,7 +927,7 @@ "marker_image_previews": "마커 움직이는 이미지 미리보기", "marker_image_previews_tooltip": "애니메이션(webp) 미리보기도 생성합니다. 영상/마커 월 미리보기 유형이 '애니메이션 이미지'로 설정된 경우에만 필요합니다. '비디오 미리보기'보다 CPU를 덜 사용하지만, '비디오 미리보기'에 추가적으로 생성되기 때문에 파일 크기가 커집니다.", "marker_screenshots": "마커 스크린샷", - "marker_screenshots_tooltip": "마커 JPG 이미지. 미리보기 유형이 이미지로 설정된 경우에만 필요합니다.", + "marker_screenshots_tooltip": "마커 고정 JPG 이미지", "markers": "마커 미리보기", "markers_tooltip": "주어진 시간 코드에서 시작하는 20초 짜리 비디오입니다.", "override_preview_generation_options": "미리보기 생성 옵션 재정의", @@ -955,7 +972,9 @@ }, "reassign_files": { "destination": "~으로 재지정" - } + }, + "overwrite_filter_warning": "저장된 필터 \"{entityName}\"은 덮어쓰기될 것입니다.", + "set_default_filter_confirm": "정말로 이 필터를 기본값으로 설정하시겠습니까?" }, "dimensions": "해상도", "director": "감독", @@ -965,7 +984,8 @@ "list": "목록", "tagger": "태거", "unknown": "알 수 없음", - "wall": "월 모드" + "wall": "월 모드", + "label_current": "디스플레이 모드: {current}" }, "donate": "후원", "dupe_check": { @@ -1023,7 +1043,13 @@ "header": "오류", "loading_type": "{type}을(를) 로딩하는 중 오류가 발생했습니다", "invalid_javascript_string": "유효하지 않은 자바스크립트 코드입니다: {error}", - "invalid_json_string": "유효하지 않은 JSON 문자열입니다: {error}" + "invalid_json_string": "유효하지 않은 JSON 문자열입니다: {error}", + "custom_fields": { + "field_name_required": "항목 이름이 필요합니다", + "field_name_whitespace": "항목 이름의 전후에 공백이 없어야 합니다", + "duplicate_field": "항목 이름은 중복될 수 없습니다", + "field_name_length": "항목 이름의 글자 수는 65글자보다 작아야 합니다" + } }, "ethnicity": "인종", "existing_value": "존재하는 값", @@ -1092,7 +1118,8 @@ "last_played_at": "마지막 재생 날짜", "library": "라이브러리", "loading": { - "generic": "로드 중…" + "generic": "로드 중…", + "plugins": "플러그인 로딩 중…" }, "marker_count": "마커 개수", "markers": "마커", @@ -1105,11 +1132,11 @@ "interactive_speed": "인터랙티브 속도", "performer_card": { "age": "{age} {years_old}", - "age_context": "작품에서 {age} {years_old}" + "age_context": "제작 당시 {age} {years_old}" }, "phash": "PHash", - "play_count": "재생 횟수", - "play_duration": "재생 길이", + "play_count": "재생된 횟수", + "play_duration": "재생된 길이", "stream": "스트림", "video_codec": "비디오 코덱", "o_count": "싼 횟수" @@ -1163,21 +1190,21 @@ "network_error": "네트워크 오류", "no_results_found": "결과가 없습니다.", "number_of_performers_will_be_processed": "{performer_count}명의 배우들이 처리됩니다", - "performer_already_tagged": "이 배우에 이미 존재하는 태그입니다", + "performer_already_tagged": "배우가 이미 태그되어 있음", "performer_names_separated_by_comma": "배우 이름 (,으로 구분)", "performer_selection": "배우 선택", "performer_successfully_tagged": "배우 태깅에 성공했습니다:", "query_all_performers_in_the_database": "데이터베이스의 모든 배우", "refresh_tagged_performers": "태그된 배우 새로고침", - "refreshing_will_update_the_data": "새로고침하면 Stash-box 인스턴스에 있는 태그된 배우들의 데이터가 업데이트될 것입니다.", + "refreshing_will_update_the_data": "'새로고침'을 통해, Stash-box 인스턴스로부터 태그된 배우들의 데이터를 업데이트합니다.", "status_tagging_job_queued": "상태: 태그 작업 대기열 추가됨", "status_tagging_performers": "상태: 배우 태그 중", "tag_status": "태그 상태", "to_use_the_performer_tagger": "배우 태거를 사용하기 위해서는 stash-box 인스턴스가 설정되어야 합니다.", "untagged_performers": "태그되지 않은 배우", - "update_performer": "배우 수정", - "update_performers": "배우 수정", - "updating_untagged_performers_description": "태그가 지정되지 않은 배우를 업데이트하면, Stash ID가 없는 배우와 비교해본 뒤 메타데이터를 업데이트할 것입니다." + "update_performer": "배우 업데이트", + "update_performers": "배우 업데이트", + "updating_untagged_performers_description": "'태그되지 않은 배우 업데이트'를 통해, Stash ID가 없는 배우들에 대한 데이터를 찾아보고, 가능하다면 이를 이용해 배우를 업데이트합니다." }, "performer_tags": "배우 태그", "performers": "배우", @@ -1208,14 +1235,16 @@ "edit_filter": "필터 수정", "name": "필터", "saved_filters": "저장된 필터", - "update_filter": "필터 업데이트" + "update_filter": "필터 업데이트", + "more_filter_criteria": "외 {count} 개", + "search_term": "검색어" }, "second": "초", "seconds": "초", "settings": "설정", "setup": { "confirm": { - "almost_ready": "거의 설정을 완료했습니다. 아래 설정들을 확인해주세요. 틀린 내용이 있다면 이전으로 돌아가 변경할 수 있습니다. 내용이 모두 맞다면, '확인'을 눌러 시스템을 만드세요.", + "almost_ready": "거의 설정을 완료했습니다. 아래 설정들을 확인해주세요. 틀린 내용이 있다면 이전으로 돌아가 변경할 수 있습니다. 내용이 모두 맞다면, '확인'을 눌러 시스템을 생성하세요.", "blobs_directory": "바이너리 데이터 경로", "cache_directory": "캐시 파일 경로", "configuration_file_location": "설정 파일 위치:", @@ -1495,5 +1524,28 @@ "include_sub_studio_content": "서브스튜디오 컨텐츠 포함", "include_sub_tag_content": "서브태그 컨텐츠 포함", "time_end": "종료 시간", - "include_sub_groups": "서브그룹 포함" + "include_sub_groups": "서브그룹 포함", + "custom_fields": { + "value": "값", + "field": "항목", + "title": "커스텀 항목", + "criteria_format_string": "{criterion} (커스텀 항목) {modifierString} {valueString}", + "criteria_format_string_others": "{criterion} (커스텀 항목) {modifierString} {valueString} (+{others} 기타)" + }, + "login": { + "password": "비밀번호", + "invalid_credentials": "유효하지 않은 사용자 이름 또는 비밀번호입니다", + "login": "로그인", + "internal_error": "예상치 못한 내부 에러입니다. 로그에서 세부 사항을 확인하세요", + "username": "사용자 이름" + }, + "age_on_date": "제작 당시 {age}살", + "sort_name": "이름 (sort name)", + "criterion_modifier_values": { + "none": "값 없음", + "only": "해당 값만 존재", + "any": "값 존재", + "any_of": "해당 값 중 일부 포함" + }, + "eta": "예상 소요 시간" } diff --git a/ui/v2.5/src/locales/lt-LT.json b/ui/v2.5/src/locales/lt-LT.json new file mode 100644 index 000000000..0007adb26 --- /dev/null +++ b/ui/v2.5/src/locales/lt-LT.json @@ -0,0 +1,112 @@ +{ + "actions": { + "add": "Pridėti", + "add_directory": "Pridėti katalogą", + "add_entity": "Pridėti {entityType}", + "add_manual_date": "Įvesti datą rankini būdu", + "add_sub_groups": "Pridėti pogrupius", + "add_o": "Pridėti O", + "add_to_entity": "Pridėti prie {entityType}", + "allow": "Leisti", + "allow_temporarily": "Leisti laikinai", + "anonymise": "Anonimizuoti", + "apply": "Taikyti", + "assign_stashid_to_parent_studio": "Priskirti Stash ID esamai pagrindinei studijai ir atnaujinti metaduomenis", + "auto_tag": "Auto žymė", + "browse_for_image": "Ieškoti vaizdo…", + "cancel": "Atšaukti", + "choose_date": "Pasirinkti datą", + "clean": "Valyti", + "clean_generated": "Išvalyti sugeneruotus failus", + "clear": "Valyti", + "clear_date_data": "Valyti datos duomenis", + "clear_front_image": "Valyti priekinį vaizdą", + "clear_back_image": "Valyti galinį vaizdą", + "clear_image": "Valyti vaizdą", + "close": "Uždaryti", + "confirm": "Patvirtinti", + "continue": "Tęsti", + "copy_to_clipboard": "Kopijuoti į iškarpinę", + "create": "Sukurti", + "create_chapters": "Sukurti skyrių", + "create_entity": "Sukurti {entityType}", + "create_marker": "Sukurti žymeklį", + "create_parent_studio": "Sukurti pagrindinę studiją", + "created_entity": "Sukurtas {entity_type}: {entity_name}", + "customise": "Tinkinti", + "delete": "Ištrinti", + "delete_entity": "Ištrinti {entityType}", + "delete_file": "Ištrinti failą", + "delete_file_and_funscript": "Ištrinti failą (ir funscript)", + "delete_generated_supporting_files": "Ištrinti sugeneruotus pagalbinius failus", + "disable": "Išjungti", + "disallow": "Neleisti", + "download": "Atsisiųsti", + "download_anonymised": "Atsisiųsti anonimizuotą", + "download_backup": "Atsisiųsti atsarginę kopiją", + "edit": "Redaguoti", + "edit_entity": "Redaguoti {entityType}", + "enable": "Įjungti", + "encoding_image": "Koduojamas vaizdas…", + "export": "Eksportuoti", + "export_all": "Eksportuoti visus…", + "find": "Rasti", + "finish": "Baigti", + "from_file": "Iš failo…", + "from_url": "Iš URL…", + "full_export": "Pilnas eksportas", + "full_import": "Pilnas importas", + "generate": "Generuoti", + "generate_thumb_default": "Sugeneruoti numatytąją miniatiūrą", + "generate_thumb_from_current": "Sugeneruoti miniatiūrą iš dabartinio", + "hide": "Paslėpti", + "hide_configuration": "Paslėpti konfiguraciją", + "identify": "Atpažinti", + "ignore": "Ignoruoti", + "import": "Importuoti…", + "import_from_file": "Importuoti iš failo", + "load": "Krauti", + "load_filter": "Užkrauti filtrą", + "logout": "Atsijungti", + "make_primary": "Padaryti pirminiu", + "merge": "Sulieti", + "merge_from": "Sulieti iš", + "merge_into": "Sulieti į", + "next_action": "Kitas", + "not_running": "Nevykdomas", + "open_in_external_player": "Atidaryti išoriniame grotuve", + "open_random": "Atidaryti atsitiktinį", + "optimise_database": "Optimizuoti duomenų bazę", + "overwrite": "Perrašyti", + "play": "Groti", + "play_random": "Groti atsitiktinį", + "play_selected": "Groti pasirinktą", + "preview": "Peržiūra", + "previous_action": "Atgal", + "reassign": "Priskirti iš naujo", + "refresh": "Atnaujinti", + "reload": "Perkrauti", + "reload_plugins": "Perkrauti papildinius", + "reload_scrapers": "Perkrauti skreperius", + "remove": "Šalinti", + "remove_date": "Šalinti datą", + "remove_from_containing_group": "Šalinti iš grupės", + "remove_from_gallery": "Šalinti iš galerijos", + "rename_gen_files": "Pervadinti sugeneruotus failus", + "rescan": "Pakartotinai nuskaityti", + "reset_play_duration": "Atstatyti grojimo trukmę", + "reset_resume_time": "Atstatyti tęsimo laiką", + "reset_cover": "Atkurti numatytąjį viršelį", + "reshuffle": "Permaišyti", + "running": "Vykdoma", + "save": "Išsaugoti", + "save_delete_settings": "Naudoti šias parinktis kaip numatytąsias trinant", + "save_filter": "Išsaugoti filtrą", + "scan": "Skenuoti", + "search": "Ieškoti", + "select_all": "Pasirinkti visus", + "select_entity": "Pasirinkti {entityType}", + "select_folders": "Pasirinkti katalogus", + "select_none": "Atžymėti viską" + } +} diff --git a/ui/v2.5/src/locales/lv-LV.json b/ui/v2.5/src/locales/lv-LV.json index b90c34df2..e61f8f9ab 100644 --- a/ui/v2.5/src/locales/lv-LV.json +++ b/ui/v2.5/src/locales/lv-LV.json @@ -64,13 +64,13 @@ "add_sub_groups": "Pievienot apakšgrupas", "from_file": "No Faila…", "from_url": "No URL…", - "disallow": "Neatļaut", + "disallow": "Aizliegt", "download": "Lejupielādēt", "download_anonymised": "Lejupielādēt anonīmi", "download_backup": "Lejupielādēt Dublējumu", "edit": "Rediģēt", "edit_entity": "Rediģēt {entityType}", - "enable": "Iepējot", + "enable": "Iespējot", "encoding_image": "Konstruē bildi…", "export_all": "Eksportēt visu…", "find": "Atrast", @@ -80,7 +80,7 @@ "hide_configuration": "Paslēpt Konfigurāciju", "identify": "Identificēt", "ignore": "Ignorēt", - "import": "Importēt…", + "import": "Ievietot…", "hide": "Paslēpt", "make_primary": "Padarīt primāro", "merge_from": "Apvienot no", @@ -100,7 +100,7 @@ "reload_plugins": "Pārlādēt spraudņus", "refresh": "Atsvaidzināt", "disable": "Atspējot", - "export": "Eksportēt", + "export": "Izgūt", "logout": "Izrakstīties", "full_export": "Pilns Eksports", "full_import": "Pilns Imports", @@ -108,7 +108,17 @@ "generate_thumb_from_current": "Ģenerēt sīktēlu no pašreizējā", "import_from_file": "Importēt no faila", "merge": "Apvienot", - "migrate_scene_screenshots": "Migrēt Video Ekrānšāviņus" + "migrate_scene_screenshots": "Migrēt Video Ekrānšāviņus", + "save": "Saglabāt", + "search": "Meklēt", + "skip": "Izlaist", + "split": "Sadalīt", + "stop": "Apturēt", + "submit": "Iesniegt", + "remove": "Noņemt", + "rescan": "Skenēt pa jaunu", + "scan": "Skenēt", + "show": "Rādīt" }, "unknown_date": "Nezināms datums", "twitter": "Twitter", @@ -117,5 +127,54 @@ "zip_file_count": "Zip Failu Skaits", "weight_kg": "Svars (kg)", "weight": "Svars", - "years_old": "Gadus vecs" + "years_old": "Gadus vecs", + "component_tagger": { + "config": { + "query_mode_filename": "Datnes nosaukums", + "blacklist_label": "Melnais saraksts", + "query_mode_dir": "Mape", + "query_mode_metadata": "Metadati" + } + }, + "actions_name": "Darbības", + "age": "Vecums", + "aliases": "Aizstājvārdi", + "all": "visi", + "ascending": "Augošā secībā", + "between_and": "un", + "birthdate": "Dzimšanas datums", + "blobs_storage_type": { + "filesystem": "Datņsistēma", + "database": "Datubāze" + }, + "captions": "Subtitri", + "chapters": "Nodaļas", + "config": { + "categories": { + "security": "Drošība", + "tools": "Rīki", + "changelog": "Izmaiņu žurnāls", + "plugins": "Spraudņi" + }, + "general": { + "plugins_path": { + "description": "Ceļš uz spraudņu konfigurācijas mapi", + "heading": "Spraudņu mape" + } + }, + "plugins": { + "available_plugins": "Pieejamie spraudņi", + "installed_plugins": "Uzstādītie spraudņi" + }, + "tasks": { + "cleanup_desc": "Meklēt trūkstošos failus un noņemt tos no datubāzes. Šī darbība ir neatgriezeniska." + }, + "about": { + "check_for_new_version": "Pārbaudīt, vai pieejama jauna versija" + } + }, + "donate": "Ziedot", + "package_manager": { + "check_for_updates": "Pārbaudīt, vai pieejami atjauninājumi" + } } diff --git a/ui/v2.5/src/locales/nb-NO.json b/ui/v2.5/src/locales/nb-NO.json index 883b33b34..a160a2991 100644 --- a/ui/v2.5/src/locales/nb-NO.json +++ b/ui/v2.5/src/locales/nb-NO.json @@ -5,7 +5,7 @@ "confirm": "Bekreft", "continue": "Fortsett", "close": "Lukk", - "reset_cover": "Tilbakestill Standard Omslag", + "reset_cover": "Tilbakestill Standard Forsidebilde", "remove": "Fjern", "running": "kjører", "submit_stash_box": "Send til Stash-Box", @@ -29,7 +29,7 @@ "save_delete_settings": "Bruk disse alternativene som standard når du sletter", "save_filter": "Lagre filter", "scan": "Skann", - "scrape": "Skrape", + "scrape": "Skrap", "create": "Opprett", "create_chapters": "Opprett Kapittel", "create_marker": "Opprett Markør", @@ -61,22 +61,22 @@ "created_entity": "Opprettet {entity_type}: {entity_name}", "merge_from": "Slå sammen fra", "clean_generated": "Rydd opp i genererte filer", - "clear": "Tøm", + "clear": "Fjern", "clear_back_image": "Fjern bakbilde", "clear_date_data": "Fjern dato data", "clear_image": "Fjern Bilde", "create_parent_studio": "Opprett foreldre studio", "customise": "Tilpass", - "disallow": "Ikke tillat", + "disallow": "Forby", "download_anonymised": "Last ned anonymisert", "export_all": "Eksporter alle…", - "full_export": "Full eksport", - "full_import": "Full Import", + "full_export": "Eksporter alle", + "full_import": "Importer alle", "hash_migration": "hash migrering", "make_primary": "Gjør til Primær", "previous_action": "Tilbake", "refresh": "Oppdater", - "reload": "Last på nytt", + "reload": "Last inn på nytt", "not_running": "Kjører ikke", "open_in_external_player": "Åpne i ekstern spiller", "remove_date": "Fjern dato", @@ -105,9 +105,9 @@ "view_history": "Visningshistorikk", "view_random": "Vis Tilfeldig", "migrate_blobs": "Migrer Blobs", - "migrate_scene_screenshots": "Migrer Scene Skjermbilder", - "reassign": "Omplasser", - "reload_plugins": "Last inn plugins på nytt", + "migrate_scene_screenshots": "Flytt Scene Skjermbilder", + "reassign": "Tilordne på nytt", + "reload_plugins": "Last inn programtillegg på nytt", "reload_scrapers": "Last inn skrapere på nytt", "scrape_scene_fragment": "Skrap etter fragment", "set_back_image": "Baksidebilde…", @@ -119,7 +119,7 @@ "cancel": "Avbryt", "apply": "Bruk", "assign_stashid_to_parent_studio": "Tildel Stash ID til eksisterende foreldre studio og oppdater metadata", - "add_to_entity": "Legg til til {entityType}", + "add_to_entity": "Legg til {entityType}", "add_entity": "Legg til {entityType}", "add_manual_date": "Legg til manuell dato", "add_directory": "Legg til mappe", @@ -138,10 +138,18 @@ "play_selected": "Spill av valgte", "rescan": "Skann på nytt", "reshuffle": "Stokk om", - "rename_gen_files": "Gi nytt navn til genererte filer", - "selective_auto_tag": "Selektiv Automatisk Tagging", + "rename_gen_files": "Gi genererte filer nytt navn", + "selective_auto_tag": "Selektiv Auto Tag", "set_image": "Velg bilde…", - "selective_clean": "Selektiv Rens" + "selective_clean": "Selektiv Fjerning", + "sidebar": { + "close": "lukk sidebar", + "open": "åpne sidebar", + "toggle": "Endre sidepanelet" + }, + "show_results": "Vis resultater", + "show_count_results": "Vis {count} resultater", + "play": "Spill av" }, "component_tagger": { "config": { @@ -175,8 +183,23 @@ "noun_query": "Forespørsel", "results": { "duration_off": "Varighet avviker fra forventet verdi med minst {number}s", - "duration_unknown": "Ukjent varighet" - } + "duration_unknown": "Ukjent varighet", + "phash_matches": "{count} PHash-er stemmer overens", + "unnamed": "Uten navn", + "hash_matches": "{hash_type} stemmer overens", + "match_failed_already_tagged": "Scenen er allerede tagget", + "match_failed_no_result": "Ingen resultater funnet", + "fp_found": "{fpCount, plural, =0 {Ingen nye fingeravtrykksmatch funnet} other {# nye fingeravtrykksmatch funnet}}", + "fp_matches": "Varighet stemmer overens", + "fp_matches_multi": "Varighet samsvarer med {matchCount} av {durationsLength} fingeravtrykk", + "match_success": "Scenen ble tagget vellykket" + }, + "verb_match_fp": "Match Fingeravtrykk", + "verb_matched": "Matchet", + "verb_scrape_all": "Skrap alle", + "verb_submit_fp": "Send inn {fpCount, plural, one{# Fingeravtrykk} andre{# Fingerprints}}", + "verb_toggle_config": "{toggle} {configuration}", + "verb_toggle_unmatched": "{toggle} vis scener uten treff" }, "config": { "dlna": { @@ -192,7 +215,14 @@ "allow_temp_ip": "Tillatt {tempIP}", "allowed_ip_addresses": "Tillatt IP adresser", "server_port": "Serverport", - "server_display_name": "Server Visningsnavn" + "server_display_name": "Server Visningsnavn", + "server_display_name_desc": "Visningsnavn for DLNA-serveren. Standard til {server_navn} hvis tom.", + "server_port_desc": "Port for å kjøre DLNA-serveren på. Krever omstart av DLNA etter endring.", + "until_restart": "frem til omstart", + "network_interfaces_desc": "Grensesnitt for å eksponere DLNA-server på. En tom liste resulterer i å kjøre på alle grensesnitt. Krever omstart av DLNA etter endring.", + "successfully_cancelled_temporary_behaviour": "Vellykket kansellert midlertidig oppførsel", + "video_sort_order_desc": "Rekkefølgen som videoer sorteres etter som standard.", + "video_sort_order": "Standard videosortering" }, "about": { "stash_open_collective": "Støtt oss gjennom {url}", @@ -203,7 +233,10 @@ "check_for_new_version": "Sjekk for ny versjon", "latest_version": "Siste versjon", "latest_version_build_hash": "Siste Versjon Build Hash:", - "build_time": "Kompileringstid:" + "build_time": "Kompileringstid:", + "stash_wiki": "stash {url} side", + "build_hash": "bygg hash:", + "stash_home": "Stash-startside på {url}" }, "advanced_mode": "Avansert Modus", "categories": { @@ -227,18 +260,533 @@ "log_file": "Log-fil", "generate_api_key": "Generer API-nøkkel", "log_to_terminal": "Log til terminal", - "password": "Passord" + "password": "Passord", + "log_http_desc": "Logger HTTP-tilgang til terminalen. Krever omstart.", + "authentication": "Autentisering", + "clear_api_key": "Fjern API-nøkkel", + "credentials": { + "description": "Brukernavn og passord for å sikre tilgang til Stash.", + "heading": "Påloggingsinformasjon" + }, + "log_file_desc": "Sti til filen hvor logg skal lagres. La feltet stå tomt for å deaktivere fillogging. Krever omstart.", + "log_http": "Logg HTTP-tilgang", + "log_to_terminal_desc": "Logger til terminalen i tillegg til en fil. Er alltid aktivert hvis fillogging er deaktivert. Krever omstart.", + "maximum_session_age": "Maksimal levetid for økt", + "maximum_session_age_desc": "Maksimal inaktiv tid før en innloggingsøkt utløper, i sekunder. Krever omstart.", + "password_desc": "Passord for å få tilgang til Stash. La stå tomt for å deaktivere brukergodkjenning", + "stash-box_integration": "ntegrasjon med Stash-box", + "username_desc": "Brukernavn for å få tilgang til Stash. La stå tomt for å deaktivere brukergodkjenning", + "api_key_desc": "API-nøkkel for eksterne systemer. Kun nødvendig når brukernavn/passord er konfigurert. Brukernavn må lagres før API-nøkkel kan genereres." }, "db_path_head": "Database filbane", "ffmpeg": { "download_ffmpeg": { - "heading": "Last ned FFmpeg" + "heading": "Last ned FFmpeg", + "description": "Laster ned FFmpeg til konfigurasjonsmappen og tilbakestiller ffmpeg- og ffprobe-banene til å bruke konfigurasjonsmappen i stedet." }, "hardware_acceleration": { - "heading": "FFmpeg maskinvare encoding" + "heading": "Maskinvareakselerert koding med FFmpeg", + "desc": "Bruker tilgjengelig maskinvare til å kode video for sanntids-transkoding." + }, + "ffmpeg_path": { + "description": "Bane til ffmpeg-programmet (ikke bare mappen). Hvis den er tom, vil ffmpeg bli funnet fra miljøet via $PATH, konfigurasjonsmappen eller fra $HOME/.stash", + "heading": "FFmpeg kjørbar filbane" + }, + "ffprobe_path": { + "heading": "FFmpeg kjørbar filbane", + "description": "Bane til ffprobe-kjørbar fil (ikke bare mappen). Hvis feltet er tomt, vil ffprobe bli hentet fra miljøet via $PATH, konfigurasjonsmappen eller fra $HOME/.stash" + }, + "live_transcode": { + "input_args": { + "heading": "FFmpeg sanntids-transkoding inndata-argumenter", + "desc": "Avansert: Ekstra argumenter som sendes til ffmpeg før inndatafeltet ved sanntids-transkoding av video." + }, + "output_args": { + "heading": "FFmpeg sanntids-transkoding utdata-argumenter", + "desc": "Avansert: Ekstra argumenter som sendes til ffmpeg før utdatafeltet ved sanntids-transkoding av video." + } + }, + "transcode": { + "input_args": { + "desc": "Avansert: Ekstra argumenter som sendes til ffmpeg før inndatafeltet ved generering av video.", + "heading": "FFmpeg transkoding inndata-argumenter" + }, + "output_args": { + "desc": "Avansert: Ekstra argumenter som sendes til ffmpeg før utdatafeltet ved generering av video.", + "heading": "FFmpeg transkoding utdata-argumenter" + } } }, - "database": "Database" + "database": "Database", + "backup_directory_path": { + "heading": "Sti til sikkerhetskopimappe", + "description": "Mappeplassering for sikkerhetskopier av SQLite-databasefilen" + }, + "blobs_storage": { + "description": "Hvor binærdata som scenecovers, bilder av skuespillere, studioer og tagger skal lagres. Etter at denne verdien endres, må eksisterende data migreres ved hjelp av oppgaven “Migrer blobs”. Se oppgavesiden for migrering.", + "heading": "Lagringstype for binærdata" + }, + "blobs_path": { + "description": "Hvor i filsystemet binærdata skal lagres. Gjelder kun når filsystem er valgt som lagringstype for blob-data. ADVARSEL: Endring av dette krever at eksisterende data flyttes manuelt.", + "heading": "Sti for lagring av binærdata" + }, + "cache_path_head": "Sti til hurtigbuffer", + "chrome_cdp_path": "Sti til Chrome CDP", + "cache_location": "Mappeplassering for hurtigbufferen. Påkrevd ved strømming med HLS (f.eks. på Apple-enheter) eller DASH.", + "calculate_md5_and_ohash_desc": "Beregn MD5-sjekksum i tillegg til oshash. Å aktivere dette vil gjøre innledende skanninger tregere. Filnavn-hash må være satt til oshash for å deaktivere MD5-beregning.", + "calculate_md5_and_ohash_label": "Beregn MD5 for videoer", + "check_for_insecure_certificates": "Sjekk etter usikre sertifikater", + "check_for_insecure_certificates_desc": "Noen nettsteder bruker usikre SSL-sertifikater. Når dette er avhuket, hopper skraperen over kontrollen av sertifikater og tillater skraping av slike nettsteder. Hvis du får en sertifikatfeil under skraping, fjern avhukingen her.", + "chrome_cdp_path_desc": "Filsti til Chrome-kjørbar fil, eller en ekstern adresse (som begynner med http:// eller https://, for eksempel http://localhost:9222/json/version) til en Chrome-instans.", + "create_galleries_from_folders_desc": "Hvis aktivert, opprettes gallerier automatisk fra mapper som inneholder bilder. Opprett en fil kalt .forcegallery eller .nogallery i en mappe for å henholdsvis tvinge eller hindre dette.", + "create_galleries_from_folders_label": "Lag gallerier automatisk fra bildemapper", + "directory_locations_to_your_content": "Stier til innholdskataloger", + "excluded_image_gallery_patterns_desc": "Regexps av bilde og galleri filer/filbaner å utelukke fra Scan og legge til Clean", + "excluded_video_patterns_desc": "Regexps av video filer/filbaner å utelukke fra Scan og legge til Clean", + "excluded_image_gallery_patterns_head": "Utelukkede Bilde-/Gallerimønstre", + "excluded_video_patterns_head": "Utelukkede Videomønstre", + "image_ext_head": "Bilde-filendelser", + "plugins_path": { + "description": "Mappestedsplassering for plugin-konfigurasjonsfiler", + "heading": "Plugin-bane" + }, + "scrapers_path": { + "heading": "Scraper-bane", + "description": "Mappestedsplassering for scraper-konfigurasjonsfiler" + }, + "scraping": "Scraping", + "logging": "Logging", + "maximum_transcode_size_head": "Maksimal transkodingsstørrelse", + "funscript_heatmap_draw_range": "Inkluder område i genererte varmekart", + "funscript_heatmap_draw_range_desc": "Tegn bevegelsesområde på y-aksen i det genererte varmekartet. Eksisterende varmekart må genereres på nytt etter endring.", + "generated_file_naming_hash_head": "Hash for generert filnavngivning", + "generated_path_head": "Generert bane", + "maximum_streaming_transcode_size_head": "Maksimal størrelse for strømmingstranskoding", + "maximum_transcode_size_desc": "Maksimal størrelse for genererte transkodinger", + "number_of_parallel_task_for_scan_generation_head": "Antall parallelle oppgaver for skanning/generering", + "scraper_user_agent_desc": "User-Agent-streng som brukes under HTTP-forespørsler for scraping", + "gallery_cover_regex_desc": "Regexp som brukes for å identifisere et bilde som galleriomslag", + "include_audio_desc": "Inkluderer lydstrøm ved generering av forhåndsvisninger.", + "include_audio_head": "Inkluder lyd", + "maximum_streaming_transcode_size_desc": "Maksimal størrelse for transkodede strømmer", + "metadata_path": { + "heading": "Metadatabane", + "description": "Mappestedsplassering som brukes ved full eksport eller import" + }, + "python_path": { + "description": "Bane til Python-kjørbar fil (ikke bare mappen). Brukes for skript-skapere og plugins. Hvis tomt, vil Python bli hentet fra miljøet", + "heading": "Python kjørbar filbane" + }, + "generated_file_naming_hash_desc": "Bruk MD5 eller oshash for generert filnavngivning. Endring av dette krever at alle scener har den aktuelle MD5/oshash-verdien fylt ut. Etter å ha endret denne verdien, må eksisterende genererte filer migreres eller genereres på nytt. Se Oppgaver-siden for migrering.", + "heatmap_generation": "Funscript-varmekartgenerering", + "number_of_parallel_task_for_scan_generation_desc": "Sett til 0 for automatisk deteksjon. Advarsel: Å kjøre flere oppgaver enn nødvendig for å oppnå 100 % CPU-utnyttelse vil redusere ytelsen og kan potensielt forårsake andre problemer.", + "sqlite_location": "Filplassering for SQLite-databasen (krever omstart). ADVARSEL: Å lagre databasen på et annet system enn der Stash-serveren kjører (f.eks. over nettverket) støttes ikke!", + "image_ext_desc": "Kommaseparert liste over filendelser som vil bli identifisert som bilder.", + "parallel_scan_head": "Parallell skanning/generering", + "video_ext_desc": "Kommaseparert liste over filendelser som vil bli identifisert som videoer.", + "gallery_cover_regex_label": "Mønster for galleriomslag", + "gallery_ext_desc": "Kommaseparert liste over filendelser som vil bli identifisert som galleri-zip-filer.", + "gallery_ext_head": "Galleri-zip-filendelser", + "generated_files_location": "Mappestedsplassering for de genererte filene (scene-markører, scene-forhåndsvisninger, sprites, osv.)", + "hashing": "Hashing", + "preview_generation": "Forhåndsvisningsgenerering", + "scraper_user_agent": "Scraper-brukeragent", + "video_ext_head": "Video-filendelser", + "video_head": "Video" + }, + "application_paths": { + "heading": "applikasjon baner" + }, + "tasks": { + "plugin_tasks": "Plugin-oppgaver", + "generate_thumbnails_during_scan": "Generer miniatyrbilder for bilder", + "identify": { + "source": "Kilde", + "tag_skipped_matches": "Tagg hoppet over treff med", + "and_create_missing": "og skape mangler", + "create_missing": "Lag mangler", + "description": "Angi automatisk scenemetadata ved hjelp av stash-box- og scraper-kilder.", + "field": "Felt", + "identifying_scenes": "Identifisering av {num} {scene}", + "include_male_performers": "Inkluder mannlige utøvere", + "set_cover_images": "Sett forsidebilder", + "tag_skipped_performers": "Tagg hoppet over utøvere med", + "default_options": "Standardalternativer", + "set_organized": "Sett organisert flagg", + "identifying_from_paths": "Identifisere scener fra følgende stier", + "tag_skipped_performer_tooltip": "Opprett en tagg som «Identifiser: Enkeltnavnsutøver» som du kan filtrere etter i scenetaggervisningen, og velg hvordan du vil håndtere disse utøverne", + "explicit_set_description": "Følgende alternativer vil bli brukt der de ikke overstyres i de kildespesifikke alternativene.", + "field_behaviour": "{strategi} {felt}", + "field_options": "Feltalternativer", + "heading": "Identifisere", + "skip_multiple_matches": "Hopp over treff som har mer enn ett resultat", + "skip_multiple_matches_tooltip": "Hvis dette ikke er aktivert og mer enn ett resultat returneres, vil ett bli tilfeldig valgt for å matche", + "skip_single_name_performers": "Hopp over utøvere med ett enkelt navn uten entydighet", + "skip_single_name_performers_tooltip": "Hvis dette ikke er aktivert, vil utøvere som ofte er generiske, som Samantha eller Olga, bli matchet", + "source_options": "{kilde} Alternativer", + "sources": "Kilder", + "strategy": "Strategi", + "tag_skipped_matches_tooltip": "Opprett en tagg som «Identifiser: Flere treff» som du kan filtrere etter i Scene Tagger-visningen og velge riktig treff manuelt" + }, + "rescan": "Skann filer på nytt", + "auto_tag": { + "auto_tagging_all_paths": "Automatisk tagging av alle stier", + "auto_tagging_paths": "Automatisk tagging av følgende stier" + }, + "auto_tag_based_on_filenames": "Automatisk tagging av innhold basert på filstier.", + "clean_generated": { + "blob_files": "Blob-filer", + "description": "Fjerner genererte filer uten en tilhørende databaseoppføring.", + "image_thumbnails": "Bildeminiatyrer", + "markers": "Markørforhåndsvisninger", + "previews": "Sceneforhåndsvisninger", + "sprites": "Scenesprites", + "transcodes": "Scenetranskodinger", + "image_thumbnails_desc": "Bildeminiatyrer og klipp", + "previews_desc": "Sceneforhåndsvisninger og miniatyrbilder" + }, + "dont_include_file_extension_as_part_of_the_title": "Ikke inkluder filtypen som en del av tittelen", + "generate_video_previews_during_scan": "Generer forhåndsvisninger", + "generated_content": "Generert innhold", + "import_from_exported_json": "Importer fra eksportert JSON i metadatakatalogen. Sletter den eksisterende databasen.", + "incremental_import": "Trinnvis import fra en levert eksport-zip-fil.", + "job_queue": "Oppgavekø", + "maintenance": "Vedlikehold", + "anonymising_database": "Anonymisering av database", + "backing_up_database": "Sikkerhetskopiering av database", + "generate_video_previews_during_scan_tooltip": "Generer forhåndsvisninger av videoer som spilles av når du holder musepekeren over en scene", + "added_job_to_queue": "La til {operation_name} i jobbkøen", + "anonymise_and_download": "Lager en anonymisert kopi av databasen og laster ned den resulterende filen.", + "anonymise_database": "Lager en kopi av databasen i backup-mappen, og anonymiserer all sensitiv informasjon. Denne kan deretter deles med andre for feilsøking og debugging. Den opprinnelige databasen blir ikke endret. Den anonymiserte databasen bruker filnavnformatet {filename_format}.", + "backup_database": "Utfører en sikkerhetskopi av databasen til backup-mappen, med filnavnformatet {filename_format}", + "cleanup_desc": "Sjekk etter manglende filer og fjern dem fra databasen. Dette er en destruktiv handling.", + "defaults_set": "Standardinnstillinger er satt og vil bli brukt når du klikker på {action}-knappen på Oppgaver-siden.", + "migrate_scene_screenshots": { + "description": "Migrer skjermbilder av scener til det nye blob-lagringssystemet. Denne migreringen bør kjøres etter at et eksisterende system er migrert til 0.20. Kan eventuelt slette de gamle skjermbildene etter migreringen.", + "delete_files": "Slett skjermbildefiler", + "overwrite_existing": "Overskriv eksisterende blobs med skjermbildedata" + }, + "data_management": "Databehandling", + "generate_previews_during_scan_tooltip": "Generer også animerte (webp) forhåndsvisninger, som bare er nødvendig når Forhåndsvisningstype for scene/markørvegg er satt til Animert bilde. Når du surfer, bruker de mindre CPU enn videoforhåndsvisningene, men genereres i tillegg til dem og er større filer.", + "generate_video_covers_during_scan": "Generer sceneomslag", + "optimise_database_warning": "Advarsel: Mens denne oppgaven kjører, vil alle operasjoner som endrer databasen mislykkes, og avhengig av databasestørrelsen kan det ta flere minutter å fullføre. Den krever også minst like mye ledig diskplass som databasen er stor, men 1,5 ganger anbefales.", + "auto_tagging": "Automatisk tagging", + "backup_and_download": "Utfører en sikkerhetskopi av databasen og laster ned den resulterende filen.", + "empty_queue": "Ingen oppgaver kjører for øyeblikket.", + "export_to_json": "Eksporterer databaseinnholdet til JSON-format i metadata-mappen.", + "generate": { + "generating_from_paths": "Genererer for scener fra følgende stier", + "generating_scenes": "Genererer for {num} {scene}" + }, + "generate_clip_previews_during_scan": "Generer forhåndsvisninger for bildeklipp", + "generate_desc": "Generer støttefiler for bilde, sprite, video, vtt og andre filer.", + "generate_phashes_during_scan": "perseptuelle", + "generate_phashes_during_scan_tooltip": "For deduplisering og sceneidentifikasjon.", + "generate_previews_during_scan": "Generer animerte bildeforhåndsvisninger", + "generate_sprites_during_scan": "Generer scrubber-sprites", + "generate_sprites_during_scan_tooltip": "Bildesettet som vises under videospilleren for enkel navigering.", + "migrate_blobs": { + "delete_old": "Slett gamle data", + "description": "Migrer blober til gjeldende blob-lagringssystem. Denne migreringen bør kjøres etter at blob-lagringssystemet er endret. Kan eventuelt slette gamle data etter migrering." + }, + "migrate_hash_files": "Brukes etter endring av den genererte filnavngivningshashen for å gi eksisterende genererte filer nytt navn til det nye hashformatet.", + "migrations": "Migrasjoner", + "only_dry_run": "Utfør kun en prøvekjøring. Ikke fjern noe", + "optimise_database": "Forsøk å forbedre ytelsen ved å analysere og deretter gjenoppbygge hele databasefilen.", + "rescan_tooltip": "Skann alle filer i banen på nytt. Brukes til å tvinge frem oppdatering av filmetadata og skanne zip-filer på nytt.", + "scan": { + "scanning_all_paths": "Skanner alle stier", + "scanning_paths": "Skanner følgende stier" + }, + "scan_for_content_desc": "Skann etter nytt innhold og legg det til i databasen.", + "set_name_date_details_from_metadata_if_present": "Angi navn, dato og detaljer fra innebygde filmetadata" + }, + "ui": { + "scene_player": { + "options": { + "vr_tag": { + "heading": "VR Tag", + "description": "VR-knappen vises bare for scener med denne taggen." + }, + "always_start_from_beginning": "Start alltid videoen fra begynnelsen", + "auto_start_video": "Autostart video", + "auto_start_video_on_play_selected": { + "description": "Start scenevideoer automatisk når du spiller av fra køen, eller spiller av valgte eller tilfeldige scener fra scenesiden", + "heading": "Start video automatisk når den valgte videoen spilles av" + }, + "continue_playlist_default": { + "description": "Spill av neste scene i køen når videoen er ferdig", + "heading": "Fortsett spilleliste som standard" + }, + "disable_mobile_media_auto_rotate": "Deaktiver automatisk rotasjon av fullskjermsmedier på mobil", + "enable_chromecast": "Aktiver Chromecast", + "show_ab_loop_controls": "Vis AB Loop-plugin-kontroller", + "show_scrubber": "Vis Scrubber", + "show_range_markers": "Vis avstandsmarkører", + "track_activity": "Aktiver Scene Play-logg" + }, + "heading": "Scenespiller" + }, + "tag_panel": { + "options": { + "show_child_tagged_content": { + "heading": "Vis subtag-innhold", + "description": "I tag-visningen kan du også vise innhold fra undertaggene" + } + }, + "heading": "Tag-visning" + }, + "editing": { + "heading": "Redigering", + "disable_dropdown_create": { + "heading": "Deaktiver oppretting av rullegardinmenyen", + "description": "Fjern muligheten til å opprette nye objekter fra rullegardinmenyene" + }, + "max_options_shown": { + "label": "Maksimalt antall elementer som skal vises i utvalgte rullegardinmenyer" + }, + "rating_system": { + "star_precision": { + "label": "Rangeringsstjernepresisjon", + "options": { + "full": "Full", + "half": "Halv", + "quarter": "Fjerdedel", + "tenth": "Tiende" + } + }, + "type": { + "label": "Rangeringssystemtype", + "options": { + "decimal": "Desimal", + "stars": "Stjerner" + } + } + } + }, + "interactive_options": "Interaktive alternativer", + "studio_panel": { + "options": { + "show_child_studio_content": { + "heading": "Vis innhold fra understudioer", + "description": "I studiovisningen kan du også vise innhold fra understudioene" + } + }, + "heading": "Studio utsikt" + }, + "abbreviate_counters": { + "heading": "Forkort tellere", + "description": "Forkort tellere i kort- og detaljvisningssider, for eksempel vil «1831» bli formatert til «1,8K»." + }, + "basic_settings": "Grunnleggende innstillinger", + "custom_css": { + "heading": "Egendefinert CSS", + "option_label": "Egendefinert CSS aktivert", + "description": "Siden må lastes inn på nytt for at endringene skal tre i kraft. Det er ingen garanti for kompatibilitet mellom tilpasset CSS og fremtidige utgivelser av Stash." + }, + "custom_javascript": { + "description": "Siden må lastes inn på nytt for at endringene skal tre i kraft. Det er ingen garanti for kompatibilitet mellom tilpasset Javascript og fremtidige versjoner av Stash.", + "heading": "Egendefinert Javascript", + "option_label": "Egendefinert Javascript aktivert" + }, + "custom_locales": { + "heading": "Tilpasset lokalisering", + "option_label": "Egendefinert lokalisering aktivert", + "description": "Overstyr individuelle språkstrenger. Se https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json for hovedlisten. Siden må lastes inn på nytt for at endringene skal tre i kraft." + }, + "delete_options": { + "description": "Standardinnstillinger ved sletting av bilder, gallerier og scener.", + "heading": "Slett alternativer", + "options": { + "delete_file": "Slett fil som standard", + "delete_generated_supporting_files": "Slett genererte støttefiler som standard" + } + }, + "desktop_integration": { + "desktop_integration": "Desktop-integrasjon", + "notifications_enabled": "Aktiver varsler", + "send_desktop_notifications_for_events": "Send skrivebordsvarsler for hendelser", + "skip_opening_browser": "Hopp over å åpne nettleseren", + "skip_opening_browser_on_startup": "Hopp over automatisk åpning av nettleser under oppstart" + }, + "detail": { + "compact_expanded_details": { + "heading": "Kompakte utvidede detaljer", + "description": "Når dette alternativet er aktivert, vil det presentere utvidede detaljer samtidig som presentasjonen blir kompakt" + }, + "enable_background_image": { + "description": "Vis bakgrunnsbilde på detaljsiden.", + "heading": "Aktiver bakgrunnsbilde" + }, + "heading": "Detaljside", + "show_all_details": { + "description": "Når den er aktivert, vises alle innholdsdetaljer som standard, og hvert detaljelement får plass under én kolonne", + "heading": "Vis alle detaljer" + } + }, + "funscript_offset": { + "heading": "Funksjonsskriptforskyvning (ms)", + "description": "Tidsforskyvning i millisekunder for avspilling av interaktive skript." + }, + "handy_connection": { + "connect": "Koble til", + "server_offset": { + "heading": "Serveroffset" + }, + "status": { + "heading": "Praktisk tilkoblingsstatus" + }, + "sync": "Synkroniser" + }, + "handy_connection_key": { + "heading": "Handy tilkoblingsnøkkel", + "description": "Praktisk tilkoblingsnøkkel for bruk for interaktive scener. Hvis du angir denne knappen, kan Stash dele informasjon om gjeldende scene med handyfeeling.com" + }, + "image_lightbox": { + "heading": "Bilde lysboks" + }, + "image_wall": { + "direction": "Retning", + "heading": "Bildevegg", + "margin": "Margin (piksler)" + }, + "images": { + "heading": "Bilder", + "options": { + "create_image_clips_from_videos": { + "description": "Når et bibliotek har deaktivert videoer, vil videofiler (filer som slutter på videoendelsen) skannes som bildeklipp.", + "heading": "Skann videoutvidelser som bildeklipp" + }, + "write_image_thumbnails": { + "heading": "Skriv miniatyrbilder", + "description": "Skriv miniatyrbilder av bilder til disk når de genereres på farten" + } + } + }, + "language": { + "heading": "Språk" + }, + "max_loop_duration": { + "description": "Maksimal scenevarighet der scenespilleren vil spille av videoen i loop - 0 for å deaktivere", + "heading": "Maksimal sløyfevarighet" + }, + "menu_items": { + "heading": "Menyelementer", + "description": "Vis eller skjul ulike typer innhold på navigasjonslinjen" + }, + "minimum_play_percent": { + "description": "Prosentandelen av tiden en scene må spilles av før avspillingstallene økes.", + "heading": "Minimum Spilleprosent" + }, + "performers": { + "options": { + "image_location": { + "heading": "Tilpasset utøverbildebane", + "description": "Tilpasset sti for standard utøverbilder. La stå tomt for å bruke innebygde standardinnstillinger" + } + } + }, + "preview_type": { + "heading": "Forhåndsvisningstype", + "options": { + "animated": "Animert bilde", + "static": "Statisk bilde", + "video": "Video" + }, + "description": "Standardalternativet er forhåndsvisninger av videoer (mp4). For mindre CPU-bruk når du surfer, kan du bruke forhåndsvisninger av animerte bilder (webp). Disse må imidlertid genereres i tillegg til forhåndsvisningene av videoer, og de er større filer." + }, + "scene_list": { + "heading": "Rutenettvisning", + "options": { + "show_studio_as_text": "Vis studiooverlegg som tekst" + } + }, + "scene_wall": { + "heading": "Scene-/markørvegg", + "options": { + "toggle_sound": "Aktiver lyd", + "display_title": "Vis tittel og tagger" + } + }, + "scroll_attempts_before_change": { + "heading": "Rullforsøk før overgang", + "description": "Antall forsøk på å bla før man går til neste/forrige element. Gjelder kun for Pan Y-rullemodus." + }, + "show_tag_card_on_hover": { + "description": "Vis tagkort når du holder musepekeren over tag-merkene", + "heading": "Verktøytips for tagkort" + }, + "slideshow_delay": { + "description": "Lysbildefremvisning er tilgjengelig i gallerier i veggvisningsmodus", + "heading": "Forsinkelse av lysbildefremvisning (sekunder)" + }, + "title": "Brukergrensesnitt", + "use_stash_hosted_funscript": { + "heading": "Server funscripts direkte", + "description": "Når dette er aktivert, vil funscripts bli servert direkte fra Stash til Handy-enheten din uten å bruke tredjeparts Handy-serveren. Krever at Stash er tilgjengelig fra Handy-enheten din, og at en API-nøkkel genereres hvis stash har konfigurert legitimasjon." + } + }, + "scraping": { + "available_scrapers": "Tilgjengelige scrapers", + "search_by_name": "Søk etter navn", + "supported_types": "Støttede typer", + "installed_scrapers": "Installerte scrapers", + "scraper": "Skraper", + "entity_scrapers": "{entityType} scrapers", + "excluded_tag_patterns_desc": "Regulære uttrykk for tag-navn som skal ekskluderes fra scraperesultater", + "scrapers": "Skrapere", + "entity_metadata": "{entityType} Metadata", + "excluded_tag_patterns_head": "Ekskluderte tag-mønstre", + "supported_urls": "URLs" + }, + "plugins": { + "installed_plugins": "Installerte plugins", + "hooks": "Hooks", + "triggers_on": "Utløsere aktivert", + "available_plugins": "Tilgjengelige plugins" + }, + "library": { + "media_content_extensions": "Filendelser for medieinnhold", + "gallery_and_image_options": "Galleri- og bildeinnstillinger", + "exclusions": "Eksklusjoner" + }, + "tools": { + "scene_duplicate_checker": "Sceneduplikatkontrollør", + "scene_filename_parser": { + "add_field": "Legg til felt", + "capitalize_title": "Bruk stor bokstav i tittelen", + "display_fields": "Vis felt", + "escape_chars": "Bruk \\ for å escape-tegn", + "filename": "Filnavn", + "ignore_organized": "Ignorer organiserte scener", + "ignored_words": "Ignorerte ord", + "matches_with": "Samsvarer med {i}", + "select_parser_recipe": "Velg parseroppskrift", + "title": "Scenefilnavnparser", + "whitespace_chars_desc": "Disse tegnene vil bli erstattet med mellomrom i tittelen", + "filename_pattern": "Filnavnmønster", + "whitespace_chars": "Mellomromstegn" + }, + "graphql_playground": "GraphQL lekeplass", + "heading": "Verktøy", + "scene_tools": "Sceneverktøy" + }, + "stashbox": { + "endpoint": "Endepunkt", + "description": "Stash-box muliggjør automatisk tagging av scener og utøvere basert på fingeravtrykk og filnavn.\nEndepunkt og API-nøkkel finnes på kontosiden din på Stash-box-instansen. Navn kreves når mer enn én instans legges til.", + "graphql_endpoint": "GraphQL-endepunkt", + "name": "Navn", + "add_instance": "Legg til Stash-box-instans", + "api_key": "API-nøkkel", + "max_requests_per_minute": "Maks forespørsler per minutt", + "max_requests_per_minute_description": "Bruker standardverdi på {defaultValue} hvis satt til 0", + "title": "Stash-box-endepunkter" + }, + "logs": { + "log_level": "Loggnivå" + }, + "system": { + "transcoding": "Transkoding" } }, "appears_with": "Opptrer med", @@ -265,5 +813,736 @@ "UNCUT": "Ikke omskåret" }, "birth_year": "Fødselsår", - "all": "alle" + "all": "alt", + "media_info": { + "performer_card": { + "age": "{alder} {år_gammel}", + "age_context": "{age} {years_old} ved produksjon" + }, + "video_codec": "Video Kodek", + "audio_codec": "Lydkodek", + "checksum": "Sjekksum", + "downloaded_from": "Lastet ned fra", + "hash": "Hash", + "interactive_speed": "Interaktiv hastighet", + "o_count": "0 Antall", + "phash": "PHash", + "play_count": "Avspilt", + "play_duration": "Spillevarighet", + "stream": "Strøm" + }, + "age_on_date": "{age} ved produksjon", + "search_filter": { + "update_filter": "Oppdater Filtre", + "saved_filters": "Lagrede filter", + "edit_filter": "Rediger filter", + "name": "Filter", + "more_filter_criteria": "+{tell} flere" + }, + "history": "Historie", + "play_count": "Antall Avspillinger", + "play_history": "Avspillinger", + "release_notes": "Utgivelsesnotater", + "scene": "Scene", + "path": "Sti", + "package_manager": { + "installed_version": "Installert versjon", + "required_by": "Kreves av {pakker}", + "add_source": "Legg til kilde", + "check_for_updates": "Se etter oppdateringer", + "confirm_delete_source": "Er du sikker på at du vil slette kilden {name} ({url})?", + "description": "Beskrivelse", + "edit_source": "Rediger Kilde", + "hide_unselected": "Skjul uvalgt", + "latest_version": "Siste versjon", + "no_packages": "Ingen pakker funnet", + "no_sources": "Ingen kilder er konfigurert", + "no_upgradable": "Ingen oppgraderbare pakker funnet", + "package": "Pakke", + "selected_only": "Kun valgte", + "show_all": "Vis alle", + "source": { + "local_path": { + "heading": "Lokal Sti", + "description": "Relativ sti til lagringspakker for denne kilden. Merk at endring av dette krever at pakkene flyttes manuelt." + }, + "name": "Navn", + "url": "Kilde URL" + }, + "uninstall": "Avinstallere", + "unknown": "", + "update": "Oppdatere", + "version": "Versjon", + "install": "Installere", + "confirm_uninstall": "Er du sikker på at du vil avinstallere {number} pakker?" + }, + "rating": "Vurdering", + "queue": "Kø", + "studio_tagger": { + "status_tagging_job_queued": "Status: Taggejobb i kø", + "batch_update_studios": "Batch Oppdater Studio", + "failed_to_save_studio": "Kunne ikke lagre studioet «{studio}»", + "add_new_studios": "Legg til nye studioer", + "batch_add_studios": "Batch Legg Til Studio", + "config": { + "edit_excluded_fields": "Rediger ekskluderte felt", + "excluded_fields": "Ekskluderte felt:", + "no_fields_are_excluded": "Ingen felt er ekskludert", + "no_instances_found": "Ingen forekomster funnet", + "these_fields_will_not_be_changed_when_updating_studios": "Disse feltene vil ikke bli endret når du oppdaterer studioer.", + "active_stash-box_instance": "Aktiv stash-box-instans:", + "create_parent_label": "Lag foreldrestudioer", + "create_parent_desc": "Opprett manglende foreldrestudioer, eller tagg og oppdater data/bilde for eksisterende foreldrestudioer med nøyaktige navnesamsvar" + }, + "create_or_tag_parent_studios": "Opprett manglende eller tagg eksisterende foreldrestudioer", + "current_page": "Gjeldende side", + "name_already_exists": "Navnet finnes allerede", + "network_error": "Nettverksfeil", + "no_results_found": "Ingen resultater funnet.", + "number_of_studios_will_be_processed": "{studio_count} studioer vil bli behandlet", + "query_all_studios_in_the_database": "Alle studioer i databasen", + "refreshing_will_update_the_data": "Oppdatering vil oppdatere dataene til alle taggede studioer fra stash-box-instansen.", + "studio_already_tagged": "Studio allerede merket", + "studio_names_separated_by_comma": "Studionavn atskilt med komma", + "studio_selection": "Studio utvalg", + "studio_successfully_tagged": "Studio er tagget", + "any_names_entered_will_be_queried": "Alle navn som legges inn vil bli spørt fra den eksterne Stash-Box-instansen og lagt til hvis de blir funnet. Bare eksakte treff vil bli ansett som treff.", + "refresh_tagged_studios": "Oppdater taggede studioer", + "status_tagging_studios": "Status: Merking av studioer", + "tag_status": "Tag status", + "to_use_the_studio_tagger": "For å bruke studio-taggeren må en stash-box-instans konfigureres.", + "untagged_studios": "Umerkede studioer", + "update_studio": "Oppdater Studio", + "update_studios": "Oppdater Studio", + "updating_untagged_studios_description": "Oppdatering av utaggede studioer vil prøve å matche alle studioer som mangler en stashid og oppdatere metadataene." + }, + "effect_filters": { + "blue": "Blå", + "name_transforms": "Forvandles", + "red": "Rød", + "aspect": "Aspekt", + "blur": "Uskarphet", + "brightness": "Lysstyrke", + "contrast": "Kontrast", + "gamma": "Gamma", + "green": "Grønn", + "hue": "Hue", + "rotate": "Rotere", + "rotate_left_and_scale": "Roter til venstre og skaler", + "rotate_right_and_scale": "Roter til høyre og skaler", + "saturation": "Metning", + "scale": "Skala", + "warmth": "Varme", + "reset_filters": "Tilbakestill filtre", + "reset_transforms": "Tilbakestill Transforms", + "name": "Filtre" + }, + "megabits_per_second": "{verdi} mbps", + "stats": { + "scenes_duration": "Varighet av scener", + "image_size": "Bildestørrelse", + "total_o_count": "Total O-telling", + "scenes_played": "Scener spilt", + "scenes_size": "Scenestørrelse", + "total_play_count": "Totale Avspillinger", + "total_play_duration": "Total Spilletid" + }, + "studio_depth": "Nivåer (tomt for alle)", + "custom_fields": { + "title": "Egendefinerte felt", + "criteria_format_string": "{kriterium} (egendefinert felt) {modifierString} {valueString}", + "value": "Verdi", + "criteria_format_string_others": "{kriterium} (egendefinert felt) {modifierString} {valueString} (+{andre} andre)", + "field": "Felt" + }, + "dialogs": { + "scene_gen": { + "marker_screenshots": "Marker skjermbilder", + "video_previews": "Forhåndsvisninger", + "video_previews_tooltip": "Videoforhåndsvisninger som spilles av når du holder musepekeren over en scene", + "covers": "Scenebilder", + "image_previews": "Forhåndsvisninger av animerte bilder", + "image_previews_tooltip": "Generer også animerte (webp) forhåndsvisninger, som bare er nødvendig når Forhåndsvisningstype for scene/markørvegg er satt til Animert bilde. Når du surfer, bruker de mindre CPU enn videoforhåndsvisningene, men genereres i tillegg til dem og er større filer.", + "image_thumbnails": "Miniatyrbilder", + "interactive_heatmap_speed": "Generer varmekart og hastigheter for interaktive scener", + "marker_image_previews": "Forhåndsvisning av animerte bilder for markører", + "marker_screenshots_tooltip": "Marker statiske JPG-bilder", + "markers": "Marker forhåndsvisning", + "markers_tooltip": "20 sekunders videoer som starter på den gitte tidskoden.", + "override_preview_generation_options": "Overstyr forhåndsvisningsgenereringsalternativer", + "override_preview_generation_options_desc": "Overstyr forhåndsvisningsgenereringsalternativer for denne operasjonen. Standardverdier angis i System -> Forhåndsvisningsgenerering.", + "overwrite": "Overskriv eksisterende filer", + "phash": "Perseptuelle hasher", + "phash_tooltip": "For deduplisering og sceneidentifikasjon", + "preview_exclude_end_time_head": "Ekskluder sluttid", + "preview_exclude_start_time_desc": "Ekskluder de første x sekundene fra sceneforhåndsvisninger. Dette kan være en verdi i sekunder, eller en prosentandel (f.eks. 2 %) av den totale scenevarigheten.", + "preview_exclude_start_time_head": "Ekskluder starttidspunkt", + "preview_generation_options": "Forhåndsvisningsgenereringsalternativer", + "preview_options": "Forhåndsvisningsalternativer", + "preview_preset_head": "Forhåndsinnstilling av koding", + "preview_seg_count_desc": "Antall segmenter i forhåndsvisningsfiler.", + "preview_seg_count_head": "Antall segmenter i forhåndsvisning", + "preview_seg_duration_desc": "Varigheten av hvert forhåndsvisningssegment, i sekunder.", + "preview_seg_duration_head": "Forhåndsvis segmentets varighet", + "sprites": "Scene Scrubber Sprites", + "sprites_tooltip": "Bildesettet som vises under videospilleren for enkel navigering.", + "transcodes": "Transkoder", + "force_transcodes": "Tving generering av transkode", + "marker_image_previews_tooltip": "Generer også animerte (webp) forhåndsvisninger, som bare er nødvendig når Forhåndsvisningstype for scene/markørvegg er satt til Animert bilde. Når du surfer, bruker de mindre CPU enn videoforhåndsvisningene, men genereres i tillegg til dem og er større filer.", + "force_transcodes_tooltip": "Som standard genereres transkoder bare når videofilen ikke støttes i nettleseren. Når dette er aktivert, genereres transkoder selv om videofilen ser ut til å være støttet i nettleseren.", + "preview_preset_desc": "Forhåndsinnstillingen regulerer størrelse, kvalitet og kodingstid for forhåndsvisningsgenerering. Forhåndsinnstillinger utover «treg» har avtagende avkastning og anbefales ikke.", + "preview_exclude_end_time_desc": "Ekskluder de siste x sekundene fra sceneforhåndsvisninger. Dette kan være en verdi i sekunder, eller en prosentandel (f.eks. 2 %) av den totale scenevarigheten.", + "clip_previews": "Forhåndsvisninger av bildeklipp", + "transcodes_tooltip": "MP4-transkoder vil bli forhåndsgenerert for alt innhold; nyttig for trege CPU-er, men krever mye mer diskplass" + }, + "scenes_found": "{count} scener funnet", + "scrape_results_scraped": "Skrapet", + "set_image_url_title": "Bilde-URL", + "lightbox": { + "scroll_mode": { + "label": "Rullemodus", + "pan_y": "Pan Y", + "zoom": "Zoom", + "description": "Hold Shift-tasten nede for å midlertidig bruke en annen modus." + }, + "delay": "Forsinkelse (sek)", + "display_mode": { + "fit_to_screen": "Tilpass til skjermen", + "label": "Visningsmodus", + "original": "Orginalt", + "fit_horizontally": "Passer horisontalt" + }, + "options": "Alternativer", + "reset_zoom_on_nav": "Tilbakestill zoomnivå når du bytter bilde", + "scale_up": { + "description": "Skaler mindre bilder opp for å fylle skjermen", + "label": "Skaler opp for å passe" + }, + "page_header": "Side {side} / {totalt}" + }, + "merge": { + "empty_results": "Verdiene i destinasjonsfeltet vil forbli uendret.", + "destination": "Destinasjon", + "source": "Kilde" + }, + "imagewall": { + "margin_desc": "Antall margpiksler rundt hvert bilde.", + "direction": { + "column": "Kolonne", + "description": "Kolonne- eller radbasert oppsett.", + "row": "Rad" + } + }, + "delete_confirm": "Er du sikker på at du vil slette {entityName}?", + "clear_o_history_confirm": "Er du sikker på at du vil slette O-historikken?", + "clear_play_history_confirm": "Er du sikker på at du vil slette avspillingshistorikken?", + "create_new_entity": "Opprett ny {entity}", + "delete_entity_simple_desc": "{count, plural, one {Er du sikker på at du vil slette denne {singularEntity}?} other {Er du sikker på at du vil slette disse {pluralEntity}?}}", + "delete_entity_title": "{antall, flertall, én {Slett {entallEntitet}} annet {Slett {flertallEntitet}}}", + "delete_galleries_extra": "... pluss eventuelle bildefiler som ikke er knyttet til noe annet galleri.", + "delete_object_desc": "Er du sikker på at du vil slette {count, plural, one {this {singularEntity}} other {these {pluralEntity}}}?", + "delete_object_overflow": "…og {count} other {count, flertall, one {{singularEntity}} other {{pluralEntity}}}.", + "delete_object_title": "Slett {count, flertall, én {{singularEntity}} annen {{pluralEntity}}}", + "dont_show_until_updated": "Ikke vis før neste oppdatering", + "export_include_related_objects": "Inkluder relaterte objekter i eksporten", + "export_title": "Eksport", + "merge_tags": { + "destination": "Destinasjon", + "source": "Kilde" + }, + "performers_found": "{count} utøvere funnet", + "reassign_entity_title": "{antall, flertall, én {Reassign {singularEntity}} annen {Reassign {pluralEntity}}}", + "reassign_files": { + "destination": "Tilordne på nytt til" + }, + "scrape_entity_query": "Skrapeforespørsel fra {entity_type}", + "scrape_entity_title": "{entity_type} Skrape Resultater", + "scrape_results_existing": "Eksisterende", + "delete_entity_desc": "{count, plural, one {Er du sikker på at du vil slette denne {singularEntity}? Med mindre filen også slettes, vil denne {singularEntity} bli lagt til på nytt når skanningen utføres.} other {Er du sikker på at du vil slette disse {pluralEntity}? Med mindre filene også slettes, vil disse {pluralEntity} bli lagt til på nytt når skanningen utføres.}}", + "delete_gallery_files": "Slett gallerimappen/zip-filen og alle bilder som ikke er knyttet til noe annet galleri.", + "edit_entity_title": "Rediger {count, flertall, én {{singularEntity}} annen {{pluralEntity}}}", + "delete_alert": "Følgende {count, plural, one {{singularEntity}} other {{pluralEntity}}} vil bli slettet permanent:", + "overwrite_filter_warning": "Det lagrede filteret «{entityName}» vil bli overskrevet.", + "unsaved_changes": "Ulagrede endringer. Er du sikker på at du vil avslutte?", + "set_default_filter_confirm": "Er du sikker på at du vil angi dette filteret som standard?" + }, + "director": "Regissør", + "display_mode": { + "unknown": "Ukjent", + "wall": "Vegg", + "tagger": "Tagger", + "list": "Liste", + "label_current": "Visningsmodus: {gjeldende}", + "grid": "Rutenett" + }, + "distance": "Lengde", + "dupe_check": { + "only_select_matching_codecs": "Velg bare hvis alle kodeker samsvarer i duplikatgruppen", + "options": { + "exact": "Nøyaktig", + "high": "Høy", + "low": "Lav", + "medium": "Medium" + }, + "search_accuracy_label": "Søkenøyaktighet", + "duration_diff": "Maksimal varighetsforskjell", + "duration_options": { + "equal": "Lik", + "any": "Noen" + }, + "select_none": "Velg Ingen", + "select_oldest": "Velg den eldste filen i duplikatgruppen", + "select_options": "Velg Alternativer…", + "select_youngest": "Velg den yngste filen i duplikatgruppen", + "title": "Dupliserte scener", + "description": "Nivåer under «Nøyaktig» kan ta lengre tid å beregne. Falske positive resultater kan også returneres ved lavere nøyaktighetsnivåer.", + "found_sets": "{setCount, flertall, one{# sett med duplikater funnet.} other {# sett med duplikater funnet.}}", + "select_all_but_largest_file": "Velg alle filer i hver dupliserte gruppe, unntatt den største filen", + "select_all_but_largest_resolution": "Velg alle filer i hver dupliserte gruppe, unntatt filen med høyest oppløsning" + }, + "duplicated_phash": "Duplisert (pHash)", + "errors": { + "invalid_javascript_string": "Ugyldig javascript-kode: {error}", + "custom_fields": { + "duplicate_field": "Feltnavnet må være unikt", + "field_name_length": "Feltnavnet må inneholde færre enn 65 tegn", + "field_name_required": "Feltnavn er obligatorisk", + "field_name_whitespace": "Feltnavnet kan ikke ha innledende eller etterfølgende mellomrom" + }, + "header": "Feil", + "invalid_json_string": "Ugyldig JSON-streng: {error}", + "loading_type": "Feil ved lasting av {type}", + "something_went_wrong": "Noe gikk galt.", + "image_index_greater_than_zero": "Bildeindeksen må være større enn 0", + "lazy_component_error_help": "Hvis du nylig har oppgradert Stash, må du laste inn siden på nytt eller tømme nettleserens hurtigbuffer." + }, + "file_info": "Filinformasjon", + "group_scene_number": "Scenenummer", + "groups": "Grupper", + "image_index": "Bilde #", + "last_o_at": "Siste O Kl", + "library": "Bibliotek", + "penis": "Penis", + "part_of": "En del av {forelder}", + "performer_tagger": { + "batch_update_performers": "Batch Oppdater Skuespillere", + "config": { + "active_stash-box_instance": "Aktiv stash-box-instans:", + "edit_excluded_fields": "Rediger Ekskluderte Felt", + "no_fields_are_excluded": "Ingen felt er ekskludert", + "no_instances_found": "Ingen forekomster funnet", + "excluded_fields": "Ekskluderte felt:", + "these_fields_will_not_be_changed_when_updating_performers": "Disse feltene vil ikke bli endret når skuespillerne oppdateres." + }, + "network_error": "Nettverksfeil", + "performer_already_tagged": "Skuespilleren er allerede tagget", + "number_of_performers_will_be_processed": "{skuespiller_antall} skuespillere vil bli behandlet", + "performer_names_separated_by_comma": "Skuespillernavn atskilt med komma", + "batch_add_performers": "Batch Legg Til Skuespillere", + "current_page": "Gjeldende side", + "failed_to_save_performer": "Kunne ikke lagre skuespillere «{skuespiller}»", + "name_already_exists": "Navnet finnes allerede", + "no_results_found": "Ingen resultater funnet.", + "performer_selection": "Utvalg av skuespillere", + "performer_successfully_tagged": "Skuespiller er tagget:", + "query_all_performers_in_the_database": "Alle skuespillere i databasen", + "refresh_tagged_performers": "Oppdater taggede skuespillere", + "status_tagging_job_queued": "Status: Taggejobb i kø", + "status_tagging_performers": "Status: Tagger skuespillere", + "tag_status": "Tagging Status", + "to_use_the_performer_tagger": "For å bruke skuespiller-taggeren må en stash-box-instans konfigureres.", + "untagged_performers": "Utaggede skuespillere", + "update_performer": "Oppdater Skuespiller", + "update_performers": "Oppdater Skuespillere", + "add_new_performers": "Legg til ny skuespiller", + "updating_untagged_performers_description": "Oppdatering av utaggede skuespillere vil forsøke å matche skuespillere som mangler en stashid og oppdatere metadataene.", + "refreshing_will_update_the_data": "Oppdatering vil oppdatere dataene til alle taggede skuespillere fra stash-box-instansen.", + "any_names_entered_will_be_queried": "Alle navn som legges inn vil bli spørt fra den eksterne Stash-Box-instansen og lagt til hvis de blir funnet. Bare eksakte treff vil bli ansett som treff." + }, + "setup": { + "welcome_specific_config": { + "next_step": "Når du er klar til å fortsette med å sette opp et nytt system, klikker du på Neste.", + "config_path": "Stash vil bruke følgende konfigurasjonsfilsti: {path}", + "unable_to_locate_specified_config": "Hvis du leser dette, fant ikke Stash konfigurasjonsfilen som er angitt på kommandolinjen eller i miljøet. Denne veiviseren vil veilede deg gjennom prosessen med å sette opp en ny konfigurasjon." + }, + "paths": { + "set_up_your_paths": "Sett opp dine veier", + "database_filename_empty_for_default": "databasefilnavn (tomt for standard)", + "description": "Deretter må vi finne ut hvor du finner pornosamlingen din, og hvor du skal lagre Stash-databasen, genererte filer og hurtigbufferfiler. Disse innstillingene kan endres senere om nødvendig.", + "path_to_generated_directory_empty_for_default": "sti til generert katalog (tom som standard)", + "stash_alert": "Ingen bibliotekstier er valgt. Ingen medier kan skannes inn i Stash. Er du sikker?", + "store_blobs_in_database": "Lagre blober i databasen", + "where_can_stash_store_blobs": "Hvor kan Stash lagre binære databasedata?", + "where_can_stash_store_blobs_description_addendum": "Alternativt kan du lagre disse dataene i databasen. Merk: Dette vil øke størrelsen på databasefilen din, og det vil øke migreringstiden for databasen.", + "where_can_stash_store_cache_files": "Hvor kan Stash lagre hurtigbufferfiler?", + "where_can_stash_store_its_database_description": "Stash bruker en SQLite-database for å lagre pornometadataene dine. Som standard opprettes dette som stash-go.sqlite i katalogen som inneholder konfigurasjonsfilen din. Hvis du vil endre dette, må du skrive inn et absolutt eller relativt (til gjeldende arbeidskatalog) filnavn.", + "where_can_stash_store_its_database_warning": "ADVARSEL: lagring av databasen på et annet system enn der Stash kjøres fra (f.eks. lagring av databasen på en NAS mens Stash-serveren kjøres på en annen datamaskin) er ikke støttet! SQLite er ikke ment for bruk på tvers av et nettverk, og forsøk på å gjøre det kan lett føre til at hele databasen blir ødelagt.", + "where_can_stash_store_its_generated_content": "Hvor kan Stash lagre det genererte innholdet?", + "where_is_your_porn_located": "Hvor ligger pornoen din?", + "where_is_your_porn_located_description": "Legg til kataloger som inneholder pornovideoene og bildene dine. Stash vil bruke disse katalogene til å finne videoer og bilder under skanning.", + "path_to_blobs_directory_empty_for_default": "sti til blobs-katalogen (tom som standard)", + "where_can_stash_store_its_database": "Hvor kan Stash lagre databasen sin?", + "where_can_stash_store_cache_files_description": "For at funksjonalitet som HLS/DASH live-transkoding skal fungere, krever Stash en hurtigbufferkatalog for midlertidige filer. Som standard oppretter Stash en cache-katalog i katalogen som inneholder konfigurasjonsfilen din. Hvis du vil endre dette, må du angi en absolutt eller relativ (til gjeldende arbeidskatalog) sti. Stash oppretter denne katalogen hvis den ikke allerede finnes.", + "where_can_stash_store_its_generated_content_description": "For å kunne tilby miniatyrbilder, forhåndsvisninger og sprites genererer Stash bilder og videoer. Dette inkluderer også transkoder for filformater som ikke støttes. Som standard vil Stash opprette en generert katalog i katalogen som inneholder konfigurasjonsfilen din. Hvis du vil endre hvor dette genererte mediet skal lagres, må du angi en absolutt eller relativ (til gjeldende arbeidskatalog) sti. Stash vil opprette denne katalogen hvis den ikke allerede finnes.", + "path_to_cache_directory_empty_for_default": "sti til hurtigbufferkatalog (tom som standard)", + "where_can_stash_store_blobs_description": "Stash kan lagre binære data som scenecovere, utøver, studio og tag-bilder enten i databasen eller i filsystemet. Som standard lagrer den disse dataene i filsystemet i underkatalogen blobs i katalogen som inneholder konfigurasjonsfilen din. Hvis du vil endre dette, må du angi en absolutt eller relativ (til gjeldende arbeidskatalog) sti. Stash vil opprette denne katalogen hvis den ikke allerede finnes." + }, + "creating": { + "creating_your_system": "Oppretter systemet ditt" + }, + "errors": { + "something_went_wrong_while_setting_up_your_system": "Noe gikk galt under oppsettet av systemet ditt. Her er feilen vi mottok: {feil}", + "unable_to_retrieve_system_status": "Kan ikke hente systemstatus: {feil}", + "something_went_wrong": "Å nei! Noe gikk galt!", + "unexpected_error": "Det oppsto en uventet feil: {feil}", + "something_went_wrong_description": "Hvis dette ser ut som et problem med inndataene dine, kan du klikke tilbake for å fikse dem. Ellers kan du rapportere en feil på {githubLink} eller søke hjelp på {discordLink}." + }, + "folder": { + "file_path": "Filbane", + "up_dir": "Opp en mappe" + }, + "github_repository": "Github-depot", + "migrate": { + "migration_failed": "Migrering mislyktes", + "migration_irreversible_warning": "Skjemamigreringsprosessen er ikke reversibel. Når migreringen er utført, vil databasen din være inkompatibel med tidligere versjoner av stash.", + "migration_notes": "Migrasjonsnotater", + "migration_required": "Migrering kreves", + "perform_schema_migration": "Utfør skjemamigrering", + "backup_database_path_leave_empty_to_disable_backup": "Sti til sikkerhetskopieringsdatabase (la stå tomt for å deaktivere sikkerhetskopiering):", + "migration_failed_error": "Følgende feil oppsto under migrering av databasen:", + "schema_too_old": "Din nåværende stash-database er skjemaversjon {databaseSchema} og må migreres til versjon {appSchema}. Denne versjonen av Stash vil ikke fungere uten at databasen migreres. Hvis du ikke ønsker å migrere, må du nedgradere til en versjon som samsvarer med databaseskjemaet ditt.", + "backup_recommended": "Det anbefales at du sikkerhetskopierer den eksisterende databasen din før du migrerer. Vi kan gjøre dette for deg ved å lage en kopi av databasen din til {defaultBackupPath}.", + "migrating_database": "Migrerer database", + "migration_failed_help": "Gjør nødvendige rettelser og prøv på nytt. Ellers kan du melde fra om en feil på {githubLink} eller søke hjelp på {discordLink}." + }, + "confirm": { + "almost_ready": "Vi er nesten klare til å fullføre konfigurasjonen. Vennligst bekreft følgende innstillinger. Du kan klikke tilbake for å endre noe som er feil. Hvis alt ser bra ut, klikker du på Bekreft for å opprette systemet ditt.", + "blobs_directory": "Binær datakatalog", + "blobs_use_database": "", + "cache_directory": "Cache-katalog", + "configuration_file_location": "konfigurasjonsfilplassering:", + "database_file_path": "Banen til databasefilen", + "generated_directory": "Generert katalog", + "nearly_there": "Nesten der!", + "stash_library_directories": "Stash bibliotekkataloger" + }, + "stash_setup_wizard": "Stash Setup Wizard", + "success": { + "download_ffmpeg": "Last ned ffmpeg", + "getting_help": "Får hjelp", + "help_links": "Hvis du støter på problemer eller har spørsmål eller forslag, kan du gjerne åpne en sak i {githubLink} eller spørre fellesskapet i {discordLink}.", + "next_config_step_one": "Du blir deretter tatt til konfigurasjonssiden. Denne siden lar deg tilpasse hvilke filer som skal inkluderes og ekskluderes, angi et brukernavn og passord for å beskytte systemet ditt, og en hel rekke andre alternativer.", + "next_config_step_two": "Når du er fornøyd med disse innstillingene, kan du begynne å skanne innholdet ditt inn i Stash ved å klikke på {localized_task}, deretter {localized_scan}.", + "open_collective": "Sjekk ut vår {open_collective_link} for å se hvordan du kan bidra til den fortsatte utviklingen av Stash.", + "support_us": "Støtt oss", + "thanks_for_trying_stash": "Takk for at du prøvde Stash!", + "your_system_has_been_created": "Suksess! Systemet ditt er opprettet!", + "in_app_manual_explained": "Du oppfordres til å sjekke ut manualen i appen, som du finner via ikonet øverst til høyre på skjermen. Det ser slik ut: {icon}", + "missing_ffmpeg": "Du mangler den nødvendige ffmpeg-binærfilen. Du kan laste den ned automatisk til konfigurasjonskatalogen din ved å merke av i boksen nedenfor. Alternativt kan du oppgi stier til ffmpeg- og ffprobe-binærfilene i systeminnstillingene. Disse binærfilene må være tilstede for at Stash skal fungere.", + "welcome_contrib": "Vi tar også imot bidrag i form av kode (feilrettinger, forbedringer og nye funksjoner), testing, feilrapporter, forbedrings- og funksjonsforespørsler og brukerstøtte. Detaljer finner du i bidragsdelen i appens brukerhåndbok." + }, + "welcome": { + "in_current_stash_directory": "I katalogen {path}:", + "in_the_current_working_directory": "I {path}, arbeidskatalogen, for øyeblikket:", + "in_the_current_working_directory_disabled": "I {path}, arbeidskatalogen:", + "next_step": "Når alt dette er avklart, og du er klar til å fortsette med å sette opp et nytt system, må du velge hvor du vil lagre konfigurasjonsfilen.", + "store_stash_config": "Hvor vil du lagre Stash-konfigurasjonen din?", + "unexpected_explained": "Hvis du får denne skjermen uventet, kan du prøve å starte Stash på nytt i riktig arbeidsmappe eller med -c-flagget.", + "in_the_current_working_directory_disabled_macos": "Støttes ikke når du kjører Stash.app,

kjør stash-macos for å sette opp i arbeidskatalogen", + "config_path_logic_explained": "Stash prøver først å finne konfigurasjonsfilen sin (config.yml) fra gjeldende arbeidsmappe, og hvis den ikke finner den der, går den tilbake til {fallback_path}. Du kan også få Stash til å lese fra en spesifikk konfigurasjonsfil ved å kjøre den med alternativene -c '' eller --config ''.", + "unable_to_locate_config": "Hvis du leser dette, fant ikke Stash en eksisterende konfigurasjon. Denne veiviseren vil veilede deg gjennom prosessen med å sette opp en ny konfigurasjon." + }, + "welcome_to_stash": "Velkommen til Stash" + }, + "stashbox": { + "submission_successful": "Innlevering vellykket", + "go_review_draft": "Gå til {endpoint_name} for å se gjennom utkastet.", + "selected_stash_box": "Valgt Stash-Box-endepunkt", + "source": "Stash-Box-kilde", + "submission_failed": "Innsending mislyktes", + "submit_update": "Finnes allerede i {endpoint_name}" + }, + "performer_image": "Skuespiller Bilde", + "countables": { + "images": "{antall, flertall, ett {bilde} andre {bilder}}", + "files": "{antall, flertall, én {fil} andre {filer}}", + "galleries": "{antall, flertall, ett {galleri} andre {gallerier}}", + "markers": "{antall, flertall, én {markør} andre {markører}}", + "scenes": "{antall, flertall, én {Scene} andre {Scener}}", + "studios": "{antall, flertall, ett {studio} andre {studioer}}", + "tags": "{antall, flertall, én {Tag} andre {Tags}}", + "groups": "{antall, flertall, én {gruppe} andre {grupper}}", + "performers": "{antall, flertall, én {utøver} andre {utøvere}}" + }, + "sort_name": "Sorter Navn", + "include_sub_studios": "Inkluder datterselskapsstudioer", + "include_sub_tag_content": "Inkluder innhold i undertagger", + "include_sub_tags": "Inkluder undertagger", + "include_sub_studio_content": "Inkluder innhold fra understudioer", + "dimensions": "Dimensjoner", + "criterion_modifier_values": { + "any_of": "noen av", + "none": "Ingen", + "only": "Bare", + "any": "Hvilken som helst" + }, + "configuration": "Konfigurasjon", + "connection_monitor": { + "websocket_connection_failed": "Klarte ikke å opprette websocket-tilkobling: se nettleserkonsollen for detaljer", + "websocket_connection_reestablished": "Websocket-tilkoblingen er gjenopprettet" + }, + "containing_group": "Inneholder gruppe", + "containing_group_count": "Inneholder gruppeantall", + "containing_groups": "Inneholder grupper", + "country": "Land", + "cover_image": "Forsidebilde", + "created_at": "Opprettet", + "criterion": { + "greater_than": "Større enn", + "less_than": "Mindre enn", + "value": "Verdi" + }, + "criterion_modifier": { + "between": "mellom", + "equals": "Er", + "format_string": "{kriterium} {modifikatorString} {verdiString}", + "format_string_excludes": "{kriterium} {modifierString} {valueString} (ekskluderer {excludedString})", + "format_string_excludes_depth": "{kriterium} {modifierString} {valueString} (ekskluderer {excludedString}) (+{dybde, flertall, =-1 {all} andre {{dybde}}})", + "includes": "inkluderer", + "includes_all": "inkluderer alle", + "is_null": "er null", + "less_than": "er mindre enn", + "matches_regex": "samsvarer med regulært uttrykk", + "not_between": "ikke mellom", + "not_equals": "er ikke", + "greater_than": "er større enn", + "format_string_depth": "{kriterium} {modifierString} {valueString} (+{dybde, flertall, =-1 {all} other {{dybde}}})", + "excludes": "ekskluderer", + "not_matches_regex": "samsvarer ikke med regex", + "not_null": "er ikke null" + }, + "custom": "Tilpasset", + "date": "Dato", + "date_format": "ÅÅÅÅ-MM-DD", + "datetime_format": "ÅÅÅÅ-MM-DD TT:MM", + "death_date": "Dødsdato", + "death_year": "Dødsår", + "descending": "Synkende", + "description": "Beskrivelse", + "detail": "Detalj", + "details": "Detaljer", + "developmentVersion": "Utviklingsversjon", + "disambiguation": "Presisering", + "duration": "Varighet", + "ethnicity": "Etnisitet", + "existing_value": "eksisterende verdi", + "eye_color": "Øyefarge", + "fake_tits": "Falske pupper", + "false": "Falsk", + "favourite": "Favoritt", + "file_count": "Antall filer", + "file_mod_time": "Filendringstid", + "files": "Filer", + "files_amount": "{value} filer", + "filesize": "Filstørrelse", + "filter": "Filter", + "filter_name": "Filternavn", + "filters": "Filtre", + "folder": "Mappe", + "framerate": "Bildefrekvens", + "frames_per_second": "{verdi} fps", + "front_page": { + "types": { + "saved_filter": "Lagrede filter", + "premade_filter": "Forhåndslaget filter" + } + }, + "galleries": "Gallerier", + "gallery": "Galleri", + "gallery_count": "Antall Galleri", + "gender": "Kjønn", + "gender_types": { + "FEMALE": "Kvinne", + "INTERSEX": "Intersex", + "MALE": "Mann", + "NON_BINARY": "Ikke Binær", + "TRANSGENDER_MALE": "Transgender Mann", + "TRANSGENDER_FEMALE": "Transgender kvinne" + }, + "group": "Gruppe", + "group_count": "Gruppetall", + "handy_connection_status": { + "connecting": "Kobler til", + "disconnected": "Koblet fra", + "missing": "Mangler", + "ready": "Klar", + "syncing": "Synkroniserer med server", + "uploading": "Laster opp skript", + "error": "Feil ved tilkobling til Handy" + }, + "hasChapters": "Har Kapitler", + "hasMarkers": "Har Markører", + "height": "Høyde", + "height_cm": "Høyde (cm)", + "help": "Hjelp", + "ignore_auto_tag": "Ignorer Automatisk Tagging", + "image": "Bilde", + "image_count": "Antall Bilder", + "images": "Bilder", + "include_parent_tags": "Inkluder overordnede tagger", + "include_sub_groups": "Inkluder undergrupper", + "index_of_total": "{indeks} av {totalt}", + "instagram": "Instagram", + "interactive": "Interaktiv", + "interactive_speed": "Interaktiv hastighet", + "isMissing": "Mangler", + "last_played_at": "Sist Spilt", + "loading": { + "generic": "Laster inn …", + "plugins": "Laster inn plugins …" + }, + "login": { + "login": "Logg inn", + "username": "Brukernavn", + "password": "Passord", + "invalid_credentials": "Ugyldig brukernavn eller passord", + "internal_error": "Uventet intern feil. Se loggene for mer informasjon" + }, + "marker_count": "Antall markører", + "markers": "Markører", + "measurements": "Mål", + "metadata": "Metadata", + "name": "Navn", + "new": "Ny", + "none": "Ingen", + "o_counter": "O-Teller", + "o_history": "O Historie", + "odate_recorded_no": "Ingen O-dato registrert", + "operations": "Operasjoner", + "organized": "Organisert", + "orientation": "Orientering", + "pagination": { + "current_total": "{nåværende} av {totalt}", + "first": "Første", + "last": "Siste", + "next": "Neste", + "previous": "Tilbake" + }, + "parent_of": "Forelder til {barn}", + "parent_studio": "Foreldrestudio", + "parent_studios": "Foreldrestudio", + "parent_tag_count": "Antall foreldremerker", + "parent_tags": "Foreldre Tagger", + "penis_length_cm": "Penis Lengde (cm)", + "perceptual_similarity": "Perseptuell Likhet (pHash)", + "performer": "Skuespiller", + "performer_age": "Skuespiller Alder", + "performer_count": "Skuespiller Antall", + "performers": "Skuespillere", + "photographer": "Fotograf", + "piercings": "Piercinger", + "playdate_recorded_no": "Ingen avspillningsdatodato registrert", + "plays": "{verdi} avspillinger", + "primary_file": "Primær fil", + "primary_tag": "Primær Tag", + "random": "Tilfeldig", + "recently_added_objects": "Nylig lagt til {objekter}", + "recently_released_objects": "Nylig utgitte {objekter}", + "resolution": "Oppløsning", + "resume_time": "Gjenoppta tid", + "sceneTagger": "Scene Tagger", + "scene_code": "Studio Kode", + "scene_count": "Antall Scener", + "scene_created_at": "Scene opprettet", + "scene_date": "Dato for scenen", + "scene_id": "Scene-ID", + "scene_tags": "Scene Tagger", + "scene_updated_at": "Scene Oppdatert", + "scenes": "Scener", + "scenes_updated_at": "Scener Oppdatert", + "second": "Sekund", + "seconds": "Sekunder", + "settings": "Innstillinger", + "stash_id": "Stash ID", + "stash_id_endpoint": "Stash ID-sluttpunkt", + "stash_ids": "Stash-ID-er", + "statistics": "Statistikk", + "status": "Status: {statusTekst}", + "studio": "Studio", + "studio_and_parent": "Studio og foreldre", + "studio_count": "Antall Studio", + "donate": "Donere", + "file": "fil", + "empty_server": "Legg til noen scener på serveren din for å se anbefalinger på denne siden.", + "penis_length": "Penis Lengde", + "performer_favorite": "Favoritt Skuespiller", + "eta": "ETA", + "performer_tags": "Skuespiller Tagger", + "play_duration": "Spillevarighet", + "hair_color": "Hårfarge", + "include_sub_group_content": "Inkluder innhold i undergrupper", + "o_count": "O Antall", + "toast": { + "merged_tags": "Sammenslåtte tagger", + "generating_screenshot": "Genererer skjermbilde …", + "added_generation_job_to_queue": "Lagt til genereringsjobb i køen", + "created_entity": "Opprettet {entity}", + "default_filter_set": "Standard filtersett", + "delete_past_tense": "Slettet {count, flertall, en {{singularEntity}} annen {{pluralEntity}}}", + "image_index_too_large": "Feil: Bildeindeksen er større enn antallet bilder i galleriet", + "merged_scenes": "Sammenslåtte scener", + "rescanning_entity": "Skanner på nytt {count, flertall, én {{singularEntity}} annen {{pluralEntity}}}…", + "saved_entity": "Lagret {entity}", + "started_generating": "Begynte å generere", + "started_importing": "Begynte å importere", + "updated_entity": "Oppdatert {entity}", + "started_auto_tagging": "Startet automatisk merking", + "removed_entity": "Fjernet {count, flertall, én {{singularEntity}} annen {{pluralEntity}}}", + "added_entity": "Lagt til {count, flertall, én {{singularEntity}} annen {{pluralEntity}}}", + "reassign_past_tense": "Filen er tildelt på nytt" + }, + "time_end": "Sluttid", + "sub_tags": "Undertagger", + "tag_count": "Antall Tagger", + "total": "Total", + "updated_at": "Oppdatert", + "url": "URL", + "urls": "URLs", + "validation": { + "blank": "${path} må ikke være tomt", + "end_time_before_start_time": "Sluttiden må være større enn eller lik starttiden", + "required": "${path} er et obligatorisk felt", + "unique": "${path} må være unik", + "date_invalid_form": "${path} må være i formatet ÅÅÅÅ-MM-DD" + }, + "video_codec": "Videokodek", + "videos": "Videoer", + "view_all": "Vis Alle", + "weight": "Vekt", + "weight_kg": "Vekt (kg)", + "studio_tags": "Studio Tagger", + "studios": "Studioer", + "sub_group_count": "Antall undergrupper", + "sub_group_of": "Undergruppe av {forelder}", + "sub_group_order": "Undergruppeordre", + "sub_groups": "Undergrupper", + "sub_tag_count": "Antall undertagger", + "sub_tag_of": "Undertagg av {parent}", + "subsidiary_studio_count": "Antall datterselskaper i studioer", + "subsidiary_studios": "Datterselskapsstudioer", + "synopsis": "Synopsis", + "tag": "Tag", + "tag_sub_tag_tooltip": "Har under-tagger", + "tags": "Tagger", + "tattoos": "Tatoveringer", + "time": "Tid", + "title": "Tittel", + "true": "Sant", + "twitter": "X", + "type": "Type", + "unknown_date": "Ukjent dato", + "years_old": "år gammel", + "zip_file_count": "Antall zip-filer", + "tag_parent_tooltip": "Har foreldretagger", + "sub_group": "Undergruppe" } diff --git a/ui/v2.5/src/locales/nl-NL.json b/ui/v2.5/src/locales/nl-NL.json index ceaf2ab27..56ba293b8 100644 --- a/ui/v2.5/src/locales/nl-NL.json +++ b/ui/v2.5/src/locales/nl-NL.json @@ -141,7 +141,15 @@ "make_primary": "Als primair aanduiden", "reload": "Herladen", "copy_to_clipboard": "Kopiëren naar klembord", - "swap": "Omwisselen" + "swap": "Omwisselen", + "sidebar": { + "close": "Sluit zijbalk", + "open": "Open zijbalk", + "toggle": "Schakelaar Zijbalk" + }, + "show_results": "Resultaat tonen", + "show_count_results": "{count} resultaten tonen", + "play": "Afspelen" }, "actions_name": "Acties", "age": "Leeftijd", @@ -179,7 +187,10 @@ "show_male_label": "Mannen tonen", "source": "Bron", "mark_organized_label": "Markeren als geordend na opslaan", - "mark_organized_desc": "Markeer een scène als geordend na klikken op opslaan." + "mark_organized_desc": "Markeer een scène als geordend na klikken op opslaan.", + "errors": { + "blacklist_duplicate": "Dubbel zwarte lijst item" + } }, "noun_query": "Zoekvraag", "results": { @@ -343,12 +354,59 @@ "database": "Database", "ffmpeg": { "download_ffmpeg": { - "heading": "Download FFmpeg" + "heading": "Download FFmpeg", + "description": "Downloadt FFmpeg naar de configuratiemap en wist de ffmpeg- en ffprobe-paden zodat deze uit de configuratiemap kunnen worden opgehaald." }, "hardware_acceleration": { - "heading": "FFmpeg hardwarecodering" + "heading": "FFmpeg hardwarecodering", + "desc": "Gebruikt beschikbare hardware om video te coderen voor live transcodering." + }, + "ffmpeg_path": { + "heading": "FFmpeg uitvoerbaar pad", + "description": "Pad naar het uitvoerbare bestand van ffmpeg (niet alleen de map). Indien leeg, wordt ffmpeg vanuit de omgeving opgelost via $PATH, de configuratiemap of vanuit $HOME/.stash" + }, + "ffprobe_path": { + "heading": "FFprobe uitvoerbaar pad", + "description": "Pad naar het uitvoerbare bestand ffprobe (niet alleen de map). Indien leeg, wordt ffprobe vanuit de omgeving opgelost via $PATH, de configuratiemap of vanuit $HOME/.stash" + }, + "live_transcode": { + "input_args": { + "desc": "Geavanceerd: Extra argumenten om door te geven aan ffmpeg vóór het invoerveld bij het live transcoderen van video.", + "heading": "FFmpeg Live Transcode Invoer Argumenten" + }, + "output_args": { + "desc": "Geavanceerd: Extra argumenten om door te geven aan ffmpeg vóór het uitvoerveld bij het live transcoderen van video.", + "heading": "FFmpeg Live Transcode Uitvoer Argumenten" + } + }, + "transcode": { + "input_args": { + "desc": "Geavanceerd: Extra argumenten om door te geven aan ffmpeg vóór het invoerveld bij het genereren van video.", + "heading": "FFmpeg Transcode Invoer Argumenten" + }, + "output_args": { + "heading": "FFmpeg Transcode Uitvoer Argumenten", + "desc": "Geavanceerd: Extra argumenten die aan ffmpeg moeten worden doorgegeven vóór het uitvoerveld bij het genereren van video." + } } - } + }, + "blobs_storage": { + "heading": "Type binaire gegevensopslag", + "description": "Waar binaire gegevens zoals scènecovers, performer-, studio- en tagafbeeldingen worden opgeslagen. Nadat u deze waarde hebt gewijzigd, moeten de bestaande gegevens worden gemigreerd met behulp van de taken Blobs migreren. Zie de pagina Taken voor meer informatie over migratie." + }, + "blobs_path": { + "description": "Locatie in het bestandssysteem waar binaire gegevens worden opgeslagen. Alleen van toepassing bij gebruik van het Bestandssysteem blob-opslagtype. WAARSCHUWING: om dit te wijzigen, moeten bestaande gegevens handmatig worden verplaatst.", + "heading": "Pad naar binair gegevensbestandssysteem" + }, + "heatmap_generation": "Funscript Heatmap Generatie", + "gallery_cover_regex_desc": "Regexp gebruikt om een afbeelding te identificeren als galerijomslag", + "plugins_path": { + "description": "Map locatie van plugin-configuratiebestanden", + "heading": "Plugin Pad" + }, + "gallery_cover_regex_label": "Galerie omslagpatroon", + "funscript_heatmap_draw_range": "Bereik opnemen in gegenereerde heatmaps", + "funscript_heatmap_draw_range_desc": "Teken het bewegingsbereik op de y-as van de gegenereerde heatmap. Bestaande heatmaps moeten na wijziging opnieuw worden gegenereerd." }, "library": { "exclusions": "Uitzonderingen", @@ -360,7 +418,9 @@ }, "plugins": { "hooks": "Triggers", - "triggers_on": "Reageert op" + "triggers_on": "Reageert op", + "available_plugins": "Beschikbare Plugins", + "installed_plugins": "Geïnstalleerde Plugins" }, "scraping": { "entity_metadata": "{entityType} Metadata", @@ -371,7 +431,9 @@ "scrapers": "Schrapers", "search_by_name": "Zoek op naam", "supported_types": "Ondersteunde types", - "supported_urls": "URL's" + "supported_urls": "URL's", + "installed_scrapers": "Geïnstalleerde Schrapers", + "available_scrapers": "Beschikbare schrapers" }, "stashbox": { "add_instance": "Voeg stash-box instance toe", @@ -380,7 +442,9 @@ "endpoint": "Eindpunt", "graphql_endpoint": "GraphQL eindpunt", "name": "Naam", - "title": "Stash-box Eindpunten" + "title": "Stash-box Eindpunten", + "max_requests_per_minute_description": "Gebruikt de standaardwaarde van {defaultValue} als deze is ingesteld op 0", + "max_requests_per_minute": "Max aanvragen per minuut" }, "system": { "transcoding": "Transcoderen" @@ -449,7 +513,22 @@ "scanning_paths": "Scannen van de volgende paden" }, "scan_for_content_desc": "Scan naar nieuwe inhoud en voeg deze toe aan de database.", - "set_name_date_details_from_metadata_if_present": "Stel de naam, datum, details in vanuit Embedded File Metadata" + "set_name_date_details_from_metadata_if_present": "Stel de naam, datum, details in vanuit Embedded File Metadata", + "clean_generated": { + "blob_files": "Blob bestanden", + "image_thumbnails_desc": "Afbeeldingsminiaturen en clips", + "markers": "Markeer Voorbeelden", + "previews": "Scene Voorbeelden", + "sprites": "Scène Sprites", + "transcodes": "Scène Transcoderingen", + "description": "Verwijdert gegenereerde bestanden zonder bijbehorende database-invoer.", + "previews_desc": "Scene voorbeelden en afbeeldingsminiaturen", + "image_thumbnails": "Afbeeldingsminiaturen" + }, + "anonymising_database": "Anonimiseer database", + "anonymise_database": "Maakt een kopie van de database naar de backups-map, waarbij alle gevoelige gegevens anoniem worden gemaakt. Deze kan vervolgens aan anderen worden verstrekt voor probleemoplossing en debugging. De originele database wordt niet gewijzigd. De geanonimiseerde database gebruikt de bestandsnaamindeling {filename_format}.", + "anonymise_and_download": "Maakt een geanonimiseerde kopie van de database en downloadt het resulterende bestand.", + "generate_clip_previews_during_scan": "Genereer voorbeelden van Afbeelingsfragmenten" }, "tools": { "scene_duplicate_checker": "Scène Duplicator Checker", @@ -501,7 +580,7 @@ }, "funscript_offset": { "description": "Time Offset in milliseconden voor het afspelen van interactieve scripts.", - "heading": "Funscript Offset (ms)" + "heading": "" }, "handy_connection": { "connect": "Connecteer", @@ -515,7 +594,7 @@ }, "handy_connection_key": { "description": "Handy connection key om te gebruiken voor interactieve scènes. Instellen van deze sleutel staat Stash toe om uw huidige scène-informatie met HandyFeeling.com te delen", - "heading": "Handy Connection Key" + "heading": "" }, "image_lightbox": { "heading": "Afbeelding Lightbox" @@ -602,11 +681,11 @@ "files": "{count, plural, one {Bestand} other {Bestanden}}", "galleries": "{count, plural, one {Galerij} other {Galerijen}}", "images": "{count, plural, one {Afbeelding} other {Afbeeldingen}}", - "markers": "{count, plural, one {Marker} other {Markers}}", - "performers": "{count, plural, one {Performer} other {Performers}}", - "scenes": "{count, plural, one {Scene} other {Scenes}}", - "studios": "{count, plural, one {Studio} other {Studios}}", - "tags": "{count, plural, one {Tag} other {Tags}}" + "markers": "", + "performers": "", + "scenes": "", + "studios": "", + "tags": "" }, "country": "Land", "cover_image": "Cover afbeelding", @@ -678,7 +757,6 @@ "destination": "Bestemming", "source": "Afkomst" }, - "overwrite_filter_confirm": "Weet u zeker dat u de bestaande opgeslagen zoekopdracht {entityName} wilt overschrijven?", "scene_gen": { "force_transcodes": "Genereren van transcode forceren", "force_transcodes_tooltip": "Standaard worden transcodes alleen gegenereerd als het videobestand niet wordt ondersteund in de browser. Indien ingeschakeld, worden transcodes gegenereerd, zelfs als het videobestand in de browser lijkt te worden ondersteund.", @@ -707,7 +785,7 @@ "preview_seg_count_head": "Aantal segmenten in voorbeeld", "preview_seg_duration_desc": "Duur van elk voorbeeldsegment, in seconden.", "preview_seg_duration_head": "Voorbeeld Segment Duur", - "sprites": "Scene Scrubber Sprites", + "sprites": "Scène Scrubber Sprites", "sprites_tooltip": "De set afbeeldingen die onder de videospeler worden weergegeven voor eenvoudige navigatie.", "transcodes": "Transcoderingen", "transcodes_tooltip": "MP4-conversies van niet-ondersteunde video-indelingen", @@ -720,7 +798,12 @@ "scrape_results_existing": "Bestaande", "scrape_results_scraped": "Geschraapt", "set_image_url_title": "Afbeelding URL", - "unsaved_changes": "Niet-opgeslagen wijzigingen gaan verloren. Weet je zeker dat je wilt vertrekken?" + "unsaved_changes": "Niet-opgeslagen wijzigingen gaan verloren. Weet je zeker dat je wilt vertrekken?", + "merge": { + "destination": "Bestemming", + "source": "Bron" + }, + "performers_found": "{count} artiesten gevonden" }, "dimensions": "Dimensies", "director": "Regisseur", @@ -783,11 +866,11 @@ "filter_name": "Filter Naam", "filters": "Filters", "framerate": "Frame snelheid", - "frames_per_second": "{value} frames per seconde", + "frames_per_second": "{value} fps", "front_page": { "types": { "premade_filter": "Vooraf gemaakte filter", - "saved_filter": "Opgeslagen Filter" + "saved_filter": "Opgeslagen filter" } }, "galleries": "Galerijen", @@ -798,11 +881,11 @@ "FEMALE": "Vrouw", "INTERSEX": "Intersex", "MALE": "Man", - "NON_BINARY": "Non-Binar", + "NON_BINARY": "Non-binair", "TRANSGENDER_FEMALE": "Transgender Vrouw", "TRANSGENDER_MALE": "Transgender Man" }, - "hair_color": "Haar kleur", + "hair_color": "Haarkleur", "handy_connection_status": { "connecting": "Verbinden", "disconnected": "Verbinding verbroken", @@ -828,7 +911,8 @@ "isMissing": "Is Missende", "library": "Bibliotheek", "loading": { - "generic": "Laden…" + "generic": "Laden…", + "plugins": "Plugins laden…" }, "marker_count": "Marker Aantal", "markers": "Markeringen", @@ -841,13 +925,13 @@ "interactive_speed": "Interactieve snelheid", "performer_card": { "age": "{age} {years_old}", - "age_context": "{age} {years_old} in deze scène" + "age_context": "{age} {years_old} bij productie" }, "phash": "PHash", "stream": "Stream", "video_codec": "Video Codec" }, - "megabits_per_second": "{value} megabits per seconde", + "megabits_per_second": "{value} mbps", "metadata": "Metadata", "name": "Naam", "new": "Nieuw", @@ -996,7 +1080,8 @@ "stats": { "image_size": "Afbeelding groote", "scenes_duration": "Scene duur", - "scenes_size": "Scene groote" + "scenes_size": "Scene groote", + "scenes_played": "Scènes gespeeld" }, "status": "Status: {statusText}", "studio": "Studio", @@ -1048,5 +1133,47 @@ "circumcised_types": { "CUT": "Ja", "UNCUT": "Nee" + }, + "folder": "Folder", + "hasChapters": "Heeft hoofdstukken", + "image_index": "Afbeelding #", + "include_sub_groups": "Inclusief subgroepen", + "index_of_total": "{index} van {total}", + "orientation": "Oriëntatie", + "group": "Groep", + "group_count": "Aantal groepen", + "height_cm": "Lengte (cm)", + "history": "Geschiedenis", + "groups": "Groepen", + "file_count": "Aantal bestanden", + "files_amount": "{value} bestanden", + "include_sub_group_content": "Inclusief subgroep inhoud", + "login": { + "username": "Gebruikersnaam", + "password": "Wachtwoord", + "login": "Login", + "invalid_credentials": "Ongeldige gebruikersnaam of wachtwoord" + }, + "group_scene_number": "Scènenummer", + "include_sub_studio_content": "Inclusief substudio inhoud", + "last_played_at": "Laatst gespeeld op", + "include_sub_tag_content": "Inclusief sub-tag inhoud", + "age_on_date": "{age} tijdens productie", + "studio_tagger": { + "query_all_studios_in_the_database": "Alle studio's in de database", + "current_page": "Huidige pagina", + "status_tagging_job_queued": "Status: Tagging-taak in de wachtrij", + "config": { + "no_instances_found": "Geen gevallen gevonden", + "these_fields_will_not_be_changed_when_updating_studios": "Deze velden worden niet gewijzigd bij het updaten van studio's." + }, + "failed_to_save_studio": "Opslaan van studio “{studio}” mislukt", + "name_already_exists": "Naam bestaat reeds", + "network_error": "Netwerkfout", + "no_results_found": "Geen resultaten gevonden.", + "number_of_studios_will_be_processed": "{studio_count} studio's worden verwerkt", + "refresh_tagged_studios": "Vernieuwen getagde studio's", + "refreshing_will_update_the_data": "Vernieuwen zal de gegevens van alle getagde studio's van de stash-box bijwerken.", + "create_or_tag_parent_studios": "Maak ontbrekende of label bestaande moederstudio's" } } diff --git a/ui/v2.5/src/locales/nn-NO.json b/ui/v2.5/src/locales/nn-NO.json index 81d94cf8e..a55928e53 100644 --- a/ui/v2.5/src/locales/nn-NO.json +++ b/ui/v2.5/src/locales/nn-NO.json @@ -102,7 +102,13 @@ "performers_found": "Fann {count} utøvarar", "delete_entity_title": "{count, plural, one {Slett {singularEntity}} other {Slett {pluralEntity}}}", "scenes_found": "Fann {count} scener", - "dont_show_until_updated": "Ikkje vis før neste oppdatering" + "dont_show_until_updated": "Ikkje vis før neste oppdatering", + "imagewall": { + "direction": { + "column": "Kolonne", + "row": "Rad" + } + } }, "date": "Dato", "bitrate": "Bitrate", @@ -143,6 +149,9 @@ } } } + }, + "images": { + "heading": "Bilete" } }, "about": { @@ -229,5 +238,16 @@ "sub_group": "Undergruppe", "sub_group_count": "Tal på undergrupper", "sub_group_of": "Undergruppe av {parent}", - "sub_group_order": "Undergruppesortert" + "sub_group_order": "Undergruppesortert", + "groups": "Grupper", + "performers": "Utøvarar", + "studios": "Studio", + "image": "Bilete", + "images": "Bilete", + "scene": "Scene", + "group": "Gruppe", + "galleries": "Galleri", + "scenes": "Scener", + "studio": "Studio", + "performer": "Utøvar" } diff --git a/ui/v2.5/src/locales/pl-PL.json b/ui/v2.5/src/locales/pl-PL.json index 794497801..4d830a6e7 100644 --- a/ui/v2.5/src/locales/pl-PL.json +++ b/ui/v2.5/src/locales/pl-PL.json @@ -141,7 +141,9 @@ "remove_from_containing_group": "Usuń z grupy", "reset_cover": "Przywróć domyślną okładkę", "add_sub_groups": "Dodaj podgrupy", - "view_history": "Zobacz historię" + "view_history": "Zobacz historię", + "play": "Odtwarzaj", + "show_results": "Pokaż wyniki" }, "actions_name": "Działania", "age": "Wiek", @@ -346,6 +348,9 @@ "desc": "Zaawansowane: Dodatkowe argumenty do przekazania do FFmpeg przed polem wyjściowym podczas generowania wideo.", "heading": "Argumenty wyjścia dla transkodowania z użyciem FFmpeg" } + }, + "download_ffmpeg": { + "heading": "Pobierz FFmpeg" } }, "funscript_heatmap_draw_range": "Bierz pod uwagę zakres dla wygenerowanych heatmap", @@ -832,7 +837,6 @@ "destination": "Cel", "source": "Źródło" }, - "overwrite_filter_confirm": "Czy na pewno chcesz nadpisać istniejące zapisane zapytanie {entityName}?", "reassign_entity_title": "Przypisz {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "reassign_files": { "destination": "Przypisz ponownie do" diff --git a/ui/v2.5/src/locales/pt-BR.json b/ui/v2.5/src/locales/pt-BR.json index b759122d7..40ff46319 100644 --- a/ui/v2.5/src/locales/pt-BR.json +++ b/ui/v2.5/src/locales/pt-BR.json @@ -689,7 +689,6 @@ "destination": "Destino", "source": "Fonte" }, - "overwrite_filter_confirm": "Tem certeza de que deseja sobrescrever a consulta salva existente {entityName}?", "scene_gen": { "force_transcodes": "Forçar geração de transcodificação", "force_transcodes_tooltip": "Por padrão, transcodificações são geradas apenas quando o arquivo de vídeo não é suportado pelo navegador. Quando ativado, transcodificações serão geradas mesmo quando o vídeo parecer ser suportado no navegador.", diff --git a/ui/v2.5/src/locales/ro-RO.json b/ui/v2.5/src/locales/ro-RO.json index f3fc389ba..4e2237729 100644 --- a/ui/v2.5/src/locales/ro-RO.json +++ b/ui/v2.5/src/locales/ro-RO.json @@ -88,7 +88,8 @@ "submit_update": "Trimite actualizare", "tasks": { "clean_confirm_message": "Ești sigur că vrei să cureți? Acest lucru va șterge informațiile din baza de date și conținutul generat pentru toate scenele și galeriile care nu se mai găsesc în sistemul de fișiere.", - "import_warning": "Ești sigur că vrei să imporți? Asta va șterge baza de date si va reimporta din metadatele tale exporatate." + "import_warning": "Ești sigur că vrei să imporți? Asta va șterge baza de date si va reimporta din metadatele tale exporatate.", + "dry_mode_selected": "\"Modul uscat\" selectat. Nu se va șterge nimic, doar se va loga." }, "temp_disable": "Dezactivează temporar…", "temp_enable": "Activează temporar…", @@ -131,7 +132,18 @@ "reshuffle": "Reamestecă", "scrape_query": "Extrage date", "scrape_scene_fragment": "Extrage fragment cu fragment", - "scrape_with": "Extrage cu…" + "scrape_with": "Extrage cu…", + "selective_clean": "Curățare selectivă", + "selective_scan": "Scanare selectivă", + "set_cover": "Setează ca fundal", + "swap": "Schimbă", + "unset": "Nesetat", + "view_history": "Vezi istoric", + "sidebar": { + "close": "Închide bara laterală", + "open": "Deschide bara laterală" + }, + "split": "Împarte" }, "actions_name": "Acțiuni", "age": "Vârstă", @@ -162,16 +174,27 @@ "set_tag_label": "Setați etichete", "show_male_desc": "Comutați dacă interpreții de sex masculin vor fi disponibili pentru etichetare.", "show_male_label": "Arată interpreți de sex masculin", - "source": "Sursă" + "source": "Sursă", + "query_mode_metadata": "Date meta", + "mark_organized_label": "Marchează ca Organizat la salvare", + "errors": { + "blacklist_duplicate": "Lucru de pe lista neagră duplicat" + }, + "mark_organized_desc": "Marchează scena ca Organizată imediat după ce butonul de Salvare este apăsat.", + "query_mode_label": "Mod de căutare", + "blacklist_desc": "Lucrurile din lista neagră sunt excluse din căutări. De reținut, căutările sunt expresii si diferențiază între litere mari și mici. Înaintea anumitor caractere, trebuie pus caracterul backslash: {chars_require_escape}" }, "results": { "duration_unknown": "Durată necunoscută", "match_failed_already_tagged": "Scena este deja etichetată", "match_failed_no_result": "Nu s-au găsit rezultate", "match_success": "Scena a fost etichetată cu succes", - "unnamed": "Fără denumire" + "unnamed": "Fără denumire", + "fp_matches": "Durația este aceeași", + "duration_off": "Durația diferă cu cel puțin {number}s" }, - "verb_toggle_config": "{toggle} {configuration}" + "verb_toggle_config": "{toggle} {configuration}", + "noun_query": "Căutare" }, "config": { "about": { @@ -253,7 +276,6 @@ "created_at": "Creat La", "dialogs": { "delete_confirm": "Ești sigur ca vrei să ștergi {entityName}?", - "overwrite_filter_confirm": "Sunteți sigur că doriți să suprascrieți interogarea salvată existentă {entityName}?", "scene_gen": { "force_transcodes_tooltip": "În mod implicit, transcodurile sunt generate numai atunci când fișierul video nu este acceptat în browser. Atunci când este activată, transcodurile vor fi generate chiar și atunci când fișierul video pare a fi acceptat în browser.", "image_previews": "Imagini animate de previzualizare", @@ -495,5 +517,21 @@ "view_all": "Vezi Toate", "weight": "Greutate", "years_old": "ani", - "containing_group": "Grup aparținător" + "containing_group": "Grup aparținător", + "aliases": "Porecle", + "appears_with": "Apare cu", + "audio_codec": "Codec Audio", + "between_and": "și", + "blobs_storage_type": { + "database": "Bază de date", + "filesystem": "Sistem de fișiere" + }, + "captions": "Subtitrări", + "chapters": "Capitole", + "circumcised_types": { + "CUT": "Circumcis", + "UNCUT": "Necircumcis" + }, + "circumcised": "Circumcizie", + "age_on_date": "{age} la producție" } diff --git a/ui/v2.5/src/locales/ru-RU.json b/ui/v2.5/src/locales/ru-RU.json index b5e6d8dcf..8a3bc0473 100644 --- a/ui/v2.5/src/locales/ru-RU.json +++ b/ui/v2.5/src/locales/ru-RU.json @@ -128,7 +128,7 @@ "assign_stashid_to_parent_studio": "Присвоить Stash ID для текущей родительской студии и обновить метаданные", "add_manual_date": "Добавить дату вручную", "add_o": "Добавить О", - "add_play": "Добавить проигрывание", + "add_play": "Добавить воспроизведение", "choose_date": "Выбрать дату", "clean_generated": "Очистить сгенерированные файлы", "clear_date_data": "Очистить информацию о дате", @@ -139,7 +139,17 @@ "reset_cover": "Восстановить обложку по умолчанию", "set_cover": "Установить как обложку", "add_sub_groups": "Добавить подгруппы", - "remove_from_containing_group": "Удалить из группы" + "remove_from_containing_group": "Удалить из группы", + "reset_play_duration": "Сбросить время воспроизведения", + "reset_resume_time": "Сбросить точку продолжения", + "show_results": "Показать результаты", + "sidebar": { + "toggle": "Показать/скрыть боковую панель", + "open": "Открыть панель", + "close": "Закрыть панель" + }, + "show_count_results": "Показать {count} результат(ов)", + "play": "Воспроизвести" }, "actions_name": "Действия", "age": "Возраст", @@ -183,7 +193,10 @@ "show_male_label": "Показывать актеров мужского пола", "source": "Источник", "mark_organized_label": "Отметить как Организованную при сохранении", - "mark_organized_desc": "Сразу же отметить сцену как Организованную после нажатия кнопки Сохранить." + "mark_organized_desc": "Сразу же отметить сцену как Организованную после нажатия кнопки Сохранить.", + "errors": { + "blacklist_duplicate": "Дублировать элемент чёрного списка" + } }, "noun_query": "Запрос", "results": { @@ -435,7 +448,9 @@ "endpoint": "Конечная точка", "graphql_endpoint": "Конечная точка GraphQL", "name": "Имя", - "title": "Конечные точки Stash-box" + "title": "Конечные точки Stash-box", + "max_requests_per_minute": "Максимальное количество запросов в минуту", + "max_requests_per_minute_description": "Использует значение по умолчанию {defaultValue}, если задано 0" }, "system": { "transcoding": "Транскодирование" @@ -541,7 +556,8 @@ "sprites": "Спрайты Сцен", "transcodes": "Транскоды Сцен" }, - "rescan": "Пересканировать файлы" + "rescan": "Пересканировать файлы", + "rescan_tooltip": "Повторно просканировать все файлы в пути. Используется для обновления метаданных и сканирования ZIP-файлов." }, "tools": { "scene_duplicate_checker": "Проверка сцен на дубликаты", @@ -560,7 +576,9 @@ "whitespace_chars": "Символы пробелов", "whitespace_chars_desc": "Эти символы будут заменены пробелами в названии" }, - "scene_tools": "Инструменты видео" + "scene_tools": "Инструменты видео", + "graphql_playground": "Песочница GraphQL", + "heading": "Инструменты" }, "ui": { "abbreviate_counters": { @@ -694,7 +712,7 @@ } }, "scene_list": { - "heading": "Сетка", + "heading": "Сеточный вид", "options": { "show_studio_as_text": "Отображать названия студии как текст" } @@ -720,7 +738,8 @@ "description": "Кнопка VR будет показана только для сцен с данным тегом.", "heading": "Тег VR" }, - "disable_mobile_media_auto_rotate": "Отключить автоповорот в полноэкранном режиме на мобильных устройствах" + "disable_mobile_media_auto_rotate": "Отключить автоповорот в полноэкранном режиме на мобильных устройствах", + "show_range_markers": "Показать маркеры диапазона" } }, "scene_wall": { @@ -883,7 +902,6 @@ "destination": "Назначение", "source": "Источник" }, - "overwrite_filter_confirm": "Вы уверены, что хотите перезаписать существующий сохраненный запрос {entityName}?", "reassign_entity_title": "{count, plural, one {Переназначить {singularEntity}} other {Переназначить {pluralEntity}}}", "reassign_files": { "destination": "Переназначить на" @@ -944,7 +962,9 @@ }, "clear_play_history_confirm": "Вы уверены, что хотите удалить историю просмотра?", "performers_found": "{count} исполнителей найдено", - "clear_o_history_confirm": "Вы уверены, что хотите очистить историю О?" + "clear_o_history_confirm": "Вы уверены, что хотите очистить историю О?", + "overwrite_filter_warning": "Сохранённый фильтр «{entityName}» будет перезаписан.", + "set_default_filter_confirm": "Вы уверены, что хотите установить этот фильтр по умолчанию?" }, "dimensions": "Размер", "director": "Режиссер", @@ -954,7 +974,8 @@ "list": "Список", "tagger": "Теггер", "unknown": "Неизвестный", - "wall": "Стена" + "wall": "Стена", + "label_current": "Режим отображения: {current}" }, "donate": "Пожертвование", "dupe_check": { @@ -1070,7 +1091,8 @@ "last_played_at": "Воспроизводился в последний раз", "library": "Библиотека", "loading": { - "generic": "Загрузка…" + "generic": "Загрузка…", + "plugins": "Загрузка плагинов…" }, "marker_count": "Количество маркеров", "markers": "Маркеры", @@ -1104,7 +1126,8 @@ "first": "Первая", "last": "Последняя", "next": "Следующая", - "previous": "Предыдущий" + "previous": "Предыдущий", + "current_total": "{current} из {total}" }, "parent_of": "Родитель {children}", "parent_studios": "Родительские студии", @@ -1182,7 +1205,8 @@ "name": "Фильтр", "saved_filters": "Сохраненные фильтры", "update_filter": "Обновить фильтр", - "edit_filter": "Изменить фильтр" + "edit_filter": "Изменить фильтр", + "more_filter_criteria": "+ещё {count}" }, "seconds": "Секунды", "settings": "Настройки", @@ -1204,7 +1228,9 @@ "errors": { "something_went_wrong": "О, нет! Что-то пошло не так!", "something_went_wrong_description": "Если это похоже на проблему с вашими входными данными, нажмите «Назад», чтобы исправить их. В противном случае сообщите об ошибке на {githubLink} или обратитесь за помощью в {discordLink}.", - "something_went_wrong_while_setting_up_your_system": "Что-то пошло не так при настройке вашей системы. Мы получили следующую ошибку: {error}" + "something_went_wrong_while_setting_up_your_system": "Что-то пошло не так при настройке вашей системы. Мы получили следующую ошибку: {error}", + "unexpected_error": "Произошла непредвиденная ошибка: {error}", + "unable_to_retrieve_system_status": "Не удалось получить статус системы: {error}" }, "folder": { "file_path": "Путь файла", @@ -1432,7 +1458,15 @@ "image_index_greater_than_zero": "Индекс изображения должен быть больше 0", "header": "Ошибка", "loading_type": "Ошибка загрузки {type}", - "something_went_wrong": "Что-то пошло по пизде." + "something_went_wrong": "Что-то пошло по пизде.", + "custom_fields": { + "field_name_length": "Имя поля должно содержать меньше 65 символов", + "field_name_required": "Имя поля обязательно", + "field_name_whitespace": "Имя поля не должно начинаться или заканчиваться пробелом", + "duplicate_field": "Имя поля должно быть уникальным" + }, + "invalid_javascript_string": "Недопустимый код JavaScript: {error}", + "invalid_json_string": "Недопустимая JSON-строка: {error}" }, "date_format": "ГГГГ-ММ-ДД", "datetime_format": "ГГГГ-ММ-ДД ЧЧ:ММ", @@ -1451,7 +1485,8 @@ "date_invalid_form": "${path} должен быть в формате ГГГГ-ММ-ДД", "blank": "${path} не должен быть пустым", "required": "${path} обязательное поле", - "unique": "${path} должен быть уникальным" + "unique": "${path} должен быть уникальным", + "end_time_before_start_time": "Время окончания должно быть больше или равно времени начала" }, "unknown_date": "Неизвестная дата", "urls": "URLы", @@ -1471,5 +1506,43 @@ "age_on_date": "{age} на момент съемки", "sub_group_count": "Кол-во подгрупп", "sub_group": "Подгруппа", - "studio_tags": "Теги студии" + "studio_tags": "Теги студии", + "criterion_modifier_values": { + "any": "Любой", + "any_of": "Любой из", + "none": "Отсутствует", + "only": "Только" + }, + "containing_groups": "Группы, в которые входит объект", + "custom_fields": { + "criteria_format_string": "criterion} (пользовательское поле) {modifierString} {valueString}", + "criteria_format_string_others": "{criterion} (пользовательское поле) {modifierString} {valueString} (и ещё {others})", + "field": "Поле", + "title": "Настраиваемые поля", + "value": "Значение" + }, + "containing_group": "Содержащая группа", + "containing_group_count": "Количество содержащих групп", + "time_end": "Время окончания", + "eta": "Ожидаемое время завершения", + "login": { + "username": "Имя пользователя", + "password": "Пароль", + "login": "Логин", + "internal_error": "Произошла внутренняя ошибка. Подробности смотрите в логах.", + "invalid_credentials": "Неверное имя пользователя или пароль" + }, + "groups": "Группы", + "include_sub_tag_content": "Учитывать содержимое вложенных тегов", + "sort_name": "Сортировать по имени", + "include_sub_group_content": "Включать содержимое подгрупп", + "include_sub_studio_content": "Включить данные дочерних студий", + "group": "Группа", + "group_count": "Количество групп", + "group_scene_number": "Номер сцены", + "include_sub_groups": "Включать подгруппы", + "studio_count": "Количество студий", + "sub_group_of": "Входит в группу {parent}", + "sub_group_order": "Сортировка подгрупп", + "sub_groups": "Подгруппы" } diff --git a/ui/v2.5/src/locales/sv-SE.json b/ui/v2.5/src/locales/sv-SE.json index 2180a77f2..152f137d2 100644 --- a/ui/v2.5/src/locales/sv-SE.json +++ b/ui/v2.5/src/locales/sv-SE.json @@ -141,7 +141,15 @@ "add_sub_groups": "Lägg Till Undergrupper", "remove_from_containing_group": "Ta bort från Grupp", "reset_resume_time": "Återställ återupptagningstid", - "set_cover": "Välj som Omslag" + "set_cover": "Välj som Omslag", + "play": "Spela", + "show_count_results": "Visa {count} resultat", + "sidebar": { + "toggle": "Ändra sidolisten", + "close": "Stäng sidolisten", + "open": "Öppna sidolisten" + }, + "show_results": "Visa resultat" }, "actions_name": "Handlingar", "age": "Ålder", @@ -447,7 +455,9 @@ "endpoint": "Adress", "graphql_endpoint": "GraphQL-adress", "name": "Namn", - "title": "Stash-box Adresser" + "title": "Stash-box Adresser", + "max_requests_per_minute": "Högsta antal förfrågningar per minut", + "max_requests_per_minute_description": "Använder standardvärdet {defaultValue} om detta är 0" }, "system": { "transcoding": "Omkodning" @@ -573,7 +583,9 @@ "whitespace_chars": "Blankstegstecken", "whitespace_chars_desc": "Dessa tecken kommer ersättas med blanksteg i titeln" }, - "scene_tools": "Scenverktyg" + "scene_tools": "Scenverktyg", + "graphql_playground": "GraphQL lekplats", + "heading": "Verktyg" }, "ui": { "abbreviate_counters": { @@ -907,7 +919,6 @@ "destination": "Mål", "source": "Källa" }, - "overwrite_filter_confirm": "Är du säker på att du vill skriva över existerande sökning {entityName}?", "performers_found": "{count} stjärnor hittade", "reassign_entity_title": "{count, plural, one {Omplacera {singularEntity}} other {Omplacera {pluralEntity}}}", "reassign_files": { @@ -960,7 +971,9 @@ "set_image_url_title": "URL till bild", "unsaved_changes": "Osparade ändringar. Är du säker att du vill lämna?", "clear_o_history_confirm": "Är du säker på att du vill radera O-historiken?", - "clear_play_history_confirm": "Är du säker på att du vill radera uppspelningshistoriken?" + "clear_play_history_confirm": "Är du säker på att du vill radera uppspelningshistoriken?", + "overwrite_filter_warning": "Sparat filter \"{entityName}\" kommer skrivas över.", + "set_default_filter_confirm": "Är du säker att du vill ställa in detta filter som standard?" }, "dimensions": "Mått", "director": "Regissör", @@ -970,7 +983,8 @@ "list": "Lista", "tagger": "Taggaren", "unknown": "Okänd", - "wall": "Vägg" + "wall": "Vägg", + "label_current": "Visningsläge: {current}" }, "donate": "Donera", "dupe_check": { @@ -1118,7 +1132,7 @@ "interactive_speed": "Interaktiv Hastighet", "performer_card": { "age": "{age} {years_old}", - "age_context": "{age} {years_old} i den här scenen" + "age_context": "{age} {years_old} vid produktion" }, "phash": "PHash", "play_count": "Visningar", @@ -1222,7 +1236,8 @@ "edit_filter": "Ändra Filter", "name": "Filter", "saved_filters": "Sparade filter", - "update_filter": "Uppdatera filter" + "update_filter": "Uppdatera filter", + "more_filter_criteria": "+{count} fler" }, "second": "Sekund", "seconds": "Sekunder", @@ -1516,8 +1531,18 @@ "custom_fields": { "field": "Fält", "title": "Skäddarsydda Fält", - "value": "Värde" + "value": "Värde", + "criteria_format_string": "{criterion} (eget fält) {modifierString} {valueString}", + "criteria_format_string_others": "{criterion} (eget fält) {modifierString} {valueString} (+{others} andra)" }, "eta": "Uppskattad återstående tid", - "sort_name": "Sorteringsnamn" + "sort_name": "Sorteringsnamn", + "login": { + "username": "Användarnamn", + "password": "Lösenord", + "internal_error": "Oväntad internt fel. Se loggen för mer information", + "login": "Inlogg", + "invalid_credentials": "Ogiltigt användarnamn eller lösenord" + }, + "age_on_date": "{age} vid produktion" } diff --git a/ui/v2.5/src/locales/th-TH.json b/ui/v2.5/src/locales/th-TH.json index 0376e880d..e8fdf0e52 100644 --- a/ui/v2.5/src/locales/th-TH.json +++ b/ui/v2.5/src/locales/th-TH.json @@ -1078,7 +1078,6 @@ "destination": "ปลายทาง", "source": "ต้นทาง" }, - "overwrite_filter_confirm": "คุณแน่ใจว่าต้องการเขียนทับเงื่อนไขการค้นหา{entityName}ใช่หรือไม่?", "reassign_files": { "destination": "ย้ายไปที่" }, diff --git a/ui/v2.5/src/locales/tr-TR.json b/ui/v2.5/src/locales/tr-TR.json index 8bd6f874c..5263d398e 100644 --- a/ui/v2.5/src/locales/tr-TR.json +++ b/ui/v2.5/src/locales/tr-TR.json @@ -137,11 +137,23 @@ "assign_stashid_to_parent_studio": "Mevcut ana stüdyoya Stash ID atayın ve üstverileri güncelleyin", "download_anonymised": "Anonim olarak indir", "add_manual_date": "Elle tarih ekle", - "reset_resume_time": "Devam etme süresini sıfırla" + "reset_resume_time": "Devam etme süresini sıfırla", + "split": "Ayır", + "swap": "Değiştir", + "encoding_image": "Resim kodlanıyor…", + "sidebar": { + "close": "Kenar çubuğunu kapat", + "open": "Kenar çubuğunu aç", + "toggle": "Kenar çubuğunu aç/kapat" + }, + "play": "Oynat", + "show_results": "Sonuçları göster", + "show_count_results": "{count} sonucu göster", + "load": "Yükle" }, "actions_name": "Eylemler", "age": "Yaş", - "aliases": "Takma isimler", + "aliases": "Diğer Adlar", "all": "tümü", "also_known_as": "Diğer adıyla", "ascending": "Artan", @@ -152,7 +164,7 @@ "career_length": "Kariyer Uzunluğu", "component_tagger": { "config": { - "active_instance": "Aktif stash-box:", + "active_instance": "Aktif stash-box oturumu:", "blacklist_desc": "Kara listeye alınan kelimeler sorguya eklenmez. Sözkonusu kelimeler kurallı ifadelerdir (regex) ve büyük-küçük harf ayrımına duyarlı değillerdir. Belirli karakterler ters bölü işaretiyle ayrılmalıdır: {chars_require_escape}", "blacklist_label": "Kara liste", "query_mode_auto": "Otomatik", @@ -228,7 +240,7 @@ "system": "Sistem", "tasks": "Görevler", "tools": "Araçlar", - "changelog": "Sürüm Notları" + "changelog": "Değişiklik Günlüğü" }, "dlna": { "allow_temp_ip": "{tempIP} IP adresine izin ver", @@ -287,13 +299,13 @@ "create_galleries_from_folders_desc": "Seçili ise resim içeren dizinlerden galeriler oluşturur.", "create_galleries_from_folders_label": "Resim içeren dizinlerden galeri oluştur", "db_path_head": "Veritabanı Yolu", - "directory_locations_to_your_content": "İçeriğiniz için dizin lokasyonları", + "directory_locations_to_your_content": "İçeriğiniz için dizin konumları", "excluded_image_gallery_patterns_desc": "Tarama ve Temizleme işlemine eklenmeyecek Resim ve Galeri dosyaları/dosya konumları için kurallı ifadeler (Regexp)", "excluded_image_gallery_patterns_head": "Dışta tutulan Resim/Galeri Kuralları", "excluded_video_patterns_desc": "Tarama ve Temizleme işlemine eklenmeyecek Video dosyaları/dosya konumları için kurallı ifadeler (Regexp)", "excluded_video_patterns_head": "Dışta tutulan Video Kuralları", "gallery_ext_desc": "Galeri ZIP dosyaları olarak tanımlanacak dosya uzantıları listesi (virgülle ayrılmış).", - "gallery_ext_head": "Galeri ZIP dosya Uzantıları", + "gallery_ext_head": "Galeri ZIP Uzantıları", "generated_file_naming_hash_desc": "Oluşturulacak dosya isimleri için MD5 veya oshash kullanın. Bu değeri değiştirmek, tüm sahneler için MD5/oshash hesaplaması gerektirir. Bu değeri değiştirdikten sonra mevcut tüm ek dosyalar yeniden oluşturulacak veya yer değiştirecektir. Yer değiştirme işlemleri için Görevler sayfasını ziyaret edin.", "generated_file_naming_hash_head": "Oluşturulan dosya adı imzası", "generated_files_location": "Oluşturulan ek dosyalar için dizin konumu (yer işaretleri, sahne önizlemeler, küçük resimler vb.)", @@ -323,7 +335,7 @@ "heading": "Veri Toplayıcı Yolu" }, "scraping": "Veri Toplama", - "sqlite_location": "SQLite veritabanı için dizin konumu (değiştirirseniz yeniden başlatma gerekir)", + "sqlite_location": "SQLite veritabanı için dosya konumu (yeniden başlatma gerektirir). UYARI: Veritabanını, Stash sunucusunun çalıştığı sistemden farklı bir sistemde (yani ağ üzerinden) depolamak desteklenmemektedir!", "video_ext_desc": "Video olarak işlem görecek dosya uzantı listesi (virgülle ayrılmış).", "video_ext_head": "Video Uzantıları", "video_head": "Video", @@ -386,12 +398,14 @@ "python_path": { "heading": "Python Yürütülebilir Yolu", "description": "Python yürütülebilir dosyasının yolu (yalnızca klasörün değil). Komut dosyası veri kazıyıcılar ve eklentiler için kullanılır. Boşsa, python ortamdan çözümlenecektir" - } + }, + "heatmap_generation": "Funscript Isı Haritası Oluşturma", + "funscript_heatmap_draw_range_desc": "Oluşturulan ısı haritasının y ekseninde hareket aralığını çizin. Değişiklik yapıldıktan sonra mevcut ısı haritalarının yeniden oluşturulması gerekecektir." }, "library": { "exclusions": "Dışta Tutulanlar", "gallery_and_image_options": "Galeri ve Resim seçenekleri", - "media_content_extensions": "Medya içerik uzantıları" + "media_content_extensions": "Medya İçeriği Uzantıları" }, "logs": { "log_level": "Kayıt Tutma Seviyesi" @@ -422,7 +436,9 @@ "endpoint": "Bağlantı Noktası", "graphql_endpoint": "GraphQL bağlantı noktası", "name": "Ad", - "title": "Stash-box Bağlantı Noktaları" + "title": "Stash-box Bağlantı Noktaları", + "max_requests_per_minute": "Dakika başına maksimum istek", + "max_requests_per_minute_description": "0 olarak ayarlanırsa, varsayılan değer olan {defaultValue} kullanılır" }, "system": { "transcoding": "Video Dönüştürme" @@ -515,7 +531,8 @@ "anonymise_and_download": "Veritabanının anonimleştirilmiş bir kopyasını oluşturur ve elde edilen dosyayı indirir.", "anonymise_database": "Tüm hassas verileri anonimleştirerek veritabanının bir kopyasını yedekler dizinine alır. Bu daha sonra sorun giderme ve hata ayıklama amacıyla başkalarına sağlanabilir. Orijinal veritabanı değiştirilmez. Anonimleştirilmiş veritabanı {filename_format} dosya adı biçimini kullanır.", "migrate_scene_screenshots": { - "delete_files": "Ekran görüntülerini sil" + "delete_files": "Ekran görüntülerini sil", + "overwrite_existing": "Mevcut blob'ları ekran görüntüsü verileriyle üzerine yaz" }, "rescan_tooltip": "Yoldaki her dosyayı yeniden tarayın. Dosya üstverilerini güncellemeyi zorlamak ve zip dosyalarını yeniden taramak için kullanılır.", "generate_clip_previews_during_scan": "Resim klipleri için önizlemeler oluştur" @@ -537,7 +554,9 @@ "whitespace_chars": "Boşluk karakterleri", "whitespace_chars_desc": "Bu karakterler başlıkta boşluk karakteri ile değiştirilecektir" }, - "scene_tools": "Sahne Araçları" + "scene_tools": "Sahne Araçları", + "graphql_playground": "GraphQL oyun alanı", + "heading": "Araçlar" }, "ui": { "basic_settings": "Temel Seçenekler", @@ -580,7 +599,7 @@ "half": "Yarım", "quarter": "Çeyrek", "tenth": "Onda bir", - "full": "Dolu" + "full": "Tam" }, "label": "Derecelendirme Yıldızı Hassasiyeti" } @@ -605,7 +624,8 @@ "heading": "Resim önizlemelerini kaydet" }, "create_image_clips_from_videos": { - "heading": "Video Uzantılarını Resim Klibi Olarak Tara" + "heading": "Video Uzantılarını Resim Klibi Olarak Tara", + "description": "Bir kütüphanede videolar devre dışı bırakıldığında, video dosyaları (video uzantısı ile biten dosyalar) Resim Klibi olarak taranacaktır." } } }, @@ -618,7 +638,7 @@ "heading": "Maksimum döngü süresi" }, "menu_items": { - "description": "Gezinti çubuğunda farklı türdeki içerikleri göster veya gizle", + "description": "Gezinti çubuğunda farklı içerik türlerini göster veya gizle", "heading": "Menü Öğeleri" }, "performers": { @@ -645,7 +665,7 @@ } }, "scene_player": { - "heading": "Sahne Oynatıcısı", + "heading": "Sahne Oynatıcı", "options": { "auto_start_video": "Videoları otomatik başlat", "auto_start_video_on_play_selected": { @@ -664,7 +684,9 @@ "track_activity": "Sahne Oynatma Geçmişi'ni etkinleştir", "enable_chromecast": "Chromecast'i Etkinleştir", "show_ab_loop_controls": "AB Loop eklenti kontrollerini göster", - "show_scrubber": "Video İlerleme Çubuğunu Göster" + "show_scrubber": "Video İlerleme Çubuğunu Göster", + "disable_mobile_media_auto_rotate": "Mobil cihazlarda tam ekran medyanın otomatik döndürülmesini devre dışı bırak", + "show_range_markers": "Zaman İşaretleyicilerini Göster" } }, "scene_wall": { @@ -688,6 +710,10 @@ "show_all_details": { "heading": "Tüm ayrıntıları göster", "description": "Etkinleştirildiğinde, varsayılan olarak tüm içerik ayrıntıları gösterilecek ve her ayrıntı öğesi tek bir sütuna sığacak" + }, + "compact_expanded_details": { + "heading": "Kompakt genişletilmiş ayrıntılar", + "description": "Bu seçenek etkinleştirildiğinde, kompakt görünüm korunurken genişletilmiş ayrıntılar gösterilir" } }, "custom_javascript": { @@ -696,12 +722,12 @@ "description": "Değişikliklerin etkili olması için sayfanın yeniden yüklenmesi gerekir. Özel Javascript ile Stash'in gelecekteki sürümleri arasında uyumluluk garantisi yoktur." }, "custom_locales": { - "heading": "Özel yerelleştirme", + "heading": "Özel Yerelleştirme", "option_label": "Özel yerelleştirme etkin", "description": "Bireysel yerel ayar dizelerini geçersiz kılın. Ana liste için https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json adresine bakın. Değişikliklerin etkili olması için sayfanın yeniden yüklenmesi gerekir." }, "studio_panel": { - "heading": "Stüdyo görünümü", + "heading": "Stüdyo Görünümü", "options": { "show_child_studio_content": { "heading": "Alt stüdyo içeriğini görüntüle", @@ -716,7 +742,7 @@ "description": "Etiket görünümündeyken alt etiketlerdeki içeriği de görüntüleyin" } }, - "heading": "Etiket görünümü" + "heading": "Etiket Görünümü" }, "abbreviate_counters": { "heading": "Sayaçları kısalt", @@ -735,10 +761,20 @@ "connect": "Bağlan", "status": { "heading": "Handy Bağlantı Durumu" - } + }, + "server_offset": { + "heading": "Sunucu Zaman Farkı" + }, + "sync": "Senkronize et" }, "use_stash_hosted_funscript": { "heading": "Funscript'leri doğrudan sun" + }, + "show_tag_card_on_hover": { + "heading": "Etiket kartı araç ipuçları" + }, + "scroll_attempts_before_change": { + "heading": "Geçiş Öncesi Kaydırma Denemeleri" } }, "advanced_mode": "Gelişmiş Mod" @@ -829,7 +865,6 @@ "destination": "Hedef Noktası", "source": "Kaynak" }, - "overwrite_filter_confirm": "Kayıtlı {entityName} sorgusunun üzerine yazmak istediğinizden emin misiniz?", "scene_gen": { "force_transcodes": "Dönüştürülmüş video oluşturmayı zorla", "force_transcodes_tooltip": "Varsayılan olarak, video yalnızca video dosyası tarayıcı tarafından desteklenmediğinde dönüştürülür. Etkinleştirildiğinde, video dosyası tarayıcı tarafından destekleniyorsa bile video dönüştürülür.", @@ -839,7 +874,7 @@ "marker_image_previews": "İşaretleyici Hareketli Resim Önizlemeleri", "marker_image_previews_tooltip": "Hareketli Yer İmi WebP önizlemeleri, Önizleme Türü sadece Hareketli Resim olarak seçilmişse gereklidir.", "marker_screenshots": "İşaretleyici Ekran Görüntüleri", - "marker_screenshots_tooltip": "Yer İmi hareketsiz JPG resimleri, Önizleme Türü sadece Hareketsiz Resim olarak seçilmişse gereklidir.", + "marker_screenshots_tooltip": "Yer İmi hareketsiz JPG resimleri", "markers": "İşaretleyici Önizlemeleri", "markers_tooltip": "Belirlenen zamandan itibaren başlayan 20 saniyelik videolar.", "overwrite": "Varolan oluşturulmuş dosyaların üzerine yaz", @@ -877,7 +912,9 @@ "unsaved_changes": "Değişiklikler kaydedilmedi. Sayfadan ayrılmak istediğinize emin misiniz?", "clear_play_history_confirm": "Oynatma geçmişini temizlemek istediğinize emin misiniz?", "merge": { - "source": "Kaynak" + "source": "Kaynak", + "destination": "Hedef", + "empty_results": "Hedef alan değerleri değişmeyecektir." }, "dont_show_until_updated": "Sonraki güncellemeye kadar gösterme", "clear_o_history_confirm": "O geçmişini temizlemek istediğinize emin misiniz?", @@ -894,7 +931,8 @@ "reassign_files": { "destination": "Şuraya yeniden ata" }, - "reassign_entity_title": "{count, plural, one {Yeniden ata {singularEntity}} other {Yeniden ata {pluralEntity}}}" + "reassign_entity_title": "{count, plural, one {Yeniden ata {singularEntity}} other {Yeniden ata {pluralEntity}}}", + "set_default_filter_confirm": "Bu filtreyi varsayılan olarak ayarlamak istediğinize emin misiniz?" }, "dimensions": "Boyutlar", "director": "Yönetmen", @@ -903,7 +941,8 @@ "list": "Liste", "tagger": "Etiketleyici", "unknown": "Bilinmeyen", - "wall": "Duvar" + "wall": "Duvar", + "label_current": "Görüntüleme Modu: {current}" }, "donate": "Bağış Yap", "dupe_check": { @@ -918,7 +957,8 @@ "search_accuracy_label": "Arama Kesinliği", "title": "Yinelenen Sahneler", "duration_options": { - "equal": "Eşit" + "equal": "Eşit", + "any": "Herhangi" }, "duration_diff": "Maksimum Süre Farkı", "select_all_but_largest_file": "En büyük dosya hariç, yinelenen her gruptaki her dosyayı seç", @@ -985,7 +1025,8 @@ "isMissing": "Eksik", "library": "Kütüphane", "loading": { - "generic": "Yükleniyor…" + "generic": "Yükleniyor…", + "plugins": "Eklentiler yükleniyor…" }, "marker_count": "İşaretleyici Sayısı", "markers": "İşaretleyiciler", @@ -1109,7 +1150,8 @@ "path_to_cache_directory_empty_for_default": "önbellek dizini yolu (varsayılan için boş bırakın)", "store_blobs_in_database": "Blob'ları veritabanında depola", "path_to_blobs_directory_empty_for_default": "blobs dizini yolu (varsayılan için boş bırakın)", - "where_can_stash_store_cache_files_description": "Stash, HLS/DASH canlı video dönüştürme gibi bazı işlevlerin çalışabilmesi için geçici dosyalara yönelik bir önbellek dizini gerektirir. Varsayılan olarak, Stash yapılandırma dosyanızı içeren dizin içinde bir cache dizini oluşturacaktır. Bunu değiştirmek istiyorsanız, lütfen mutlak veya göreceli (geçerli çalışma dizinine) bir yol girin. Mevcut değilse, Stash bu dizini oluşturacaktır." + "where_can_stash_store_cache_files_description": "Stash, HLS/DASH canlı video dönüştürme gibi bazı işlevlerin çalışabilmesi için geçici dosyalara yönelik bir önbellek dizini gerektirir. Varsayılan olarak, Stash yapılandırma dosyanızı içeren dizin içinde bir cache dizini oluşturacaktır. Bunu değiştirmek istiyorsanız, lütfen mutlak veya göreceli (geçerli çalışma dizinine) bir yol girin. Mevcut değilse, Stash bu dizini oluşturacaktır.", + "where_can_stash_store_blobs_description_addendum": "Alternatif olarak bu verileri veritabanında saklayabilirsiniz. Not:Bu işlem veritabanınızın boyutunu artıracak ve veritabanı taşıma süresini uzatacaktır." }, "stash_setup_wizard": "Stash Kurulum Sihirbazı", "success": { @@ -1129,11 +1171,12 @@ "welcome": { "config_path_logic_explained": "Stash (config.yml) yapılandırma dosyasını ilk olarak mevcut dizinde bulmaya çalışır. Eğer bulamazsa, $HOME/.stash/config.yml dizinini (Windows işletim sistemi için %USERPROFILE%\\.stash\\config.yml dizini) araştırır. Öte yandan -c '' veya --config '' seçeneklerini kullanarak özelleştirilmiş bir yapılandırma dosyası da kullanabilirsiniz.", "in_current_stash_directory": "{path} yolunda:", - "in_the_current_working_directory": "Mevcut dizinde", + "in_the_current_working_directory": "", "next_step": "Eğer yeni bir sistem oluşturmak için hazırsanız, yapılandırma dosyasının nereye kaydedileceğini seçin ve Sonraki düğmesine basın.", "store_stash_config": "Stash yapılandırmasını nereye kaydetmek istiyorsunuz?", "unable_to_locate_config": "Eğer bunu okuyorsanız, Stash herhangi bir mevcut yapılandırma bulamamış demektir. Bu sihirbaz yeni bir yapılandırma sırasında size yol gösterecektir.", - "unexpected_explained": "Eğer beklenmedik bir şekilde bu ekranı gördüyseniz, Stash uygulamasını doğru dizinden başlatın veya başlatma komutuna -c değişkenini ekleyin." + "unexpected_explained": "Eğer beklenmedik bir şekilde bu ekranı gördüyseniz, Stash uygulamasını doğru dizinden başlatın veya başlatma komutuna -c değişkenini ekleyin.", + "in_the_current_working_directory_disabled": "{path} yolundaki çalışma dizini:" }, "welcome_specific_config": { "config_path": "Stash, yapılandırma dosyası için bu dizini kullanacak: {path}", @@ -1142,7 +1185,7 @@ }, "welcome_to_stash": "Stash uygulamasına hoşgeldiniz" }, - "stash_id": "Stash Kimliği (ID)", + "stash_id": "Stash Kimliği", "stash_ids": "Stash Kimliği", "stats": { "image_size": "Toplam resim boyutu", @@ -1182,7 +1225,8 @@ "started_importing": "İçe aktarma başladı", "updated_entity": "{entity} güncellendi", "merged_scenes": "Birleştirilmiş sahneler", - "reassign_past_tense": "Dosya yeniden atandı" + "reassign_past_tense": "Dosya yeniden atandı", + "removed_entity": "{count, plural, one {{singularEntity}} other {{pluralEntity}}} kaldırıldı" }, "total": "Toplam", "true": "Doğru", @@ -1207,7 +1251,9 @@ "edit_excluded_fields": "Hariç Tutulan Alanları Düzenle", "no_fields_are_excluded": "Hiçbir alan hariç tutulmadı", "create_parent_label": "Ana stüdyo oluştur", - "these_fields_will_not_be_changed_when_updating_studios": "Stüdyolar güncellenirken bu alanlar değişmeyecektir." + "these_fields_will_not_be_changed_when_updating_studios": "Stüdyolar güncellenirken bu alanlar değişmeyecektir.", + "active_stash-box_instance": "Aktif stash-box oturumu:", + "no_instances_found": "Oturum bulunamadı" }, "batch_update_studios": "Stüdyoları Toplu Güncelle", "failed_to_save_studio": "Stüdyo kaydedilemedi \"{studio}\"", @@ -1227,7 +1273,12 @@ "tag_status": "Etiket Durumu", "untagged_studios": "Etiketlenmemiş stüdyolar", "updating_untagged_studios_description": "Etiketlenmemiş stüdyoları güncellemek, stashid'si olmayan tüm stüdyoları eşleştirmeye ve üstverileri güncellemeye çalışacaktır.", - "name_already_exists": "Ad zaten mevcut" + "name_already_exists": "Ad zaten mevcut", + "status_tagging_job_queued": "Durum: Etiketleme işi sıraya alındı", + "any_names_entered_will_be_queried": "Girilen tüm isimler karşıdaki Stash-Box oturumundan sorgulanacak ve bulunursa eklenecektir. Yalnızca tam eşleşmeler bir eşleşme olarak kabul edilecektir.", + "refreshing_will_update_the_data": "Yenileme işlemi stash-box oturumundaki tüm etiketli stüdyoların verilerini güncelleyecektir.", + "to_use_the_studio_tagger": "Stüdyo etiketleyiciyi kullanmak için bir stash-box oturumunun yapılandırılması gerekir.", + "studio_names_separated_by_comma": "Virgülle ayrılmış stüdyo adları" }, "blobs_storage_type": { "database": "Veritabanı", @@ -1261,7 +1312,8 @@ "update": "Güncelle", "version": "Sürüm", "confirm_delete_source": "Kaynağı silmek istediğinize emin misiniz {name} ({url})?", - "required_by": "{packages} için gerekli" + "required_by": "{packages} için gerekli", + "confirm_uninstall": "{number} paketi kaldırmak istediğinize emin misiniz?" }, "penis": "Penis", "penis_length": "Penis Uzunluğu", @@ -1272,7 +1324,9 @@ "excluded_fields": "Hariç tutulan alanlar:", "edit_excluded_fields": "Hariç Tutulan Alanları Düzenle", "no_fields_are_excluded": "Hiçbir alan hariç tutulmadı", - "these_fields_will_not_be_changed_when_updating_performers": "Oyuncular güncellenirken bu alanlar değiştirilmeyecektir." + "these_fields_will_not_be_changed_when_updating_performers": "Oyuncular güncellenirken bu alanlar değiştirilmeyecektir.", + "active_stash-box_instance": "Aktif stash-box oturumu:", + "no_instances_found": "Oturum bulunamadı" }, "current_page": "Geçerli sayfa", "network_error": "Ağ Hatası", @@ -1294,7 +1348,9 @@ "updating_untagged_performers_description": "Etiketlenmemiş oyuncuları güncellemek, stashid'si olmayan tüm oyuncuları eşleştirmeye ve üstverileri güncellemeye çalışacaktır.", "add_new_performers": "Yeni Oyuncular Ekle", "refresh_tagged_performers": "Etiketlenmiş oyuncuları yenile", - "performer_already_tagged": "Oyuncu zaten etikelenmiş" + "performer_already_tagged": "Oyuncu zaten etikelenmiş", + "any_names_entered_will_be_queried": "Girilen tüm isimler karşıdaki Stash-Box oturumundan sorgulanacak ve bulunursa eklenecektir. Yalnızca tam eşleşmeler bir eşleşme olarak kabul edilecektir.", + "refreshing_will_update_the_data": "Yenileme işlemi stash-box oturumundaki tüm etiketli oyuncuların verisini güncelleyecektir." }, "photographer": "Fotoğrafçı", "play_count": "Oynatma Sayısı", @@ -1330,7 +1386,13 @@ "loading_type": "{type} yüklenirken hata oluştu", "something_went_wrong": "Bir şeyler ters gitti.", "lazy_component_error_help": "Stash'i yakın zamanda güncellediyseniz, lütfen sayfayı yeniden yükleyin ya da tarayıcınızın önbelleğini temizleyin.", - "invalid_json_string": "Geçersiz JSON dizesi: {error}" + "invalid_json_string": "Geçersiz JSON dizesi: {error}", + "custom_fields": { + "duplicate_field": "Alan adı benzersiz olmalıdır", + "field_name_required": "Alan adı gereklidir", + "field_name_whitespace": "Alan adının başında veya sonunda boşluk bulunamaz", + "field_name_length": "Alan adı 65 karakterden az olmalıdır" + } }, "sub_group_order": "Alt Grup Sırası", "validation": { @@ -1358,7 +1420,8 @@ "disconnected": "Bağlantı kesildi", "error": "Handy'e bağlanırken hata oluştu", "ready": "Hazır", - "uploading": "Komut dosyası yükleniyor" + "uploading": "Komut dosyası yükleniyor", + "syncing": "Sunucu ile senkronize ediliyor" }, "o_count": "O Sayısı", "o_history": "O Geçmişi", @@ -1381,7 +1444,9 @@ "zip_file_count": "Zip Dosyası Sayısı", "last_played_at": "Son Oynatma Tarihi", "criterion_modifier_values": { - "only": "Sadece" + "only": "Sadece", + "none": "Hiçbiri", + "any_of": "Herhangi biri" }, "history": "Geçmiş", "existing_value": "mevcut değer", @@ -1418,6 +1483,20 @@ "sub_group_of": "{parent} öğesinin alt grubu", "time_end": "Bitiş Zamanı", "custom_fields": { - "value": "Değer" - } + "value": "Değer", + "field": "Alan", + "title": "Özel Alanlar", + "criteria_format_string": "{criterion} (özel alan) {modifierString} {valueString}" + }, + "eta": "Tahmini Kalan Süre", + "login": { + "password": "Şifre", + "internal_error": "Beklenmeyen dahili hata. Daha fazla ayrıntı için günlüklere bakın", + "login": "Giriş Yap", + "username": "Kullanıcı Adı", + "invalid_credentials": "Geçersiz kullanıcı adı veya şifre" + }, + "age_on_date": "Videoda {age} yaşında", + "time": "Başlangıç Zamanı", + "disambiguation": "Ad Ayrımı" } diff --git a/ui/v2.5/src/locales/uk-UA.json b/ui/v2.5/src/locales/uk-UA.json index bbf0e426f..3f44afca1 100644 --- a/ui/v2.5/src/locales/uk-UA.json +++ b/ui/v2.5/src/locales/uk-UA.json @@ -1061,7 +1061,6 @@ "synopsis": "Синопсис", "dialogs": { "delete_gallery_files": "Видалити папку галереї/zip-файл та всі зображення, які не прив'язані до жодної іншої галереї.", - "overwrite_filter_confirm": "Ви впевнені, що хочете перезаписати існуючий збережений запит {entityName}?", "scene_gen": { "marker_image_previews_tooltip": "Також створюйте анімовані (webp) прев’ю, які необхідні лише тоді, коли тип прев’ю для стіни сцен/маркерів встановлено на Анімоване зображення. Під час перегляду вони споживають менше ресурсів CPU, ніж відео-прев’ю, але генеруються додатково до них і займають більше місця на диску.", "transcodes_tooltip": "MP4-транскоди будуть попередньо згенеровані для всього контенту; корисно для повільних процесорів, але вимагає набагато більше дискового простору", diff --git a/ui/v2.5/src/locales/ur-PK.json b/ui/v2.5/src/locales/ur-PK.json new file mode 100644 index 000000000..9558feeaa --- /dev/null +++ b/ui/v2.5/src/locales/ur-PK.json @@ -0,0 +1,14 @@ +{ + "actions": { + "add": "شامل کریں", + "allow": "اجازت دیں", + "add_directory": "ڈکشنری میں شامل کریں", + "cancel": "منسوخ کریں", + "add_manual_date": "تاریخ شامل کریں", + "add_o": "مٹھ کی گنتئ بڑہاین", + "add_play": "پلۓ شامل کریں", + "allow_temporarily": "وقتئ اجازت دیں", + "anonymise": "بےنام کریں", + "apply": "لاگو کریں" + } +} diff --git a/ui/v2.5/src/locales/vi-VN.json b/ui/v2.5/src/locales/vi-VN.json index 72749ca64..25da90f0e 100644 --- a/ui/v2.5/src/locales/vi-VN.json +++ b/ui/v2.5/src/locales/vi-VN.json @@ -134,14 +134,24 @@ "clean_confirm_message": "Bạn có chắc chắn muốn làm sạch không? Thao tác này sẽ xóa thông tin cơ sở dữ liệu và nội dung đã tạo cho tất cả các cảnh và bộ sưu tập không còn tồn tại trong hệ thống tệp.", "import_warning": "Bạn có chắc chắn muốn nhập không? Thao tác này sẽ xóa cơ sở dữ liệu và nhập lại từ siêu dữ liệu đã xuất của bạn." }, - "temp_disable": "Tạm thời vô hiệu hóa…", - "temp_enable": "Tạm thời kích hoạt…", + "temp_disable": "Vô hiệu hóa tạm thời…", + "temp_enable": "Kích hoạt tạm thời…", "unset": "Bỏ thiết lập", - "use_default": "Dùng mặc định", + "use_default": "Dùng thiết lập mặc định", "view_history": "Xem lịch sử", "view_random": "Xem ngẫu nhiên", "set_front_image": "Ảnh trước…", - "select_entity": "Chọn {entityType}" + "select_entity": "Chọn {entityType}", + "play": "Phát", + "show_results": "Hiển thị kết quả", + "show_count_results": "Hiển thị {count} kết quả", + "sidebar": { + "toggle": "Bật thanh bên", + "open": "Mở thanh bên", + "close": "Đóng thanh bên" + }, + "load": "Nạp", + "load_filter": "Nạp bộ lọc" }, "actions_name": "Hành động", "age": "Tuổi", @@ -158,25 +168,886 @@ "bitrate": "Bit Rate", "blobs_storage_type": { "database": "Cơ sở dữ liệu", - "filesystem": "Files hệ thống" + "filesystem": "Tập tin hệ thống" }, "captions": "Tiêu đề", "career_length": "Tuổi nghề", - "chapters": "Chapters", + "chapters": "Chương", "circumcised_types": { "CUT": "Cắt", "UNCUT": "Không cắt" }, - "circumcised": "Cắt bao quy đầu", + "circumcised": "Đã cắt bao quy đầu", "component_tagger": { "config": { "blacklist_desc": "Các mục trong danh sách đen sẽ bị loại trừ khỏi các truy vấn. Lưu ý rằng chúng là các biểu thức chính quy và không phân biệt chữ hoa chữ thường. Một số ký tự cần phải được thoát bằng dấu gạch chéo ngược: {chars_require_escape}", "blacklist_label": "Danh sách đen", - "mark_organized_desc": "Ngay lập tức đánh dấu cảnh là Đã tổ chức sau khi nhấn nút Lưu.", + "mark_organized_desc": "Ngay lập tức đánh dấu cảnh là Đã sắp xếp sau khi nhấn nút Lưu.", "active_instance": "Phiên bản stash-box đang hoạt động:", - "mark_organized_label": "Đánh dấu là Đã tổ chức khi lưu.", + "mark_organized_label": "Đánh dấu là Đã sắp xếp khi lưu.", "query_mode_auto": "Tự động", - "query_mode_auto_desc": "Sử dụng siêu dữ liệu nếu có, hoặc tên tệp." + "query_mode_auto_desc": "Sử dụng siêu dữ liệu nếu có, hoặc tên tệp", + "query_mode_filename": "Tên tệp tin", + "query_mode_filename_desc": "Chỉ dùng tên tệp tin", + "query_mode_label": "Chế độ truy vấn", + "query_mode_path": "Đường dẫn", + "query_mode_path_desc": "Dùng toàn bộ đường dẫn tệp tin", + "set_cover_desc": "Thay thế bìa nền nếu đã được tìm thấy.", + "set_cover_label": "Đặt ảnh bìa nền", + "set_tag_label": "Đặt các thẻ", + "query_mode_dir_desc": "Chỉ dùng thư mục chính của file video", + "set_tag_desc": "Đính kèm các thẻ vào cảnh, bằng cách ghi đè hoặc ghép với các thẻ có sẵn trên cảnh.", + "source": "Nguồn", + "errors": { + "blacklist_duplicate": "Các mục bị trùng lặp trong danh sách đen" + }, + "query_mode_dir": "Danh sách", + "show_male_desc": "Chọn khi thẻ của nam diễn viên có sẵn để gán.", + "show_male_label": "Xem danh sách diễn viên nam", + "query_mode_metadata_desc": "Chỉ dùng dữ liệu mô tả", + "query_mode_metadata": "Thông tin mô tả" + }, + "noun_query": "Truy vấn", + "results": { + "duration_unknown": "Thời gian không xác định", + "fp_matches": "Thời lượng tương ứng", + "fp_matches_multi": "Thời lượng khớp {matchCount}/{durationsLength} dấu vân tay", + "hash_matches": "Khớp với {hash_type}", + "match_failed_already_tagged": "Phân cảnh đã được gắn thẻ", + "match_failed_no_result": "Không có kết quả được tìm thấy", + "match_success": "Phân cảnh đã được gán thẻ thành công", + "phash_matches": "Khớp với {count} PHashes", + "duration_off": "Thời gian tắt ít nhất {number} giây", + "fp_found": "{fpCount, plural, =0 {Không có dấu vết trùng khớp mới được tìm thấy} other {# dấu vết trùng khớp mới đã được tìm thấy}}", + "unnamed": "Chưa được đặt tên" + }, + "verb_match_fp": "Các dấu vân tay trùng khớp", + "verb_matched": "Trùng khớp", + "verb_scrape_all": "Loại bỏ tất cả", + "verb_toggle_config": "{toggle} {configuration}", + "verb_toggle_unmatched": "{toggle} phân cảnh không trùng khớp", + "verb_submit_fp": "Gửi {fpCount, plural, one{# Fingerprint} other{# Fingerprint}}" + }, + "config": { + "about": { + "new_version_notice": "[MỚI]", + "build_hash": "Mã băm bản xây dựng:", + "check_for_new_version": "Kiểm tra phiên bản mới", + "latest_version": "Phiên bản mới nhất", + "latest_version_build_hash": "Mã bản dựng mới nhất:", + "release_date": "Ngày phát hành:", + "stash_discord": "Tham gia vào kênh {url} của chúng tôi", + "stash_open_collective": "Giúp đỡ chúng tôi qua {url}", + "version": "Phiên bản", + "build_time": "Thời điểm tạo:", + "stash_wiki": "Trang {url} của Stash", + "stash_home": "Trang chủ Stash tại {url}" + }, + "application_paths": { + "heading": "Đường dẫn tới ứng dụng" + }, + "categories": { + "about": "Về", + "changelog": "Nhật ký thay đổi", + "interface": "Giao diện", + "logs": "Tập nhật ký", + "plugins": "Các phần bổ trợ", + "security": "Bảo mật", + "services": "Các dịch vụ", + "system": "Hệ thống", + "tasks": "Các công việc", + "tools": "Các công cụ", + "metadata_providers": "Các bên cung cấp thông tin dữ liệu", + "scraping": "Đang quét dữ liệu" + }, + "dlna": { + "allow_temp_ip": "Cho phép {tempIP}", + "allowed_ip_addresses": "Các địa chỉ IP được cho phép", + "default_ip_whitelist": "Danh sách các IP mặc định được cho phép", + "disabled_dlna_temporarily": "Vô hiệu DLNA tạm thời", + "disallowed_ip": "Các IP bị cấm", + "enabled_by_default": "Mặc định được kích hoạt", + "enabled_dlna_temporarily": "Kích hoạt DLNA tạm thời", + "network_interfaces": "Các giao diện", + "recent_ip_addresses": "Các địa chỉ IP gần đây", + "server_display_name": "Tên hiển thị của máy chủ", + "server_display_name_desc": "Tên hiển thị cho máy chủ DLNA. Mặc định là {server_name} nếu để trống.", + "server_port": "Cổng máy chủ", + "server_port_desc": "Cổng cho máy chủ DLNA dùng. Yêu cầu khởi động lại DLNA sau khi sửa đổi.", + "successfully_cancelled_temporary_behaviour": "Hủy bỏ hành vi tạm thời thành công", + "until_restart": "cho tới lúc khởi động lại", + "video_sort_order": "Thứ tự sắp xếp video mặc định", + "video_sort_order_desc": "Thứ tự sắp xếp các video mặc định.", + "allowed_ip_temporarily": "Các địa chỉ IP được cho phép tạm thời", + "default_ip_whitelist_desc": "Các địa chỉ IP mặc định được phép sử dụng DLNA. Dùng {wildcard} để cho phép tất cả các địa chỉ IP.", + "network_interfaces_desc": "Các giao diện để hiển thị máy chủ DLNA. Danh sách trống nghĩa là chạy trên mọi máy chủ. Yêu cần khởi động lại DLNA sau khi sửa đổi." + }, + "general": { + "auth": { + "api_key": "Khóa API", + "authentication": "Xác thực", + "clear_api_key": "Xóa khóa API", + "credentials": { + "heading": "Các thông tin xác thực", + "description": "Tài khoản/mật khẩu dùng để kiểm soát quyền truy cập Stash." + }, + "generate_api_key": "Tạo khóa API", + "log_file": "File nhật ký", + "log_file_desc": "Đường dẫn tới file lưu nhật ký. Để trống để vô hiệu lưu nhật ký vào file. Yêu cầu khởi động lại.", + "log_http": "Nhật ký truy cập HTTP", + "log_to_terminal": "Xuất nhật ký lên terminal", + "maximum_session_age": "Thời gian phiên tối đa", + "maximum_session_age_desc": "Thời gian chờ tối đa trước khi phiên đăng nhập hết hạn, tính bằng giây. Yêu cầu khởi động lại.", + "password": "Mật khẩu", + "password_desc": "Mật khẩu để truy cập Stash. Để trống để tắt xác thực người dùng", + "stash-box_integration": "Tích hợp Stash-box", + "username": "Tên người dùng", + "username_desc": "Tên người dùng để truy cập Stash. Để trống để tắt xác thực người dùng", + "log_http_desc": "Xuất nhật ký truy cập HTTP lên terminal. Yêu cầu khởi động lại.", + "api_key_desc": "Khóa API cho các hệ thống ngoài. Chỉ yêu cầu khóa khi tên người dùng / mật khẩu được thiết lập. Tên người dùng phải được lưu trước khi tạo khóa API.", + "log_to_terminal_desc": "Đẩy nhật ký lên terminal bên cạnh việc lưu vào file. Luôn bật nếu đang vô hiệu lưu vào file. Yêu cầu khởi động lại." + }, + "backup_directory_path": { + "heading": "Đường dẫn tới thư mục sao lưu", + "description": "Vị trí thư mục để sao lưu file dữ liệu SQLite" + }, + "blobs_path": { + "heading": "Đường dẫn tới hệ thống tập tin dữ liệu binary", + "description": "Chỗ nào trong hệ thống file để lưu dữ liệu binary. Chỉ áp dụng cho lựa chọn lưu trữ blob trong hệ thống tệp tin. CẢNH BÁO: thay đổi cài đặt này yêu cầu di chuyển thủ công các dữ liệu hiện tại." + }, + "blobs_storage": { + "heading": "Loại lưu trữ dữ liệu binary", + "description": "Chỗ nào để lưu trữ dữ liệu binary như bìa phân cảnh, người biểu diễn, studio và các nhãn ảnh. Sau khi thay đổi giá trị này, dữ liệu hiện tại phải được chuyển đổi bằng cách sử dụng các tác vụ Chuyển Đổi Các Blob. Xem trang Các Tác Vụ để chuyển đổi." + }, + "cache_location": "Vị trí thư mục cache. Yêu cầu dùng nếu đang truyền trực tiếp sử dụng HLS (như trên thiết bị Apple) hoặc DASH.", + "cache_path_head": "Đường dẫn cache", + "calculate_md5_and_ohash_desc": "Tính toán hàm băm MD5 bên cạnh oshash. Bật lên sẽ làm quá trình quét ban đầu diễn ra chậm hơn. Hàm băm tên file phải đặt về oshash để vô hiệu MD5.", + "calculate_md5_and_ohash_label": "Tính toán MD5 cho các video", + "check_for_insecure_certificates": "Kiểm tra các chứng chỉ không an toàn", + "check_for_insecure_certificates_desc": "Một số trang sử dụng chứng chỉ ssl không an toàn. Nếu bỏ tick, bộ thu thập sẽ bỏ qua quy trình kiểm tra tính an toàn của chứng chỉ và sẽ thu thập toàn bộ các trang đó. Nếu bạn gặp vấn đề về chứng chỉ khi thu thập dữ liệu thì hãy bỏ tick.", + "create_galleries_from_folders_desc": "Nếu bật, các thư mục chứa ảnh sẽ mặc định được tạo thành thư viện. Tạo một tệp có tên .forcegallery hoặc .nogallery trong thư mục để ép buộc hoặc ngăn việc tạo thư viện.", + "create_galleries_from_folders_label": "Tạo thư viện ảnh từ các thư mục chứa hình ảnh", + "database": "Cơ sở dữ liệu", + "db_path_head": "Vị trí tệp cơ sở dữ liệu", + "directory_locations_to_your_content": "Vị trí thư mục chứa nội dung của bạn", + "excluded_image_gallery_patterns_head": "Các mẫu tên ảnh/thư viện cần loại trừ", + "excluded_video_patterns_desc": "Biểu thức chính quy (regex) của các tệp video hoặc đường dẫn cần loại trừ khỏi quá trình Quét và thêm vào mục Dọn dẹp", + "excluded_video_patterns_head": "Các mẫu tên video cần loại trừ", + "ffmpeg": { + "download_ffmpeg": { + "heading": "Tải xuống FFmpeg", + "description": "Tải FFmpeg vào thư mục cấu hình và xóa đường dẫn ffmpeg và ffprobe hiện tại để sử dụng từ thư mục cấu hình." + }, + "ffmpeg_path": { + "heading": "Đường dẫn tệp FFmpeg", + "description": "Đường dẫn đến tệp ffmpeg (không chỉ là thư mục). Nếu để trống, hệ thống sẽ tự tìm ffmpeg từ biến môi trường $PATH, thư mục cấu hình, hoặc từ $HOME/.stash" + }, + "ffprobe_path": { + "description": "Đường dẫn đến tệp ffprobe (không chỉ là thư mục). Nếu để trống, hệ thống sẽ tự tìm ffprobe từ biến môi trường $PATH, thư mục cấu hình hoặc từ $HOME/.stash", + "heading": "Đường dẫn tệp FFprobe" + }, + "hardware_acceleration": { + "desc": "Sử dụng phần cứng hiện có để mã hóa video trong quá trình chuyển mã trực tiếp.", + "heading": "Mã hóa phần cứng bằng FFmpeg" + }, + "live_transcode": { + "output_args": { + "heading": "Tham số đầu ra cho chuyển mã trực tiếp bằng FFmpeg", + "desc": "Nâng cao: Các tham số bổ sung sẽ được truyền vào FFmpeg trước trường đầu ra khi chuyển mã video trực tiếp." + }, + "input_args": { + "desc": "Nâng cao: Các tham số bổ sung sẽ được truyền vào FFmpeg trước trường đầu vào khi chuyển mã video trực tiếp.", + "heading": "Tham số đầu vào cho chuyển mã trực tiếp bằng FFmpeg" + } + }, + "transcode": { + "input_args": { + "desc": "Nâng cao: Các tham số bổ sung sẽ được truyền vào FFmpeg trước trường đầu vào khi tạo video.", + "heading": "Tham số đầu vào khi chuyển mã bằng FFmpeg" + }, + "output_args": { + "heading": "Tham số đầu ra khi chuyển mã bằng FFmpeg", + "desc": "Nâng cao: Các tham số bổ sung sẽ được truyền vào FFmpeg trước trường đầu ra khi tạo video." + } + } + }, + "funscript_heatmap_draw_range": "Bao gồm khoảng giá trị trong các bản đồ nhiệt được tạo ra", + "gallery_cover_regex_desc": "Biểu thức chính quy dùng để nhận diện ảnh bìa của thư viện", + "gallery_cover_regex_label": "Mẫu tên file ảnh dùng làm bìa thư viện", + "gallery_ext_desc": "Danh sách phần mở rộng tệp (file extensions), phân cách bằng dấu phẩy, sẽ được nhận diện là tệp thư viện dạng nén (zip).", + "gallery_ext_head": "Phần mở rộng tệp thư viện nén", + "generated_file_naming_hash_head": "Mã băm dùng để đặt tên cho các tệp được tạo ra", + "generated_files_location": "Vị trí thư mục lưu trữ các tệp được tạo (dấu cảnh, ảnh xem trước cảnh, ảnh sprite, v.v.)", + "generated_path_head": "Thư mục lưu trữ các tệp sinh ra tự động", + "hashing": "Băm (tạo mã băm)", + "heatmap_generation": "Tạo bản đồ nhiệt từ Funscript", + "image_ext_desc": "Danh sách phần mở rộng tệp được nhận diện là hình ảnh, ngăn cách bằng dấu phẩy.", + "image_ext_head": "Đuôi file ảnh", + "include_audio_desc": "Bao gồm luồng âm thanh khi tạo bản xem trước.", + "include_audio_head": "Bao gồm âm thanh", + "maximum_streaming_transcode_size_head": "Kích thước tối đa cho các luồng video đã chuyển mã", + "maximum_transcode_size_desc": "Kích thước tối đa cho các video chuyển mã được tạo ra", + "maximum_transcode_size_head": "Kích thước chuyển mã tối đa", + "metadata_path": { + "heading": "Vị trí thư mục chứa siêu dữ liệu", + "description": "Vị trí thư mục được sử dụng khi thực hiện xuất hoặc nhập toàn bộ dữ liệu" + }, + "number_of_parallel_task_for_scan_generation_head": "Số lượng tác vụ song song cho quá trình quét/tạo dữ liệu", + "parallel_scan_head": "Quét/Tạo dữ liệu song song", + "plugins_path": { + "description": "Vị trí thư mục chứa các tệp cấu hình plugin", + "heading": "Đường dẫn plugins" + }, + "preview_generation": "Tạo bản xem trước", + "python_path": { + "description": "Đường dẫn đến tệp Python (không chỉ là thư mục). Được sử dụng cho các trình quét (scraper) và plugin viết bằng script. Nếu để trống, hệ thống sẽ tự tìm Python từ môi trường", + "heading": "Đường dẫn tệp thực thi Python" + }, + "scrapers_path": { + "description": "Vị trí thư mục chứa các tệp cấu hình của trình quét (scraper)", + "heading": "Đường dẫn đến thư mục chứa các scraper" + }, + "scraping": "Thu thập dữ liệu từ website", + "video_ext_desc": "Danh sách phần mở rộng tệp sẽ được nhận diện là video, phân cách bằng dấu phẩy.", + "video_ext_head": "Định dạng của video", + "video_head": "Video", + "chrome_cdp_path": "Đường dẫn giao thức CDP của Chrome", + "chrome_cdp_path_desc": "Đường dẫn đến tệp thực thi của Chrome, hoặc một địa chỉ từ xa (bắt đầu bằng http:// hoặc https://, ví dụ http://localhost:9222/json/version) trỏ đến một phiên bản Chrome đang chạy.", + "excluded_image_gallery_patterns_desc": "Biểu thức chính quy (regex) của tệp ảnh và thư mục thư viện cần loại trừ khỏi quá trình Quét và thêm vào mục Dọn dẹp", + "funscript_heatmap_draw_range_desc": "Vẽ phạm vi chuyển động trên trục y của bản đồ nhiệt được tạo. Các bản đồ nhiệt hiện có sẽ cần được tạo lại sau khi thay đổi tùy chọn này.", + "generated_file_naming_hash_desc": "Sử dụng MD5 hoặc oshash để đặt tên cho các tệp được tạo. Việc thay đổi tùy chọn này yêu cầu tất cả các cảnh (scenes) phải có giá trị MD5/oshash tương ứng. Sau khi thay đổi, các tệp đã tạo trước đó sẽ cần được di chuyển hoặc tạo lại. Vui lòng xem trang Nhiệm vụ (Tasks) để thực hiện di chuyển.", + "maximum_streaming_transcode_size_desc": "Kích thước tối đa cho các luồng video đã chuyển mã", + "number_of_parallel_task_for_scan_generation_desc": "Đặt giá trị là 0 để hệ thống tự động phát hiện. Cảnh báo: chạy nhiều tác vụ hơn mức cần thiết để sử dụng 100% CPU sẽ làm giảm hiệu suất và có thể gây ra các sự cố khác.", + "scraper_user_agent": "User Agent cho trình quét (scraper)", + "scraper_user_agent_desc": "Chuỗi User-Agent được sử dụng trong các yêu cầu HTTP khi quét dữ liệu (scrape)", + "sqlite_location": "Vị trí tệp cơ sở dữ liệu SQLite (cần khởi động lại). CẢNH BÁO: Lưu cơ sở dữ liệu trên một hệ thống khác với nơi chạy Stash server (ví dụ: qua mạng) là không được hỗ trợ!", + "logging": "Ghi nhật ký" + }, + "advanced_mode": "Chế độ nâng cao", + "stashbox": { + "name": "Tên", + "title": "Các điểm cuối Stash-box", + "add_instance": "Thêm một phiên bản Stash-box", + "api_key": "API key", + "endpoint": "Điểm cuối (Endpoint)", + "graphql_endpoint": "Điểm cuối GraphQL", + "description": "Stash-box hỗ trợ gán tag tự động cho cảnh và diễn viên dựa trên dấu vân tay (fingerprints) và tên tệp.\nEndpoint và khóa API có thể được tìm thấy trong trang tài khoản của bạn trên phiên hoạt động của stash-box. Tên định danh là bắt buộc nếu bạn thêm nhiều hơn một phiên hoạt động.", + "max_requests_per_minute": "Số lượng yêu cầu tối đa mỗi phút", + "max_requests_per_minute_description": "Sử dụng giá trị mặc định là {defaultValue} nếu đặt là 0" + }, + "system": { + "transcoding": "Chuyển đổi định dạng video" + }, + "tasks": { + "added_job_to_queue": "Đã thêm {operation_name} vào hàng đợi công việc", + "anonymising_database": "Đang ẩn danh cơ sở dữ liệu", + "auto_tag": { + "auto_tagging_all_paths": "Tự động gắn tag cho tất cả các đường dẫn", + "auto_tagging_paths": "Đang tự động gắn tag cho các đường dẫn sau" + }, + "backing_up_database": "Sao lưu dữ liệu", + "backup_and_download": "Thực hiện sao lưu cơ sở dữ liệu và tải xuống tệp kết quả.", + "backup_database": "Thực hiện sao lưu cơ sở dữ liệu vào thư mục sao lưu (backups), với định dạng tên tệp là {filename_format}", + "cleanup_desc": "Kiểm tra các tệp bị thiếu và xóa chúng khỏi cơ sở dữ liệu. Đây là hành động có tính phá hủy.", + "clean_generated": { + "blob_files": "Tệp nhị phân (blob)", + "description": "Xóa các tệp đã được tạo nhưng không còn bản ghi tương ứng trong cơ sở dữ liệu.", + "image_thumbnails": "Ảnh thu nhỏ", + "markers": "Xem trước điểm đánh dấu", + "previews": "Xem trước của cảnh", + "previews_desc": "Ảnh và đoạn xem trước cảnh", + "sprites": "Bản ghép khung hình của cảnh", + "transcodes": "Bản video đã chuyển mã của cảnh", + "image_thumbnails_desc": "Ảnh thu nhỏ và đoạn xem trước" + }, + "data_management": "Quản lý dữ liệu", + "dont_include_file_extension_as_part_of_the_title": "Không bao gồm phần đuôi tệp trong tiêu đề", + "empty_queue": "Hiện không có tác vụ nào đang chạy.", + "generate": { + "generating_from_paths": "Đang tạo dữ liệu cho các cảnh từ những đường dẫn sau", + "generating_scenes": "Đang tạo dữ liệu cho {num} {scene}" + }, + "generate_clip_previews_during_scan": "Tạo bản xem trước cho các đoạn ảnh clip", + "generate_desc": "Tạo các tệp hỗ trợ bao gồm ảnh, sprite, video, vtt và các tệp khác.", + "generate_phashes_during_scan": "Tạo mã băm theo đặc điểm hình ảnh", + "generate_previews_during_scan": "Tạo ảnh xem trước động", + "anonymise_and_download": "Tạo một bản sao ẩn danh của cơ sở dữ liệu và tải xuống tệp kết quả.", + "generate_previews_during_scan_tooltip": "Cũng tạo các ảnh xem trước động (định dạng WebP), chỉ cần thiết khi kiểu xem trước Cảnh/Dấu mốc (Scene/Marker Wall Preview Type) được đặt thành Ảnh động. Khi duyệt nội dung, ảnh WebP tiêu tốn ít CPU hơn so với video preview, nhưng sẽ được tạo thêm bên cạnh video và có kích thước tệp lớn hơn.", + "anonymise_database": "Tạo một bản sao của cơ sở dữ liệu trong thư mục sao lưu (backups), đồng thời ẩn danh toàn bộ dữ liệu nhạy cảm. Bản sao này có thể được cung cấp cho người khác để hỗ trợ khắc phục sự cố và gỡ lỗi. Cơ sở dữ liệu gốc sẽ không bị thay đổi. Tệp cơ sở dữ liệu ẩn danh sẽ sử dụng định dạng tên file là {filename_format}.", + "generate_phashes_during_scan_tooltip": "Dùng để loại bỏ trùng lặp và nhận diện Scene.", + "defaults_set": "Giá trị mặc định đã được thiết lập và sẽ được sử dụng khi nhấn nút {action} trên trang Tác vụ (Tasks).", + "export_to_json": "Xuất nội dung cơ sở dữ liệu sang định dạng JSON trong thư mục metadata.", + "auto_tag_based_on_filenames": "Tự động gắn tag cho nội dung dựa trên đường dẫn tệp.", + "auto_tagging": "Gắn tag tự động", + "generate_sprites_during_scan": "Tạo ảnh xem trước cho thanh tua", + "generate_sprites_during_scan_tooltip": "Tập hợp hình ảnh được hiển thị bên dưới trình phát video để hỗ trợ điều hướng dễ dàng.", + "generate_thumbnails_during_scan": "Tạo ảnh thu nhỏ cho các hình ảnh", + "generate_video_covers_during_scan": "Tạo ảnh bìa cho từng cảnh quay", + "generate_video_previews_during_scan": "Tạo đoạn xem trước video", + "generate_video_previews_during_scan_tooltip": "Tạo video xem trước phát tự động khi rê chuột lên cảnh quay", + "generated_content": "Nội dung được tạo tự động", + "identify": { + "and_create_missing": "và tạo các mục còn thiếu", + "create_missing": "Tạo phần bị thiếu", + "default_options": "Tùy Chọn Mặc Định", + "description": "Tự động điền thông tin cảnh quay từ stash-box và các nguồn dữ liệu quét.", + "heading": "Nhận diện", + "identifying_from_paths": "Đang nhận diện các cảnh quay từ các đường dẫn sau", + "identifying_scenes": "Đang nhận diện {num} {scene}", + "include_male_performers": "Bao gồm diễn viên nam", + "set_cover_images": "Thiết lập ảnh bìa", + "set_organized": "Đánh dấu đã sắp xếp", + "skip_multiple_matches_tooltip": "Nếu tùy chọn này không được bật và có nhiều kết quả được trả về, một kết quả sẽ được chọn ngẫu nhiên để khớp", + "skip_single_name_performers_tooltip": "Nếu tùy chọn này không được bật, các diễn viên có tên chung chung như Samantha hoặc Olga vẫn sẽ được gán khớp", + "source": "Nguồn", + "source_options": "Tùy chọn cho {source}", + "sources": "Các nguồn", + "strategy": "Chiến lược", + "tag_skipped_matches": "Gắn thẻ cho các kết quả bị bỏ qua bằng", + "tag_skipped_performer_tooltip": "Tạo một thẻ như 'Nhận diện: Diễn viên một tên' để bạn có thể lọc trong chế độ xem Scene Tagger và tự chọn cách xử lý các diễn viên này", + "tag_skipped_performers": "Gắn thẻ cho các diễn viên bị bỏ qua bằng", + "field_options": "Tùy chọn trường dữ liệu", + "skip_single_name_performers": "Bỏ qua các diễn viên chỉ có một tên mà không có thông tin phân biệt rõ ràng", + "field_behaviour": "{strategy} {field}", + "field": "Trường dữ liệu", + "tag_skipped_matches_tooltip": "Tạo một thẻ như 'Nhận diện: Nhiều kết quả khớp' để bạn có thể lọc trong chế độ xem Scene Tagger và tự chọn kết quả đúng bằng tay", + "explicit_set_description": "Các thiết lập dưới đây sẽ áp dụng trừ khi có thiết lập riêng từ từng nguồn.", + "skip_multiple_matches": "Bỏ qua nếu tìm thấy nhiều kết quả trùng khớp" + }, + "incremental_import": "Chỉ nhập những dữ liệu còn thiếu từ tệp ZIP được cung cấp.", + "job_queue": "Hàng đợi tác vụ", + "maintenance": "Công cụ bảo trì hệ thống", + "migrate_blobs": { + "delete_old": "Xóa dữ liệu không còn sử dụng", + "description": "Di chuyển dữ liệu blob sang hệ thống lưu trữ blob hiện tại. Quá trình di chuyển này nên được thực hiện sau khi thay đổi hệ thống lưu trữ. Có thể chọn xóa dữ liệu cũ sau khi hoàn tất di chuyển." + }, + "migrate_scene_screenshots": { + "delete_files": "Xóa các tệp ảnh chụp màn hình", + "overwrite_existing": "Ghi đè các blob hiện có bằng dữ liệu ảnh chụp màn hình", + "description": "Di chuyển ảnh chụp cảnh quay sang hệ thống lưu trữ blob mới. Quá trình này nên được thực hiện sau khi nâng cấp hệ thống hiện tại lên phiên bản 0.20. Có thể tùy chọn xóa các ảnh chụp cũ sau khi di chuyển." + }, + "migrations": "Chuyển đổi dữ liệu", + "only_dry_run": "Chỉ thực hiện chạy thử. Không xóa bất kỳ dữ liệu nào", + "optimise_database": "Cố gắng cải thiện hiệu suất bằng cách phân tích và sau đó tái tạo lại toàn bộ tệp cơ sở dữ liệu.", + "plugin_tasks": "Các tác vụ của tiện ích mở rộng", + "rescan": "Quét lại tất cả các tệp", + "rescan_tooltip": "Quét lại mọi tệp trong đường dẫn. Dùng để buộc cập nhật metadata của tệp và quét lại các tệp ZIP.", + "scan": { + "scanning_all_paths": "Đang quét tất cả các đường dẫn", + "scanning_paths": "Đang quét các đường dẫn sau" + }, + "scan_for_content_desc": "Quét nội dung mới và thêm vào cơ sở dữ liệu.", + "set_name_date_details_from_metadata_if_present": "Thiết lập tên, ngày và thông tin chi tiết từ metadata được nhúng trong tệp", + "migrate_hash_files": "Được sử dụng sau khi thay đổi mã hash đặt tên tệp để đổi tên các tệp đã tạo sang định dạng hash mới.", + "import_from_exported_json": "Nhập dữ liệu từ tệp JSON đã xuất trong thư mục metadata. Hành động này sẽ xóa toàn bộ cơ sở dữ liệu hiện tại.", + "optimise_database_warning": "Cảnh báo: trong khi tác vụ này đang chạy, mọi thao tác có thay đổi cơ sở dữ liệu sẽ bị lỗi. Tùy vào kích thước cơ sở dữ liệu, quá trình này có thể mất vài phút để hoàn tất. Ngoài ra, bạn cần ít nhất dung lượng ổ đĩa trống tương đương với kích thước cơ sở dữ liệu, nhưng khuyến nghị là 1.5 lần để đảm bảo an toàn." + }, + "library": { + "exclusions": "Danh sách loại trừ", + "gallery_and_image_options": "Tùy chọn Thư viện và Hình ảnh", + "media_content_extensions": "Phần mở rộng của nội dung đa phương tiện" + }, + "logs": { + "log_level": "Cấp độ ghi nhật ký" + }, + "plugins": { + "available_plugins": "Plugin khả dụng", + "hooks": "Cơ chế kích hoạt tự động", + "installed_plugins": "Cài đặt plugin", + "triggers_on": "Kích hoạt khi" + }, + "scraping": { + "available_scrapers": "Scraper khả dụng", + "entity_scrapers": "Thông tin mô tả cho {entityType}", + "excluded_tag_patterns_head": "Mẫu tag bị loại trừ", + "installed_scrapers": "Các trình quét đã cài đặt", + "scraper": "Trình quét dữ liệu tự động", + "scrapers": "Các trình quét dữ liệu", + "search_by_name": "Tìm kiếm theo tên", + "supported_types": "Các loại được hỗ trợ", + "supported_urls": "URLs", + "entity_metadata": "Siêu dữ liệu {entityType}", + "excluded_tag_patterns_desc": "Biểu thức chính quy của tên tag cần loại trừ khỏi kết quả quét dữ liệu" + }, + "tools": { + "scene_duplicate_checker": "Kiểm tra cảnh quay bị trùng", + "scene_filename_parser": { + "add_field": "Thêm trường dữ liệu", + "capitalize_title": "Tự động viết hoa chữ cái đầu của mỗi từ trong tiêu đề", + "display_fields": "Hiển thị các trường dữ liệu", + "escape_chars": "Sử dụng \\ để thoát các ký tự đặc biệt", + "filename": "Tên tệp", + "filename_pattern": "Mẫu tên tệp", + "ignore_organized": "Bỏ qua các cảnh đã được sắp xếp", + "ignored_words": "Từ bị bỏ qua", + "matches_with": "Khớp với {i}", + "title": "Trình phân tích tên tệp cảnh quay", + "whitespace_chars": "Ký tự khoảng trắng", + "select_parser_recipe": "Chọn công thức phân tích", + "whitespace_chars_desc": "Các ký tự này sẽ được thay thế bằng khoảng trắng trong tiêu đề" + }, + "scene_tools": "Công cụ xử lý cảnh quay", + "graphql_playground": "Công cụ GraphGL", + "heading": "Công cụ" + }, + "ui": { + "abbreviate_counters": { + "description": "Rút gọn số đếm trong giao diện thẻ và trang chi tiết, ví dụ '1831' sẽ được định dạng thành '1.8K'.", + "heading": "Rút gọn số đếm" + }, + "basic_settings": "Cài đặt cơ bản", + "custom_css": { + "heading": "CSS tùy chỉnh", + "option_label": "Đã bật CSS tùy chỉnh", + "description": "Trang cần được tải lại để các thay đổi có hiệu lực. Không có gì đảm bảo rằng CSS tùy chỉnh sẽ tương thích với các phiên bản Stash trong tương lai." + }, + "custom_javascript": { + "description": "Hãy tải lại trang để áp dụng thay đổi. JavaScript tùy chỉnh có thể không tương thích với các bản cập nhật sau này.", + "option_label": "Đã bật JavaScript tùy chỉnh", + "heading": "JavaScript tùy chỉnh" + }, + "custom_locales": { + "heading": "Bản địa hóa tùy chỉnh", + "option_label": "Đã bật bản địa hóa tùy chỉnh", + "description": "Ghi đè các chuỗi ngôn ngữ riêng lẻ. Xem danh sách chính tại https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json. Trang cần được tải lại để các thay đổi có hiệu lực." + }, + "delete_options": { + "description": "Thiết lập mặc định cho thao tác xóa ảnh, gallery và cảnh.", + "heading": "Tùy chọn xóa", + "options": { + "delete_file": "Xóa tệp theo mặc định", + "delete_generated_supporting_files": "Xóa các tệp hỗ trợ đã tạo theo mặc định" + } + }, + "desktop_integration": { + "desktop_integration": "Tích hợp Desktop", + "notifications_enabled": "Cho phép Thông báo", + "send_desktop_notifications_for_events": "Gửi thông báo trên màn hình máy tính cho các sự kiện", + "skip_opening_browser": "Bỏ qua việc mở trình duyệt", + "skip_opening_browser_on_startup": "Bỏ qua việc tự động mở trình duyệt khi khởi động" + }, + "detail": { + "compact_expanded_details": { + "description": "Khi được bật, tùy chọn này sẽ hiển thị chi tiết mở rộng trong khi vẫn duy trì giao diện gọn gàng", + "heading": "Thu gọn chi tiết mở rộng" + }, + "enable_background_image": { + "description": "Hiển thị ảnh nền trên trang chi tiết.", + "heading": "Bật ảnh nền" + }, + "heading": "Trang Chi tiết", + "show_all_details": { + "heading": "Hiển thị tất cả chi tiết", + "description": "Khi được bật, tất cả các chi tiết nội dung sẽ được hiển thị theo mặc định và mỗi mục chi tiết sẽ nằm gọn trong một cột duy nhất" + } + }, + "editing": { + "disable_dropdown_create": { + "description": "Xóa bỏ khả năng tạo đối tượng mới từ các bộ chọn thả xuống", + "heading": "Tắt chức năng tạo mới từ danh sách thả xuống" + }, + "heading": "Chỉnh sửa", + "rating_system": { + "star_precision": { + "label": "Độ chính xác của sao đánh giá", + "options": { + "full": "Đầy", + "half": "Một nữa", + "quarter": "1/4", + "tenth": "1/10" + } + }, + "type": { + "label": "Loại Hệ thống Đánh giá", + "options": { + "decimal": "Thập phân", + "stars": "Sao" + } + } + }, + "max_options_shown": { + "label": "Số lượng mục tối đa hiển thị trong danh sách thả xuống" + } + }, + "funscript_offset": { + "description": "Độ lệch thời gian tính bằng mili giây cho việc phát lại các kịch bản tương tác.", + "heading": "Độ lệch Funscript (ms)" + }, + "handy_connection": { + "connect": "Kết nối", + "status": { + "heading": "Trạng thái Kết nối Handy" + }, + "sync": "Đồng bộ hóa", + "server_offset": { + "heading": "Độ lệch Máy chủ" + } + }, + "handy_connection_key": { + "heading": "Khóa Kết nối Handy", + "description": "Khóa kết nối Handy để sử dụng cho các cảnh tương tác. Việc thiết lập khóa này sẽ cho phép Stash chia sẻ thông tin cảnh hiện tại của bạn với handyfeeling.com" + }, + "image_lightbox": { + "heading": "Hộp đèn ảnh" + }, + "image_wall": { + "direction": "Phương hướng", + "heading": "Tường ảnh", + "margin": "Khoảng lề (pixel)" + }, + "images": { + "heading": "Hình ảnh", + "options": { + "create_image_clips_from_videos": { + "heading": "Quét các Phần mở rộng Video dưới dạng Clip hình ảnh", + "description": "Khi một thư viện bị tắt Video, các Tệp video (các tệp có phần mở rộng là Video Extension) sẽ được quét dưới dạng Clip hình ảnh." + }, + "write_image_thumbnails": { + "description": "Ghi hình thu nhỏ của ảnh ra đĩa khi chúng được tạo tự động", + "heading": "Ghi hình thu nhỏ của ảnh" + } + } + }, + "interactive_options": "Tùy chọn Tương tác", + "menu_items": { + "description": "Hiển thị hoặc ẩn các loại nội dung khác nhau trên thanh điều hướng", + "heading": "Mục Menu" + }, + "minimum_play_percent": { + "description": "Phần trăm thời lượng cảnh phải được phát trước khi số lượt phát của nó được tăng lên.", + "heading": "Phần trăm Phát tối thiểu" + }, + "scene_player": { + "options": { + "enable_chromecast": "Bật Chromecast", + "show_ab_loop_controls": "Hiển thị điều khiển plugin Vòng lặp AB", + "show_scrubber": "Hiển thị Thanh điều khiển", + "vr_tag": { + "description": "Nút VR sẽ chỉ hiển thị cho các cảnh có gắn thẻ này.", + "heading": "Thẻ VR" + }, + "show_range_markers": "Hiện thị phạm vị của điểm đánh dấu", + "track_activity": "Bật lịch sử phát cảnh quay", + "auto_start_video_on_play_selected": { + "description": "Tự động phát video cảnh quay khi phát từ hàng chờ, hoặc phát các cảnh được chọn hoặc ngẫu nhiên từ trang Cảnh Quay", + "heading": "Tự động phát video khi phát các mục đã chọn" + }, + "continue_playlist_default": { + "description": "Phát cảnh tiếp theo trong hàng chờ khi video kết thúc", + "heading": "Tiếp tục danh sách phát theo mặc định" + }, + "disable_mobile_media_auto_rotate": "Tắt tự động xoay phương tiện toàn màn hình trên thiết bị Di động", + "always_start_from_beginning": "Luôn phát video từ đầu", + "auto_start_video": "Tự động phát video" + }, + "heading": "Trình phát cảnh quay" + }, + "scene_wall": { + "options": { + "toggle_sound": "Bật âm thanh", + "display_title": "Hiển thị tiêu đề và thẻ" + }, + "heading": "Tường Cảnh Quay / Điểm Đánh Dấu" + }, + "scroll_attempts_before_change": { + "heading": "Số lần thử cuộn trước khi chuyển đổi", + "description": "Số lần thử cuộn trước khi chuyển sang mục tiếp theo/trước đó. Chỉ áp dụng cho chế độ cuộn Pan Y." + }, + "show_tag_card_on_hover": { + "description": "Hiển thị thẻ thông tin thẻ khi di chuột qua các huy hiệu thẻ", + "heading": "Chú giải công cụ thẻ thông tin" + }, + "slideshow_delay": { + "heading": "Độ trễ Trình chiếu (giây)", + "description": "Trình chiếu có sẵn trong các thư viện ảnh khi ở chế độ xem dạng tường" + }, + "studio_panel": { + "heading": "Chế độ xem Studio", + "options": { + "show_child_studio_content": { + "description": "Trong chế độ xem Studio, hãy hiển thị cả nội dung từ các studio phụ", + "heading": "Hiển thị nội dung của các studio phụ" + } + } + }, + "tag_panel": { + "heading": "Chế độ xem Thẻ", + "options": { + "show_child_tagged_content": { + "heading": "Hiển thị nội dung của thẻ phụ", + "description": "Trong chế độ xem thẻ, hãy hiển thị cả nội dung từ các thẻ phụ" + } + } + }, + "title": "Giao diện Người dùng", + "use_stash_hosted_funscript": { + "heading": "Phục vụ/Truyền funscript trực tiếp", + "description": "Khi được bật, funscript sẽ được phục vụ trực tiếp từ Stash đến thiết bị Handy của bạn mà không sử dụng máy chủ Handy của bên thứ ba. Yêu cầu Stash phải truy cập được từ thiết bị Handy của bạn và cần tạo khóa API nếu Stash có cấu hình thông tin xác thực." + }, + "max_loop_duration": { + "heading": "Thời lượng lặp tối đa", + "description": "Thời lượng cảnh tối đa (tính bằng giây) mà trình phát cảnh sẽ lặp lại video - 0 để tắt" + }, + "performers": { + "options": { + "image_location": { + "description": "Đường dẫn tùy chỉnh cho ảnh mặc định của người biểu diễn. Để trống để sử dụng các mặc định có sẵn", + "heading": "Đường dẫn Ảnh Người biểu diễn Tùy chỉnh" + } + } + }, + "preview_type": { + "description": "Tùy chọn mặc định là xem trước video (mp4). Để giảm sử dụng CPU khi duyệt, bạn có thể dùng xem trước ảnh động (webp). Tuy nhiên, chúng phải được tạo ra ngoài các bản xem trước video và có kích thước tệp lớn hơn.", + "heading": "Kiểu Xem trước", + "options": { + "animated": "Ảnh động", + "static": "Ảnh tĩnh", + "video": "Video" + } + }, + "scene_list": { + "heading": "Chế độ xem dạng lưới", + "options": { + "show_studio_as_text": "Hiển thị lớp phủ studio dưới dạng văn bản" + } + }, + "language": { + "heading": "Ngôn ngữ" + } } - } + }, + "age_on_date": "{age} tại thời điểm sản xuất", + "description": "Mô Tả", + "custom_fields": { + "title": "Tùy Chỉnh Trường (dữ liệu)", + "criteria_format_string": "{Tiêu chí} (trường tùy chỉnh) {Điều kiện} {Giá trị}", + "value": "Giá trị", + "criteria_format_string_others": "{Tiêu chí} (trường tùy chỉnh) {Điều kiện} {Giá trị} (+{số lượng khác} mục khác)", + "field": "Trường (dữ liệu)" + }, + "dialogs": { + "delete_entity_title": "{count, plural, one {Xóa {singularEntity}} other {Xóa {pluralEntity}}}", + "delete_galleries_extra": "...cộng với bất kỳ tệp ảnh nào không được đính kèm vào bất kỳ thư viện ảnh nào khác.", + "delete_object_title": "Xóa {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "lightbox": { + "scale_up": { + "description": "Phóng lớn ảnh nhỏ để lấp đầy màn hình", + "label": "Phóng lớn để vừa" + }, + "delay": "Độ Trễ (Giây)", + "display_mode": { + "fit_horizontally": "Căn chỉnh vừa theo chiều ngang", + "fit_to_screen": "Vừa màn hình", + "label": "Chế độ hiển thị", + "original": "Bản gốc" + }, + "options": "Tùy Chọn", + "page_header": "Trang {page} / {total}", + "reset_zoom_on_nav": "Đặt lại mức thu phóng khi đổi ảnh", + "scroll_mode": { + "description": "Giữ phím Shift để tạm thời dùng chế độ khác.", + "pan_y": "Pan (chiều) Y", + "label": "Chế độ cuộn", + "zoom": "Phóng" + } + }, + "clear_o_history_confirm": "Bạn có chắc chắn muốn xóa lịch sử O không?", + "clear_play_history_confirm": "Bạn có chắc muốn xóa lịch sử phát không?", + "create_new_entity": "Tạo mới {entity}", + "delete_entity_simple_desc": "{count, plural, one {Bạn có chắc chắn muốn xóa {singularEntity} này không?} other {Bạn có chắc chắn muốn xóa {pluralEntity} này không?}}", + "delete_gallery_files": "Xóa thư mục/tệp zip thư viện ảnh và bất kỳ ảnh nào không được đính kèm vào thư viện ảnh khác.", + "delete_object_desc": "Bạn có chắc muốn xóa {count, plural, one {{singularEntity} này} other {{pluralEntity} này} } không?", + "dont_show_until_updated": "Không hiển thị cho đến bản cập nhật tiếp theo", + "export_include_related_objects": "Bao gồm các đối tượng liên quan khi xuất", + "export_title": "Xuất (dữ liệu)", + "imagewall": { + "direction": { + "column": "Cột", + "description": "Bố cục dựa trên cột hoặc hàng.", + "row": "Hàng" + }, + "margin_desc": "Số lượng pixel lề xung quanh mỗi ảnh." + }, + "edit_entity_title": "Chỉnh sửa {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "delete_alert": "Các {count, plural, one {{singularEntity}} other {{pluralEntity}}} sau đây sẽ bị xóa vĩnh viễn:", + "delete_confirm": "Bạn có chắc chắn muốn xóa {entityName} không?", + "delete_entity_desc": "{count, plural, one {Bạn có chắc chắn muốn xóa {singularEntity} này không? Trừ khi tệp cũng bị xóa, {singularEntity} này sẽ được thêm lại khi quá trình quét được thực hiện.} other {Bạn có chắc chắn muốn xóa {pluralEntity} này không? Trừ khi các tệp cũng bị xóa, {pluralEntity} này sẽ được thêm lại khi quá trình quét được thực hiện.}}", + "delete_object_overflow": "...và {count} {count, plural, one {{singularEntity}} other {{pluralEntity}}} khác.", + "overwrite_filter_warning": "Bộ lọc đã lưu \"{entityName}\" sẽ bị ghi đè.", + "scene_gen": { + "clip_previews": "Xem trước Hình ảnh từ Clip", + "marker_screenshots": "Ảnh màn hình Điểm đánh dấu", + "marker_screenshots_tooltip": "Điểm đánh dấu ảnh tĩnh JPG", + "overwrite": "Ghi đè các tệp tin có sẫn", + "phash": "Băm (hash) nhận thức", + "preview_generation_options": "Lựa chọn Tạo ra Bản xem trước", + "covers": "Bìa cảnh quay", + "force_transcodes": "Ép chuyển mã", + "image_previews": "Xem trước Hình ảnh Động", + "image_thumbnails": "Hình thu nhỏ của Ảnh", + "interactive_heatmap_speed": "Tạo bản đồ nhiệt và tốc độ cho các cảnh tương tác", + "marker_image_previews": "Xem trước Điểm đánh dấu Ảnh động", + "marker_image_previews_tooltip": "Ngoài ra sẽ tạo nên những hình xem trước động (webp), chỉ bắt buộc khi Loại Cảnh/Dấu Tường Xem Trước được thành Ảnh Động. Khi lướt nó sử dụng ít CPU hơn video xem trước, nhưng khi tạo nên thì đi kèm với chúng và là những file lớn hơn.", + "markers": "Xem trước Điểm đánh dấu", + "markers_tooltip": "Video 20 giây bắt đầu tại điểm thời gian đã cho sẫn.", + "override_preview_generation_options": "Ghi đè các lựa chọn tạo ra Bản xem trước", + "phash_tooltip": "Cho việc khử trùng lặp và phát hiện cảnh quay", + "preview_exclude_end_time_desc": "Bỏ qua x giây cuối cùng từ các bản xem trước cảnh. Đây có thể là một giá trị bằng giây, hoặc phần trăm (ví dụ 2%) của tổng thời gian cảnh quay.", + "preview_exclude_start_time_head": "Bỏ qua thời gian bắt đầu", + "preview_options": "Lựa chọn Bản xem trước", + "preview_preset_desc": "Phần cài đặt trước điều chỉnh kích thước, chất lượng và thời gian mã hóa của bản xem trước. Các cài đặt trước vượt quá việc \"chậm\" có hiệu năng giảm dần và không được khuyến khích.", + "preview_preset_head": "Xem trước phần cài đặt trước cho việc mã hóa", + "preview_exclude_end_time_head": "Bỏ qua thời gian kết thúc", + "force_transcodes_tooltip": "Mặc định, việc chuyển mã sẽ được tạo ra khi file video không được hỗ trợ trong trình duyệt. Khi kích hoạt, việc chuyển mã sẽ bắt đầu kể cả khi file video có vẻ được hỗ trợ bởi trình duyệt.", + "image_previews_tooltip": "Ngoài ra sẽ tạo nên những hình xem trước động (webp), chỉ bắt buộc khi Loại Cảnh/Dấu Tường Xem Trước được thành Ảnh Động. Khi lướt nó sử dụng ít CPU hơn video xem trước, nhưng khi tạo nên thì đi kèm với chúng và là những file lớn hơn.", + "preview_exclude_start_time_desc": "Bỏ qua x giây đầu tiên từ các bản xem trước cảnh. Đây có thể là một giá trị bằng giây, hoặc phần trăm (ví dụ 2%) của tổng thời gian cảnh quay.", + "override_preview_generation_options_desc": "Ghi đè các lựa chọn tạo ra Bản xem trước cho hành động này. Những thiết lập mặc định được đặt trong Hệ thống -> Tạo ra Bản xem trước.", + "preview_seg_count_desc": "Số lượng phân đoạn trong các file bản xem trước.", + "preview_seg_count_head": "Số lượng phân đoạn trong bản xem trước", + "preview_seg_duration_desc": "Độ dài của mỗi đoạn xem trước, tính theo giây.", + "preview_seg_duration_head": "Độ dài đoạn xem trước", + "sprites": "Sprites của Phần tìm Phân cảnh", + "sprites_tooltip": "Danh sách ảnh hiển thị ở dưới video để dễ thao tác", + "transcodes": "Giải mã", + "video_previews": "Xem trước", + "transcodes_tooltip": "Mã chuyển đổi MP4 sẽ được tạo trước cho tất cả nội dung; hữu ích cho CPU chậm nhưng cần nhiều dung lượng hơn", + "video_previews_tooltip": "Bản xem trước video phát khi di chuột qua một cảnh" + }, + "merge": { + "destination": "Đích đến", + "source": "Nguồn", + "empty_results": "Giá trị điền vào mục Đích đến sẽ không bị thay đổi." + }, + "merge_tags": { + "destination": "Đích đến", + "source": "Nguồn" + }, + "performers_found": "Đã tìm thấy {count} diễn viên", + "reassign_entity_title": "{count, plural, one {Chỉnh lại {singularEntity}} other {Chỉnh lại {pluralEntity}}}", + "reassign_files": { + "destination": "Chỉnh lại đến" + }, + "scrape_results_existing": "Hiện có", + "set_default_filter_confirm": "Bạn có chắc chắn muốn đặt bộ lọc này làm mặc định không?", + "set_image_url_title": "URL hình ảnh", + "unsaved_changes": "Thay đổi chưa được lưu. Bạn có chắc chắn muốn thoát không?", + "scenes_found": "tìm được {count} cảnh quay" + }, + "configuration": "Cấu Hình", + "connection_monitor": { + "websocket_connection_failed": "Không thể tạo kết nối websocket: xem bảng điều khiển trình duyệt để biết chi tiết", + "websocket_connection_reestablished": "Kết nối Websocket đã được thiết lập lại" + }, + "containing_group": "Nhóm chứa", + "containing_groups": "Các Nhóm chứa", + "countables": { + "files": "{count, plural, one {Tệp} other {Các tệp}}", + "groups": "{count, plural, one {Nhóm} other {Các nhóm}}", + "images": "{count, plural, one {Ảnh} other {Các ảnh}}", + "markers": "{count, plural, one {Dấu} other {Các dấu}}", + "scenes": "{count, plural, one {Cảnh} other {Các cảnh}}", + "studios": "{count, plural, one {Studio} other {Các studio}}", + "tags": "{count, plural, one {Thẻ} other {Các thẻ}}", + "performers": "{count, plural, one {Người biểu diễn} other {Các người biểu diễn}}", + "galleries": "{count, plural, one {Thư viện ảnh} other {Các thư viện ảnh}}" + }, + "country": "Quốc Gia", + "cover_image": "Ảnh Bìa", + "created_at": "Thời gian tạo", + "criterion": { + "greater_than": "Lớn hơn", + "less_than": "Nhỏ hơn", + "value": "Giá trị" + }, + "criterion_modifier": { + "between": "Giữa", + "equals": "Là", + "format_string": "{criterion} {modifierString} {valueString}", + "format_string_excludes": "{Tiêu chí} {Điều kiện} {Giá trị} (loại trừ {Ngoại lệ})", + "format_string_excludes_depth": "{Tiêu chí} {Điều kiện} {Giá trị} (loại trừ {Ngoại lệ}) (+{depth, plural, =-1 {tất cả} other {{depth}}})", + "greater_than": "lớn hơn", + "includes": "Bao gồm", + "includes_all": "Bao gồm tất cả", + "is_null": "rỗng", + "less_than": "nhỏ hơn", + "matches_regex": "Khớp biểu thức chính quy", + "not_between": "không nằm giữa", + "not_equals": "không phải", + "not_null": "không rỗng", + "format_string_depth": "{criterion} {modifierString} {valueString} (+{depth, plural, =-1 {all} other {{depth}}})", + "not_matches_regex": "Không khớp biểu thức chính quy", + "excludes": "Không bao gồm" + }, + "death_date": "Ngày Mất", + "death_year": "Năm Mất", + "descending": "Giảm dần", + "details": "Thông tin chi tiết", + "developmentVersion": "Phiên bản Phát triển", + "criterion_modifier_values": { + "any_of": "bất kỳ nào trong số", + "none": "không gì cả", + "only": "Duy nhất", + "any": "bất kỳ" + }, + "custom": "Tùy chỉnh", + "date": "Ngày", + "date_format": "YYYY-MM-DD", + "containing_group_count": "Số lượng Nhóm chứa", + "detail": "Chi tiết", + "datetime_format": "YYYY-MM-DD HH:MM", + "dimensions": "Kích thước", + "director": "Đạo diễn", + "display_mode": { + "grid": "Lưới", + "list": "Danh sách", + "unknown": "Chưa biết", + "label_current": "Chế độ Hiển Thị: {current}", + "wall": "Tường" + }, + "distance": "Khoảng cách", + "donate": "Ủng hộ", + "dupe_check": { + "duration_diff": "Chênh lệch thời lượng tối đa", + "only_select_matching_codecs": "Chỉ chọn nếu tất cả codec khớp với nhóm trùng lặp", + "options": { + "high": "Cao", + "low": "Thấp", + "medium": "Trung bình", + "exact": "Chính xác" + }, + "search_accuracy_label": "Độ chính xác tìm kiếm", + "select_all_but_largest_file": "Chọn mọi tệp trong mỗi nhóm trùng lặp, ngoại trừ tệp lớn nhất", + "select_all_but_largest_resolution": "Chọn mọi tệp trong mỗi nhóm được sao chép, ngoại trừ tệp có độ phân giải cao nhất", + "select_none": "Chọn Không", + "select_oldest": "Chọn tệp cũ nhất trong nhóm trùng lặp", + "select_options": "Chọn Tùy chọn…", + "select_youngest": "Chọn tệp mớinhất trong nhóm trùng lặp", + "title": "Cảnh quay trùng lặp", + "duration_options": { + "any": "Bất kỳ", + "equal": "Tương đương" + } + }, + "duplicated_phash": "Trùng lặp (pHash)", + "duration": "Thời lượng", + "effect_filters": { + "aspect": "Tỷ lệ", + "blur": "Mờ", + "brightness": "Độ sáng", + "contrast": "Tương phản", + "green": "Xanh lá", + "hue": "Sắc thái", + "name": "Bộ lọc", + "name_transforms": "Biến đổi", + "red": "Đỏ", + "reset_filters": "Đặt lại bộ lọc", + "reset_transforms": "Đặt lại chuyển đổi", + "rotate": "Xoay", + "rotate_left_and_scale": "Xoay trái và thay đổi tỷ lệ", + "rotate_right_and_scale": "Xoay phải và thay đổi tỷ lệ", + "saturation": "Độ bão hòa", + "scale": "Tỉ lệ", + "warmth": "Sự ấm áp" + }, + "empty_server": "Thêm một số cảnh quay vào máy chủ của bạn để xem các đề xuất trên trang này." } diff --git a/ui/v2.5/src/locales/zh-CN.json b/ui/v2.5/src/locales/zh-CN.json index 6ebb8d7b6..415c17976 100644 --- a/ui/v2.5/src/locales/zh-CN.json +++ b/ui/v2.5/src/locales/zh-CN.json @@ -141,7 +141,17 @@ "reset_play_duration": "重置播放时长", "reset_resume_time": "重置恢复时间", "add_sub_groups": "添加子集合", - "remove_from_containing_group": "从集合中移除" + "remove_from_containing_group": "从集合中移除", + "sidebar": { + "close": "关闭侧边栏", + "open": "打开侧边栏", + "toggle": "切换侧边栏" + }, + "play": "播放", + "show_results": "显示结果", + "show_count_results": "显示{count}个结果", + "load": "加载", + "load_filter": "加载过滤器" }, "actions_name": "操作", "age": "年龄", @@ -446,7 +456,9 @@ "endpoint": "入口", "graphql_endpoint": "GraphQL 入口", "name": "名称", - "title": "Stash-box 入口" + "title": "Stash-box 入口", + "max_requests_per_minute": "每分钟最多可发起请求数量", + "max_requests_per_minute_description": "使用{defaultValue}的默认数值,当其数值设置为0时" }, "system": { "transcoding": "转码" @@ -572,7 +584,9 @@ "whitespace_chars": "空白字符", "whitespace_chars_desc": "这些字符在标题中会替换为空白字符" }, - "scene_tools": "短片工具" + "scene_tools": "短片工具", + "heading": "工具", + "graphql_playground": "GraphQL试验场" }, "ui": { "abbreviate_counters": { @@ -906,7 +920,6 @@ "destination": "目标", "source": "源" }, - "overwrite_filter_confirm": "确定要覆盖现有的已保存查询 {entityName} 吗?", "reassign_entity_title": "{count, plural, one {Reassign {singularEntity}} 其它 {Reassign {pluralEntity}}}", "reassign_files": { "destination": "重新指定至" @@ -959,7 +972,9 @@ "unsaved_changes": "更改未保存,确定离开吗?", "performers_found": "找到{count} 个演员", "clear_o_history_confirm": "真的确定要清空高潮记录吗?", - "clear_play_history_confirm": "真的确定要清空播放历史?" + "clear_play_history_confirm": "真的确定要清空播放历史?", + "set_default_filter_confirm": "你确定要设置这个过滤器为默认吗?", + "overwrite_filter_warning": "已保存的过滤器 \"{entityName}\" 将被覆盖。" }, "dimensions": "大小", "director": "导演", @@ -969,7 +984,8 @@ "list": "列表显示", "tagger": "标签工具", "unknown": "未知", - "wall": "预览墙" + "wall": "预览墙", + "label_current": "显示模式: {current}" }, "donate": "赞助", "dupe_check": { @@ -1216,7 +1232,9 @@ "edit_filter": "编辑筛选器", "name": "过滤", "saved_filters": "保存过滤器", - "update_filter": "更新过滤器" + "update_filter": "更新过滤器", + "more_filter_criteria": "+{count}个更多", + "search_term": "搜索词" }, "second": "秒", "seconds": "秒", @@ -1522,5 +1540,12 @@ }, "eta": "预估剩余时间", "sort_name": "排序用名", - "age_on_date": "在制作时{age}岁" + "age_on_date": "在制作时{age}岁", + "login": { + "username": "用户名", + "password": "密码", + "invalid_credentials": "无效的用户名或密码", + "internal_error": "意外的内部错误。有关更多详细信息,请查看日志", + "login": "登录" + } } diff --git a/ui/v2.5/src/locales/zh-TW.json b/ui/v2.5/src/locales/zh-TW.json index 264973de3..90db9e296 100644 --- a/ui/v2.5/src/locales/zh-TW.json +++ b/ui/v2.5/src/locales/zh-TW.json @@ -141,7 +141,15 @@ "reset_resume_time": "重置恢復時間", "set_cover": "設為封面", "remove_from_containing_group": "從群組中刪除", - "add_sub_groups": "新增子分類" + "add_sub_groups": "新增子分類", + "sidebar": { + "close": "關閉側邊攔", + "open": "開啟側邊攔", + "toggle": "切換側邊欄" + }, + "show_results": "顯示結果", + "show_count_results": "顯示 {count} 筆結果", + "play": "播放" }, "actions_name": "動作", "age": "年齡", @@ -440,7 +448,9 @@ "endpoint": "端點", "graphql_endpoint": "GraphQL 端點", "name": "名稱", - "title": "Stash-box 端點" + "title": "Stash-box 端點", + "max_requests_per_minute": "每分鐘請求上限", + "max_requests_per_minute_description": "當設為 0 時,會套用預設值 {defaultValue}" }, "system": { "transcoding": "轉檔" @@ -566,7 +576,9 @@ "whitespace_chars": "空白字元", "whitespace_chars_desc": "這些字元將在標題中被空格取代" }, - "scene_tools": "短片工具" + "scene_tools": "短片工具", + "heading": "工具", + "graphql_playground": "GraphQL 測試環境" }, "ui": { "abbreviate_counters": { @@ -726,7 +738,8 @@ }, "enable_chromecast": "啟用 Chromecast", "show_ab_loop_controls": "顯示AB循環插件控件", - "disable_mobile_media_auto_rotate": "停用行動裝置上全螢幕媒體的自動旋轉功能" + "disable_mobile_media_auto_rotate": "停用行動裝置上全螢幕媒體的自動旋轉功能", + "show_range_markers": "顯示範圍標記" } }, "scene_wall": { @@ -889,7 +902,6 @@ "destination": "目的地", "source": "來源" }, - "overwrite_filter_confirm": "您確定要覆蓋現有的條件 {entityName} 嗎?", "reassign_entity_title": "{count, plural, one {重新指定{singularEntity}}}", "reassign_files": { "destination": "重新指定至" @@ -950,7 +962,9 @@ }, "clear_o_history_confirm": "您確定要清除尻尻紀錄嗎?", "clear_play_history_confirm": "您確定要清除播放紀錄嗎?", - "performers_found": "找到{count} 個演員" + "performers_found": "找到{count} 個演員", + "set_default_filter_confirm": "是否確定設定這一個過濾條件為預設?", + "overwrite_filter_warning": "篩選器 \"{entityName}\" 已存在,將被覆蓋。" }, "dimensions": "解析度", "director": "導演", @@ -960,7 +974,8 @@ "list": "條列顯示", "tagger": "標記工具", "unknown": "未知", - "wall": "預覽牆顯示" + "wall": "預覽牆顯示", + "label_current": "顯示模式:{current}" }, "donate": "贊助", "dupe_check": { @@ -1090,7 +1105,7 @@ "interactive_speed": "互動速度", "performer_card": { "age": "{age} {years_old}", - "age_context": "這齣戲裡面 {age} {years_old}" + "age_context": "這齣戲時 {age} {years_old}" }, "phash": "PHash", "play_count": "播放次數", @@ -1190,7 +1205,8 @@ "name": "篩選", "saved_filters": "已儲存的過濾條件", "update_filter": "更新篩選", - "edit_filter": "編輯篩選器" + "edit_filter": "編輯篩選器", + "more_filter_criteria": "+{count} 更多" }, "seconds": "秒", "settings": "設定", @@ -1515,6 +1531,18 @@ "custom_fields": { "field": "欄位", "title": "自訂欄位", - "value": "值" - } + "value": "值", + "criteria_format_string_others": "{criterion} (custom field) 條件:{modifierString} {valueString} (+{others} others)", + "criteria_format_string": "{criterion} (custom field) 條件:{modifierString} {valueString}" + }, + "login": { + "login": "登入", + "username": "使用者名稱", + "password": "密碼", + "invalid_credentials": "無效的使用者名稱或是密碼", + "internal_error": "系統發生未預期的內部錯誤。詳情請參考日誌" + }, + "sort_name": "分類名稱", + "eta": "預估剩餘時間", + "age_on_date": "在{age}歲時製作" } From de5a9129b3d01bc758a75d2f69e4279e67a23915 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 17 Oct 2025 08:17:15 +1100 Subject: [PATCH 061/157] Use SafeMove when moving backup database (#6147) --- pkg/sqlite/database.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index fa5d1e877..8bf0f0bda 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -368,7 +368,7 @@ func (db *Database) Backup(backupPath string) (err error) { if moveAfter { logger.Infof("Moving database backup to: %s", backupPath) - err = os.Rename(vacuumOut, backupPath) + err = fsutil.SafeMove(vacuumOut, backupPath) if err != nil { return fmt.Errorf("moving database backup failed: %w", err) } From 060daef0b78ea92d0295a468f9fe7d3ac2675a2d Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Thu, 16 Oct 2025 20:53:43 -0400 Subject: [PATCH 062/157] add gql interceptor note to changelog #5964 (#6148) --- ui/v2.5/src/docs/en/ReleaseNotes/v0290.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/docs/en/ReleaseNotes/v0290.md b/ui/v2.5/src/docs/en/ReleaseNotes/v0290.md index 4a816edd4..6dfe0b209 100644 --- a/ui/v2.5/src/docs/en/ReleaseNotes/v0290.md +++ b/ui/v2.5/src/docs/en/ReleaseNotes/v0290.md @@ -1,3 +1,5 @@ The Scenes page and related scene list views have been updated with a filter sidebar and a toolbar for filtering and other actions. This design is intended to be applied to other query pages in the following release. The design will be refined based on user feedback. -You can help steer the direction of this design by providing feedback in the [forum thread](https://discourse.stashapp.cc/t/query-page-redesign-feedback-thread-0-29/3935). \ No newline at end of file +You can help steer the direction of this design by providing feedback in the [forum thread](https://discourse.stashapp.cc/t/query-page-redesign-feedback-thread-0-29/3935). + +Old userscripts and plugins that intercept GraphQL with content-type `application/json` will stop working, as gqlenc uses the updated content-type `application/graphql-response+json` \ No newline at end of file From 914bbfc1642ce2bb1983d5ead89c1ae596549c03 Mon Sep 17 00:00:00 2001 From: gregpetersonanon Date: Sun, 19 Oct 2025 16:54:26 -0700 Subject: [PATCH 063/157] Prevent scanner from failing when reading file info (#6123) --- pkg/file/scan.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/file/scan.go b/pkg/file/scan.go index 8b0ec956e..40f474f34 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -239,7 +239,8 @@ func (s *scanJob) queueFileFunc(ctx context.Context, f models.FS, zipFile *scanF info, err := d.Info() if err != nil { - return fmt.Errorf("reading info for %q: %w", path, err) + logger.Errorf("reading info for %q: %v", path, err) + return nil } if !s.acceptEntry(ctx, path, info) { From c6bf20dd770272129a3e4daa365943e78b13a45a Mon Sep 17 00:00:00 2001 From: fancydancers <235832478+fancydancers@users.noreply.github.com> Date: Sun, 19 Oct 2025 23:55:11 +0000 Subject: [PATCH 064/157] install python packages system-wide (#6120) --- docker/ci/x86_64/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/ci/x86_64/Dockerfile b/docker/ci/x86_64/Dockerfile index 957da347c..6a9c6b76d 100644 --- a/docker/ci/x86_64/Dockerfile +++ b/docker/ci/x86_64/Dockerfile @@ -13,7 +13,7 @@ FROM --platform=$TARGETPLATFORM alpine:latest AS app COPY --from=binary /stash /usr/bin/ RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg tzdata vips vips-tools \ - && pip install --user --break-system-packages mechanicalsoup cloudscraper stashapp-tools + && pip install --break-system-packages mechanicalsoup cloudscraper stashapp-tools ENV STASH_CONFIG_FILE=/root/.stash/config.yml # Basic build-time metadata as defined at https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys From cee68ab87b8475ee821489f763fb883cc1eb89e6 Mon Sep 17 00:00:00 2001 From: smith113-p <205463041+smith113-p@users.noreply.github.com> Date: Sun, 19 Oct 2025 21:58:26 -0400 Subject: [PATCH 065/157] Merge URLs when merging scenes (#6151) --- ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx index 52b3ea67c..3731f1d6a 100644 --- a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx @@ -206,13 +206,7 @@ const SceneMergeDetails: React.FC = ({ setCode( new ScrapeResult(dest.code, sources.find((s) => s.code)?.code, !dest.code) ); - setURL( - new ScrapeResult( - dest.urls, - sources.find((s) => s.urls)?.urls, - !dest.urls?.length - ) - ); + setURL(new ScrapeResult(dest.urls, uniq(all.map((s) => s.urls).flat()))); setDate( new ScrapeResult(dest.date, sources.find((s) => s.date)?.date, !dest.date) ); From 97ca5a28d3b47f38dff6d5b31ba1ada234119287 Mon Sep 17 00:00:00 2001 From: smith113-p <205463041+smith113-p@users.noreply.github.com> Date: Sun, 19 Oct 2025 21:59:36 -0400 Subject: [PATCH 066/157] Use the merged stash IDs by default (#6152) --- ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx index 3731f1d6a..d5a18d4ac 100644 --- a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx @@ -305,8 +305,7 @@ const SceneMergeDetails: React.FC = ({ .filter((s, index, a) => { // remove entries with duplicate endpoints return index === a.findIndex((ss) => ss.endpoint === s.endpoint); - }), - !dest.stash_ids.length + }) ) ); From cb6c53deb51c22cf6c8c2dec5babad7a5ddd7989 Mon Sep 17 00:00:00 2001 From: theqwertyqwert Date: Mon, 20 Oct 2025 05:00:06 +0300 Subject: [PATCH 067/157] Update marker background color logic to use primaryTag name instead of title (#6141) --- ui/v2.5/src/components/ScenePlayer/markers.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/ui/v2.5/src/components/ScenePlayer/markers.ts b/ui/v2.5/src/components/ScenePlayer/markers.ts index 83a695c1b..6215f7f47 100644 --- a/ui/v2.5/src/components/ScenePlayer/markers.ts +++ b/ui/v2.5/src/components/ScenePlayer/markers.ts @@ -5,6 +5,7 @@ export interface IMarker { title: string; seconds: number; end_seconds?: number | null; + primaryTag: { name: string }; } interface IMarkersOptions { @@ -85,8 +86,13 @@ class MarkersPlugin extends videojs.getPlugin("plugin") { markerSet.dot.toggleAttribute("marker-tooltip-shown", true); // Set background color based on tag (if available) - if (marker.title && this.tagColors[marker.title]) { - markerSet.dot.style.backgroundColor = this.tagColors[marker.title]; + if ( + marker.primaryTag && + marker.primaryTag.name && + this.tagColors[marker.primaryTag.name] + ) { + markerSet.dot.style.backgroundColor = + this.tagColors[marker.primaryTag.name]; } markerSet.dot.addEventListener("mouseenter", () => { this.showMarkerTooltip(marker.title); @@ -152,8 +158,12 @@ class MarkersPlugin extends videojs.getPlugin("plugin") { rangeDiv.style.display = "none"; // Initially hidden // Set background color based on tag (if available) - if (marker.title && this.tagColors[marker.title]) { - rangeDiv.style.backgroundColor = this.tagColors[marker.title]; + if ( + marker.primaryTag && + marker.primaryTag.name && + this.tagColors[marker.primaryTag.name] + ) { + rangeDiv.style.backgroundColor = this.tagColors[marker.primaryTag.name]; } markerSet.range = rangeDiv; From c162c3843db02ab6f9268fa2fd06e309e5a7e536 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 21 Oct 2025 08:13:42 +1100 Subject: [PATCH 068/157] Add timeout to ffmpeg hardware tests (#6154) --- pkg/ffmpeg/codec_hardware.go | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/pkg/ffmpeg/codec_hardware.go b/pkg/ffmpeg/codec_hardware.go index 5151e7efe..4081f015d 100644 --- a/pkg/ffmpeg/codec_hardware.go +++ b/pkg/ffmpeg/codec_hardware.go @@ -5,9 +5,11 @@ import ( "context" "fmt" "math" + "os" "regexp" "strconv" "strings" + "time" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" @@ -64,12 +66,32 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) { args = args.Format("null") args = args.Output("-") - cmd := f.Command(ctx, args) + // #6064 - add timeout to context to prevent hangs + const hwTestTimeoutSecondsDefault = 1 + hwTestTimeoutSeconds := hwTestTimeoutSecondsDefault * time.Second + + // allow timeout to be overridden with environment variable + if timeout := os.Getenv("STASH_HW_TEST_TIMEOUT"); timeout != "" { + if seconds, err := strconv.Atoi(timeout); err == nil { + hwTestTimeoutSeconds = time.Duration(seconds) * time.Second + } + } + + testCtx, cancel := context.WithTimeout(ctx, hwTestTimeoutSeconds) + defer cancel() + + cmd := f.Command(testCtx, args) + logger.Tracef("[InitHWSupport] Testing codec %s: %v", codec, cmd.Args) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { + if testCtx.Err() != nil { + logger.Debugf("[InitHWSupport] Codec %s test timed out after %d seconds", codec, hwTestTimeoutSeconds) + continue + } + errOutput := stderr.String() if len(errOutput) == 0 { From d0283fe330957a21a378a7ad4e866d87f72a0ac2 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 21 Oct 2025 08:21:53 +1100 Subject: [PATCH 069/157] Update changelog --- ui/v2.5/src/docs/en/Changelog/v0290.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/docs/en/Changelog/v0290.md b/ui/v2.5/src/docs/en/Changelog/v0290.md index f2c99de4a..12a958d86 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0290.md +++ b/ui/v2.5/src/docs/en/Changelog/v0290.md @@ -34,10 +34,12 @@ * Fixed UI crash when viewing a gallery in the Performer details page. ([#5824](https://github.com/stashapp/stash/pull/5824)) * Fixed scraped performer stash ID being saved when cancelling scrape operation. ([#5839](https://github.com/stashapp/stash/pull/5839)) * Fixed groups not transferring when merging tags. ([#6127](https://github.com/stashapp/stash/pull/6127)) +* Fixed URLs and stash IDs not transferring during scene merge operation. ([#6151](https://github.com/stashapp/stash/pull/6151), [#6152](https://github.com/stashapp/stash/pull/6152)) * Fixed empty exclusion patterns being applied when scanning and cleaning. ([#6023](https://github.com/stashapp/stash/pull/6023)) * Fixed login page being included in browser history. ([#5747](https://github.com/stashapp/stash/pull/5747)) * Fixed gallery card resizing while scrubbing. ([#5844](https://github.com/stashapp/stash/pull/5844)) * Fixed incorrectly positioned scene markers in the scene player timeline. ([#5801](https://github.com/stashapp/stash/pull/5801), [#5804](https://github.com/stashapp/stash/pull/5804)) +* Fixed incorrect marker colours in the scene player timeline. ([#6141](https://github.com/stashapp/stash/pull/6141)) * Fixed custom fields not being displayed in Performer page with `Compact Expanded Details` enabled. ([#5833](https://github.com/stashapp/stash/pull/5833)) * Fixed issue in tagger where creating a parent studio would not map it to the other results. ([#5810](https://github.com/stashapp/stash/pull/5810), [#5996](https://github.com/stashapp/stash/pull/5996)) * Fixed generation options not being respected when generating using the Tasks page. ([#6139](https://github.com/stashapp/stash/pull/6139)) @@ -51,4 +53,5 @@ * Fixed parent tags missing in export if including dependencies. ([#5780](https://github.com/stashapp/stash/pull/5780)) * Add short hash of basename when generating export file names to prevent the same filename being generated. ([#5780](https://github.com/stashapp/stash/pull/5780)) * Fixed invalid studio and performer links in the tagger view. ([#5876](https://github.com/stashapp/stash/pull/5876)) -* Fixed clickable area for tag links. ([#6129](https://github.com/stashapp/stash/pull/6129)) \ No newline at end of file +* Fixed clickable area for tag links. ([#6129](https://github.com/stashapp/stash/pull/6129)) +* ffmpeg hardware encoding checks now timeout after 1 second to prevent startup hangs. ([#6154](https://github.com/stashapp/stash/pull/6154)) \ No newline at end of file From 415e88808f3d10ab71b4ccddb88db0d0170074fa Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 21 Oct 2025 08:43:59 +1100 Subject: [PATCH 070/157] Codeberg weblate (#6159) * Translated using Weblate (Bulgarian) Currently translated at 11.3% (138 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/bg/ * Translated using Weblate (Bulgarian) Currently translated at 22.3% (272 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/bg/ --------- Co-authored-by: theqwertyqwert --- ui/v2.5/src/locales/bg-BG.json | 265 ++++++++++++++++++++++++++++++++- 1 file changed, 264 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/locales/bg-BG.json b/ui/v2.5/src/locales/bg-BG.json index 3f7e71f3d..f639b3b6b 100644 --- a/ui/v2.5/src/locales/bg-BG.json +++ b/ui/v2.5/src/locales/bg-BG.json @@ -51,6 +51,269 @@ "encoding_image": "Кодиране на картина…", "export": "Експортиране", "export_all": "Експортирай всичко…", - "reshuffle": "Пренареди" + "reshuffle": "Пренареди", + "assign_stashid_to_parent_studio": "Задай Stash ID към съществуващо родителско студио и опресни метаданните", + "find": "Намери", + "finish": "Приключи", + "from_file": "От файл…", + "from_url": "От URL…", + "full_export": "Пълен експорт", + "full_import": "Пълен Импорт", + "generate": "Генерирай", + "generate_thumb_default": "Генерирай тъмбнайл по подразбиране", + "generate_thumb_from_current": "Генерирай тъмбнайл от сегашният кадър", + "hash_migration": "миграция на хеш", + "hide": "Скрий", + "hide_configuration": "Скрий конфигурация", + "identify": "Индентифицирай", + "ignore": "Игнорирай", + "import": "Импорт…", + "import_from_file": "Импорт от файл", + "load": "Зареди", + "load_filter": "Зареди филтър", + "logout": "Изход", + "make_primary": "Направи Основен", + "merge": "Слей", + "merge_from": "Слей от", + "merge_into": "Слей към", + "migrate_blobs": "Мигрирай Блобове", + "migrate_scene_screenshots": "Мигрирай Скрийншоти от Сцени", + "next_action": "Следващ", + "not_running": "не върви", + "open_in_external_player": "Отвори в външет плейер", + "open_random": "Отвори Случайно", + "optimise_database": "Оптимизирай База Данни", + "overwrite": "Презапиши", + "play": "Пусни", + "play_random": "Пусни Случайно", + "play_selected": "Пусни избраните", + "preview": "Преглеждане", + "previous_action": "Назад", + "reassign": "Презадай", + "refresh": "Опресни", + "reload": "Презареди", + "reload_plugins": "Презареди плъгините", + "reload_scrapers": "Презареди търкачите", + "remove": "Премахни", + "remove_date": "Премахни дата", + "remove_from_containing_group": "Премахни от Група", + "remove_from_gallery": "Премахни от Галерия", + "rename_gen_files": "Преименувай генериран файл", + "rescan": "Пресканирай", + "reset_play_duration": "Нулирай период на пускане", + "reset_resume_time": "Нулирай преме за възтановяване", + "reset_cover": "Възтанови Първоначална Корица", + "running": "върви", + "save": "Запази", + "save_delete_settings": "Изполвай тези настройки по подразвиране при изтриване", + "save_filter": "Запази филтър", + "scan": "Сканирай", + "scrape": "Изтъркай", + "scrape_query": "Изтъркай със заявка", + "scrape_scene_fragment": "Изтъркай по частица", + "scrape_with": "Изтъркай чрез…", + "search": "Търси", + "select_all": "Избери Всичко", + "select_entity": "Избери {entityType}", + "select_folders": "Избери папки", + "select_none": "Избери Нищо", + "selective_auto_tag": "Избирателно Автоматично Тагване", + "selective_clean": "Избирателно Почистване", + "selective_scan": "Избирателно Сканиране", + "set_as_default": "Сложи по подразбиране", + "set_back_image": "Задна картина…", + "set_cover": "Сложи Като Корица", + "set_front_image": "Предна Картина…", + "set_image": "Сложи картина…", + "show": "Покажи", + "show_configuration": "Покажи Конфигурация", + "show_results": "Покажи резултат", + "show_count_results": "Покажи {count} резултати", + "sidebar": { + "close": "Затвори странично меню", + "open": "Отвори странично меню", + "toggle": "Превключи странично меню" + }, + "skip": "Пропусни", + "split": "Раздели", + "stop": "Спри", + "submit": "Подай", + "submit_stash_box": "Подай към Stash-Box", + "submit_update": "Подай обноваване", + "swap": "Подмени", + "tasks": { + "clean_confirm_message": "Сигурен ли си че искаш да Изчистиш? Това ще изтрие иформацията от база данни и генерирано съдържание за всички сцени и галерий който не мога да бъдат намерени на файловата система.", + "dry_mode_selected": "Избрано е Сухо Пускане. Нищо няма да бъде изтрито на истина, са ще бъде записано в логовете.", + "import_warning": "Сигурен ли си че искаш да импортираш? Това ще изтрие базата данни и че вкара на ново твоите експортирани метаданни." + }, + "temp_disable": "Спри временно…", + "temp_enable": "Включи временно…", + "unset": "", + "use_default": "Използвай на стойностите по подразбиране", + "view_history": "Виж история", + "view_random": "Виж Случайно" + }, + "actions_name": "Действия", + "age": "Години", + "age_on_date": "{age} по време на продукция", + "aliases": "Псевдоними", + "all": "всички", + "also_known_as": "Също така познат/а като", + "appears_with": "Има Участия Със", + "ascending": "Възходящ", + "audio_codec": "Аудио Кодек", + "average_resolution": "Средностатистическа Резолюция", + "between_and": "и", + "birth_year": "Година на раждане", + "birthdate": "Дата на раждане", + "bitrate": "Бит Рейт", + "blobs_storage_type": { + "database": "База Данни", + "filesystem": "Файлова Система" + }, + "captions": "Субтитри", + "career_length": "Подължителност на Кариера", + "chapters": "Глави", + "circumcised": "Образан", + "circumcised_types": { + "CUT": "Обрязан", + "UNCUT": "Необрязан" + }, + "component_tagger": { + "config": { + "active_instance": "Активни инстанции на stash-box:", + "blacklist_desc": "Предмети от черният списък са изключени от заяки. Забележи че те са regular expressions и не гледа главни и малки букви. Някой символи трябва да пъдат escape-нати със наклонка: {chars_require_escape}", + "blacklist_label": "Черен списък", + "errors": { + "blacklist_duplicate": "Дубликирани предмети от черния списък" + }, + "mark_organized_desc": "Веднага отбележи сцена като Организирана след като се натисне бутон Запази.", + "mark_organized_label": "Отбележи като Организирана на запазване", + "query_mode_auto": "Автоматично", + "query_mode_auto_desc": "Използа метаданни ако съществиват, или име на файл", + "query_mode_dir": "Папка", + "query_mode_dir_desc": "Използва само папката която съдържа видео файла", + "query_mode_filename": "Име на файл", + "query_mode_filename_desc": "Използва само име на файл", + "query_mode_label": "Мод на Заявки", + "query_mode_metadata": "Мета данни", + "query_mode_metadata_desc": "Използва само мета данни", + "query_mode_path": "Път", + "query_mode_path_desc": "Използва целият път на файла", + "set_cover_desc": "Замени корицата на сцената ако се намери такава.", + "set_cover_label": "Заложи картина за корица на сцена", + "set_tag_desc": "Закачи тагове към сцената, или чрез презаписване или чрез сливане със съществуващите тагове на сцената.", + "set_tag_label": "Задай тагове", + "show_male_desc": "Превключи дали мъжки изпълнители ще въдат предоставени за тагване.", + "show_male_label": "Покажи мъжки изпълнители", + "source": "Източник" + }, + "noun_query": "Заявка", + "results": { + "duration_off": "Продължителност не съвпада с поне {number} сек.", + "duration_unknown": "Неизвестна продължителност", + "fp_matches": "Продължителността съвпада", + "fp_matches_multi": "Продължителността съвпада {matchCount}/{durationsLength} отпечатъци", + "hash_matches": "{hash_type} съвпада", + "match_failed_already_tagged": "Сцената вече има тагове", + "match_failed_no_result": "Няма намерени резултати", + "match_success": "Счената е успешно тагната", + "phash_matches": "{count} PHashes съвпадат", + "unnamed": "Неименуван" + }, + "verb_match_fp": "Сравни Отпечатъци", + "verb_matched": "Сравнени", + "verb_scrape_all": "Изтъркай Всичко", + "verb_submit_fp": "Подай {fpCount, plural, one{# Отпечатък} other{# Отпечатъци}}", + "verb_toggle_unmatched": "{toggle} несъвпадаци сцени" + }, + "config": { + "about": { + "build_hash": "Хеш на билда:", + "build_time": "Време на билда:", + "check_for_new_version": "Проверка за нова версия", + "latest_version": "Последна Версия", + "latest_version_build_hash": "Хеш на билда на Последна Версия:", + "new_version_notice": "[НОВО]", + "release_date": "Дана за издаване:", + "stash_discord": "Присъедини се към нашият {url} канал", + "stash_home": "Stash home на {url}", + "stash_open_collective": "Подкрепи ни чрез {url}", + "stash_wiki": "Stash {url} страница", + "version": "Версия" + }, + "advanced_mode": "Мод за Напреднали", + "application_paths": { + "heading": "Пътища на Апликация" + }, + "categories": { + "about": "Относно", + "changelog": "Списък на промените", + "interface": "Интерфейс", + "logs": "Дневници", + "metadata_providers": "Доставчици на Мета данни", + "plugins": "Приставки", + "scraping": "Търкане", + "security": "Сигурност", + "services": "Услуги", + "system": "Система", + "tasks": "Задачи", + "tools": "Иструменти" + }, + "dlna": { + "allow_temp_ip": "Позволи {tempIP}", + "allowed_ip_addresses": "Позволени IP адреси", + "allowed_ip_temporarily": "Позволени IP временно", + "default_ip_whitelist": "Основен IP Бъл списък", + "default_ip_whitelist_desc": "Основени IP адреси позволени да достигат DLNA. Изполвай {wildcard} за да позволиш всички IP адреси.", + "disabled_dlna_temporarily": "Временно изключено DLNA", + "disallowed_ip": "Непозволени IP", + "enabled_by_default": "Включено по подразвиране", + "enabled_dlna_temporarily": "Временно включено DLNA", + "network_interfaces": "Интерфейси", + "network_interfaces_desc": "Интерфейси да се достъпн DLNA сървъра. Празен лист ще ползва всички интерфейси. Изисква DLNA рестрат след промяна.", + "recent_ip_addresses": "Скорошни IP адреси", + "server_display_name": "Име за Покаване на Сървъра", + "server_display_name_desc": "Име за покаване на DLNA сървъра. По подразбиране {server_name} ако е празно.", + "server_port": "Порт на Сървъра", + "server_port_desc": "Порт на който да върви DLNA сървъра. Изисква рестарт на DLNA след промяна.", + "successfully_cancelled_temporary_behaviour": "Успешно отказано временно поведение", + "until_restart": "до рестартиране", + "video_sort_order": "Ред на Видеа по подразбиране", + "video_sort_order_desc": "Ред по който да реди видеа по подразбиране." + }, + "general": { + "auth": { + "api_key": "API ключ", + "api_key_desc": "API ключ за външни системи. Нужен само когато име/парола са настроени. Името трябва да бъде запазене преди генерация на API ключ.", + "authentication": "Идентификация", + "clear_api_key": "Изчисти API ключ", + "credentials": { + "description": "Удостоверителни данни за контрол на достъпа до stash.", + "heading": "Удостоверителни данни" + }, + "generate_api_key": "Генерирай API ключ", + "log_file": "Лог файл", + "log_file_desc": "Път към файла за извод на лог. Празен за да спре извод на лог. Изисква рестарт.", + "log_http": "Логвай http достъп", + "log_http_desc": "Логва http достъп на терминала. Изисква рестарт.", + "log_to_terminal": "Лог към терминал", + "log_to_terminal_desc": "Логва към терминал заедно с към файл. Винаги истина ако логване към файл е изключено. Изискава рестарт.", + "maximum_session_age": "Максимална Продължителност на Сесия", + "maximum_session_age_desc": "Максимално време на бездействие, преди сесията за вход да изтече, в секунди. Изисква рестартиране.", + "password": "Парола", + "password_desc": "Парола за достъп до Stash. Остави празно за да изключи достъп чрез потребител", + "stash-box_integration": "Stash-box интеграция", + "username": "Потребителско име", + "username_desc": "Потребителско име за достъп до Stash. Остави празно за да изключи достъп чрез потребител" + }, + "backup_directory_path": { + "description": "Местоположение на папка за резрвни SQLite бази данни", + "heading": "Път към Папка за Резервни Данни" + }, + "blobs_path": { + "description": "Къде във файловата система да се пазят бинарни данни. Позва се само ако се ползва Файлова система за блоб пазене. ВНИМАНИЕ: промяната ще изисква ръчно местене на съществуващи данни." + } + } } } From a6778d7d225b421cf94886be05def0c514018107 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 21 Oct 2025 10:34:02 +1100 Subject: [PATCH 071/157] Add discourse links to manual --- ui/v2.5/src/docs/en/Manual/Contributing.md | 2 +- ui/v2.5/src/docs/en/Manual/Help.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/docs/en/Manual/Contributing.md b/ui/v2.5/src/docs/en/Manual/Contributing.md index 9e55dbd6d..2d62dde08 100644 --- a/ui/v2.5/src/docs/en/Manual/Contributing.md +++ b/ui/v2.5/src/docs/en/Manual/Contributing.md @@ -26,4 +26,4 @@ We welcome ideas for future improvements and features, and bug reports help ever ## Providing support -Offering support for new users on [Discord](https://discord.gg/2TsNFKt) is also welcomed. +Offering support for new users on our [Community forum](https://discourse.stashapp.cc/) and [Discord](https://discord.gg/2TsNFKt) is also welcomed. diff --git a/ui/v2.5/src/docs/en/Manual/Help.md b/ui/v2.5/src/docs/en/Manual/Help.md index f5d1d69e5..c9c4ab9d3 100644 --- a/ui/v2.5/src/docs/en/Manual/Help.md +++ b/ui/v2.5/src/docs/en/Manual/Help.md @@ -1,5 +1,7 @@ # Where to get further help +Join our [Community forum](https://discourse.stashapp.cc/). + Join our [Discord](https://discord.gg/2TsNFKt). The [Stash-Docs](https://docs.stashapp.cc) covers some areas not covered in the in-app help. From 71e407187146f5f5cafd20ec80c666506542972f Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 21 Oct 2025 19:04:44 +1100 Subject: [PATCH 072/157] Encode credentials during login (#6163) --- ui/login/login.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/login/login.html b/ui/login/login.html index 32787fc91..62b8ffdc8 100644 --- a/ui/login/login.html +++ b/ui/login/login.html @@ -44,7 +44,8 @@ xhr.onerror = function() { document.getElementsByClassName("login-error")[0].innerHTML = localeStrings.internal_error; }; - xhr.send("username=" + username + "&password=" + password + "&returnURL=" + returnURL); + var body = "username=" + encodeURIComponent(username) + "&password=" + encodeURIComponent(password) + "&returnURL=" + encodeURIComponent(returnURL); + xhr.send(body); } From 947a17355c4e25e77993939ac0ba9e403c14605b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 22 Oct 2025 11:31:42 +1100 Subject: [PATCH 073/157] Fix UI loop when sorting by random without seed (#6167) --- ui/v2.5/src/components/List/util.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index 717f9c289..c52fcc8d3 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -24,6 +24,7 @@ export function useFilterURL( const history = useHistory(); const location = useLocation(); + const prevLocation = usePrevious(location); // when the filter changes, update the URL const updateFilter = useCallback( @@ -47,7 +48,8 @@ export function useFilterURL( // and updates the filter accordingly. useEffect(() => { // don't apply if active is false - if (!active) return; + // also don't apply if location is unchanged + if (!active || prevLocation === location) return; // re-init to load default filter on empty new query params if (!location.search) { @@ -73,7 +75,8 @@ export function useFilterURL( }); }, [ active, - location.search, + prevLocation, + location, defaultFilter, setFilter, updateFilter, From 98df51755eb2fbeafc7d940dd6ab264402050274 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 22 Oct 2025 12:21:04 +1100 Subject: [PATCH 074/157] Fix column layout image wall issues (#6168) --- ui/v2.5/src/components/Images/ImageList.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index 093567613..dc1f9b1e1 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -116,15 +116,13 @@ const ImageWall: React.FC = ({ const renderImage = useCallback( (props: RenderImageProps) => { - return ( - - ); + // #6165 - only use targetRowHeight in row direction + const maxHeight = + props.direction === "column" + ? props.photo.height + : targetRowHeight(containerRef.current?.offsetWidth ?? 0) * + maxHeightFactor; + return ; }, [targetRowHeight] ); From 5049d6e5c91b3528dd633db71f5b23d5b9d749b6 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 22 Oct 2025 12:48:39 +1100 Subject: [PATCH 075/157] Fix scene list table styling issues (#6169) * Reduce z-index of table list header * Set better max-height for scene list table --- ui/v2.5/src/components/List/styles.scss | 2 +- ui/v2.5/src/components/Scenes/styles.scss | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index a4194a832..7b02fd509 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -740,7 +740,7 @@ input[type="range"].zoom-slider { background-color: #202b33; position: sticky; top: 0; - z-index: 100; + z-index: 1; } td:first-child { diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index e7c5af22a..3b00130b1 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -958,3 +958,9 @@ input[type="range"].blue-slider { } } } + +.table-list.scene-table { + // Set max height to viewport height minus estimated header/footer height + // TODO - this will need to be rolled out to other tables + max-height: calc(100dvh - 210px); +} From 869cbd496b7f3d0eef08bb020bf91d4dd9301da9 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 22 Oct 2025 12:49:27 +1100 Subject: [PATCH 076/157] Update changelog --- ui/v2.5/src/docs/en/Changelog/v0290.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/v2.5/src/docs/en/Changelog/v0290.md b/ui/v2.5/src/docs/en/Changelog/v0290.md index 12a958d86..c5cb78048 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0290.md +++ b/ui/v2.5/src/docs/en/Changelog/v0290.md @@ -28,6 +28,10 @@ * Include IP address in login errors in log. ([#5760](https://github.com/stashapp/stash/pull/5760)) ### 🐛 Bug fixes +* **[0.29.1]** Fixed password with special characters not allowing login. ([#6163](https://github.com/stashapp/stash/pull/6163)) +* **[0.29.1]** Fixed layout issues using column direction for image wall. ([#6168](https://github.com/stashapp/stash/pull/6168)) +* **[0.29.1]** Fixed layout issues for scene list table. ([#6169](https://github.com/stashapp/stash/pull/6169)) +* **[0.29.1]** Fixed UI loop when sorting by random without seed using URL. ([#6167](https://github.com/stashapp/stash/pull/6167)) * Fixed ordering studios by tag count returning error. ([#5776](https://github.com/stashapp/stash/pull/5776)) * Fixed error when submitting fingerprints for scenes that have been deleted. ([#5799](https://github.com/stashapp/stash/pull/5799)) * Fixed errors when scraping groups. ([#5793](https://github.com/stashapp/stash/pull/5793), [#5974](https://github.com/stashapp/stash/pull/5974)) From fda97e7f6c207dbd4bfec277aa5269eb357ce44a Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:11:21 +1100 Subject: [PATCH 077/157] Return if primary file failed to load (#6200) --- internal/manager/task_generate_screenshot.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/manager/task_generate_screenshot.go b/internal/manager/task_generate_screenshot.go index 77ad2be34..2f4031586 100644 --- a/internal/manager/task_generate_screenshot.go +++ b/internal/manager/task_generate_screenshot.go @@ -32,6 +32,7 @@ func (t *GenerateCoverTask) Start(ctx context.Context) { return t.Scene.LoadPrimaryFile(ctx, r.File) }); err != nil { logger.Error(err) + return } if !required { From 96b5a9448c123e0b346c76b3a7d33fc5b007c2b7 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:11:42 +1100 Subject: [PATCH 078/157] Fix source.StashBoxEndpoint reference causing nil deref (#6201) --- internal/api/resolver_query_scraper.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/api/resolver_query_scraper.go b/internal/api/resolver_query_scraper.go index f0e89cd34..5875cd11e 100644 --- a/internal/api/resolver_query_scraper.go +++ b/internal/api/resolver_query_scraper.go @@ -201,7 +201,7 @@ func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.So } // TODO - this should happen after any scene is scraped - if err := r.matchScenesRelationships(ctx, ret, *source.StashBoxEndpoint); err != nil { + if err := r.matchScenesRelationships(ctx, ret, b.Endpoint); err != nil { return nil, err } default: @@ -245,7 +245,7 @@ func (r *queryResolver) ScrapeMultiScenes(ctx context.Context, source scraper.So // just flatten the slice and pass it in flat := sliceutil.Flatten(ret) - if err := r.matchScenesRelationships(ctx, flat, *source.StashBoxEndpoint); err != nil { + if err := r.matchScenesRelationships(ctx, flat, b.Endpoint); err != nil { return nil, err } @@ -335,7 +335,7 @@ func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.S if len(ret) > 0 { if err := r.withReadTxn(ctx, func(ctx context.Context) error { for _, studio := range ret { - if err := match.ScrapedStudioHierarchy(ctx, r.repository.Studio, studio, *source.StashBoxEndpoint); err != nil { + if err := match.ScrapedStudioHierarchy(ctx, r.repository.Studio, studio, b.Endpoint); err != nil { return err } } From 648875995c252f4f548d15ff0735e35b509fa619 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:12:00 +1100 Subject: [PATCH 079/157] Fix play random not using effective filter (#6202) --- ui/v2.5/src/components/Scenes/SceneList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 1154f384e..982a11fed 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -518,7 +518,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => { const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]); - const playRandom = usePlayRandom(filter, totalCount); + const playRandom = usePlayRandom(effectiveFilter, totalCount); const playSelected = usePlaySelected(selectedIds); const playFirst = usePlayFirst(); From 1dccecc39c3634ed6d7b6f7d0ca9840fa824dff2 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:12:25 +1100 Subject: [PATCH 080/157] Go to list page if deleting with empty history (#6203) --- .../components/Galleries/GalleryDetails/Gallery.tsx | 3 ++- ui/v2.5/src/components/Groups/GroupDetails/Group.tsx | 3 ++- ui/v2.5/src/components/Images/ImageDetails/Image.tsx | 3 ++- .../Performers/PerformerDetails/Performer.tsx | 3 ++- ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx | 3 ++- .../src/components/Studios/StudioDetails/Studio.tsx | 3 ++- ui/v2.5/src/components/Tags/TagDetails/Tag.tsx | 3 ++- ui/v2.5/src/utils/history.ts | 11 +++++++++++ 8 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 ui/v2.5/src/utils/history.ts diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index 20023904b..5d7cdeb51 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -43,6 +43,7 @@ import cx from "classnames"; import { useRatingKeybinds } from "src/hooks/keybinds"; import { ConfigurationContext } from "src/hooks/Config"; import { TruncatedText } from "src/components/Shared/TruncatedText"; +import { goBackOrReplace } from "src/utils/history"; interface IProps { gallery: GQL.GalleryDataFragment; @@ -167,7 +168,7 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { function onDeleteDialogClosed(deleted: boolean) { setIsDeleteAlertOpen(false); if (deleted) { - history.goBack(); + goBackOrReplace(history, "/galleries"); } } diff --git a/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx b/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx index bd58a6682..b48f3b98c 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx @@ -43,6 +43,7 @@ import { Button, Tab, Tabs } from "react-bootstrap"; import { GroupSubGroupsPanel } from "./GroupSubGroupsPanel"; import { GroupPerformersPanel } from "./GroupPerformersPanel"; import { Icon } from "src/components/Shared/Icon"; +import { goBackOrReplace } from "src/utils/history"; const validTabs = ["default", "scenes", "performers", "subgroups"] as const; type TabKey = (typeof validTabs)[number]; @@ -276,7 +277,7 @@ const GroupPage: React.FC = ({ group, tabKey }) => { return; } - history.goBack(); + goBackOrReplace(history, "/groups"); } function toggleEditing(value?: boolean) { diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index 4ab6641d7..b19366032 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -34,6 +34,7 @@ import TextUtils from "src/utils/text"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import cx from "classnames"; import { TruncatedText } from "src/components/Shared/TruncatedText"; +import { goBackOrReplace } from "src/utils/history"; interface IProps { image: GQL.ImageDataFragment; @@ -156,7 +157,7 @@ const ImagePage: React.FC = ({ image }) => { function onDeleteDialogClosed(deleted: boolean) { setIsDeleteAlertOpen(false); if (deleted) { - history.goBack(); + goBackOrReplace(history, "/images"); } } diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index 03530c52e..ab584e90d 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -47,6 +47,7 @@ import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; import { LightboxLink } from "src/hooks/Lightbox/LightboxLink"; import { PatchComponent } from "src/patch"; import { ILightboxImage } from "src/hooks/Lightbox/types"; +import { goBackOrReplace } from "src/utils/history"; interface IProps { performer: GQL.PerformerDataFragment; @@ -330,7 +331,7 @@ const PerformerPage: React.FC = PatchComponent( return; } - history.goBack(); + goBackOrReplace(history, "/performers"); } function toggleEditing(value?: boolean) { diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index c4088654a..7d326b3cd 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -51,6 +51,7 @@ import { lazyComponent } from "src/utils/lazyComponent"; import cx from "classnames"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { PatchComponent, PatchContainerComponent } from "src/patch"; +import { goBackOrReplace } from "src/utils/history"; const SubmitStashBoxDraft = lazyComponent( () => import("src/components/Dialogs/SubmitDraft") @@ -909,7 +910,7 @@ const SceneLoader: React.FC> = ({ ) { loadScene(queueScenes[currentQueueIndex + 1].id); } else { - history.goBack(); + goBackOrReplace(history, "/scenes"); } } diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 2140af340..46c10d73c 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -47,6 +47,7 @@ import { FavoriteIcon } from "src/components/Shared/FavoriteIcon"; import { ExternalLinkButtons } from "src/components/Shared/ExternalLinksButton"; import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; +import { goBackOrReplace } from "src/utils/history"; interface IProps { studio: GQL.StudioDataFragment; @@ -378,7 +379,7 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { return; } - history.goBack(); + goBackOrReplace(history, "/studios"); } function renderDeleteAlert() { diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index 7cded1934..6d6a4a660 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -49,6 +49,7 @@ import { ExpandCollapseButton } from "src/components/Shared/CollapseButton"; import { FavoriteIcon } from "src/components/Shared/FavoriteIcon"; import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; +import { goBackOrReplace } from "src/utils/history"; interface IProps { tag: GQL.TagDataFragment; @@ -420,7 +421,7 @@ const TagPage: React.FC = ({ tag, tabKey }) => { return; } - history.goBack(); + goBackOrReplace(history, "/tags"); } function renderDeleteAlert() { diff --git a/ui/v2.5/src/utils/history.ts b/ui/v2.5/src/utils/history.ts new file mode 100644 index 000000000..6ae7b637f --- /dev/null +++ b/ui/v2.5/src/utils/history.ts @@ -0,0 +1,11 @@ +import { useHistory } from "react-router-dom"; + +type History = ReturnType; + +export function goBackOrReplace(history: History, defaultPath: string) { + if (history.length > 1) { + history.goBack(); + } else { + history.replace(defaultPath); + } +} From d70ff551d4a9cc22d84daf4b1f352bfc4f9596f9 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:12:42 +1100 Subject: [PATCH 081/157] Replace "movie" with "group" in scene is missing criterion (#6204) * Add support for "group" value in scene is-missing filter criterion * Replace movie with group in scene is missing criterion --- pkg/sqlite/scene_filter.go | 2 +- ui/v2.5/src/models/list-filter/criteria/is-missing.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index 2e63dad97..86432a4af 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -319,7 +319,7 @@ func (qb *sceneFilterHandler) isMissingCriterionHandler(isMissing *string) crite f.addWhere("galleries_join.scene_id IS NULL") case "studio": f.addWhere("scenes.studio_id IS NULL") - case "movie": + case "movie", "group": sceneRepository.groups.join(f, "groups_join", "scenes.id") f.addWhere("groups_join.scene_id IS NULL") case "performers": diff --git a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts index f7387e558..58e3535a6 100644 --- a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts +++ b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts @@ -32,7 +32,7 @@ export const SceneIsMissingCriterionOption = new IsMissingCriterionOption( "date", "galleries", "studio", - "movie", + "group", "performers", "tags", "stash_id", From 9b8300e88203092e96e89e57b0187d44966fb708 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:12:57 +1100 Subject: [PATCH 082/157] Only scroll edit filter dialog when clicking filter tag (#6205) --- ui/v2.5/src/components/List/EditFilterDialog.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/List/EditFilterDialog.tsx b/ui/v2.5/src/components/List/EditFilterDialog.tsx index e914b194e..5f6d43004 100644 --- a/ui/v2.5/src/components/List/EditFilterDialog.tsx +++ b/ui/v2.5/src/components/List/EditFilterDialog.tsx @@ -50,6 +50,7 @@ interface ICriterionList { optionSelected: (o?: CriterionOption) => void; onRemoveCriterion: (c: string) => void; onTogglePin: (c: CriterionOption) => void; + externallySelected?: boolean; } const CriterionOptionList: React.FC = ({ @@ -62,6 +63,7 @@ const CriterionOptionList: React.FC = ({ optionSelected, onRemoveCriterion, onTogglePin, + externallySelected = false, }) => { const prevCriterion = usePrevious(currentCriterion); @@ -101,14 +103,19 @@ const CriterionOptionList: React.FC = ({ // scrolling to the current criterion doesn't work well when the // dialog is already open, so limit to when we click on the // criterion from the external tags - if (!scrolled.current && type && criteriaRefs[type]?.current) { + if ( + externallySelected && + !scrolled.current && + type && + criteriaRefs[type]?.current + ) { criteriaRefs[type].current!.scrollIntoView({ behavior: "smooth", block: "start", }); scrolled.current = true; } - }, [currentCriterion, criteriaRefs, type]); + }, [externallySelected, currentCriterion, criteriaRefs, type]); function getReleventCriterion(t: CriterionType) { if (currentCriterion?.criterionOption.type === t) { @@ -549,6 +556,7 @@ export const EditFilterDialog: React.FC = ({ selected={criterion?.criterionOption} onRemoveCriterion={(c) => removeCriterionString(c)} onTogglePin={(c) => onTogglePinFilter(c)} + externallySelected={!!editingCriterion} /> {criteria.length > 0 && (
From 90baa31ee35b5fdcc2fcb1850cec51cba74efcb0 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:13:13 +1100 Subject: [PATCH 083/157] Hide zoom slider in xs viewports (#6206) The zoom slider doesn't function in this viewport so it shouldn't be shown. --- ui/v2.5/src/components/List/styles.scss | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 7b02fd509..c42c43a56 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -91,6 +91,13 @@ } } +// hide zoom slider in xs viewport +@include media-breakpoint-down(xs) { + .display-mode-menu .zoom-slider-container { + display: none; + } +} + .display-mode-popover { padding-left: 0; padding-right: 0; From db79cf9bb130f8215879a0be030275d06d21eacb Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:13:29 +1100 Subject: [PATCH 084/157] Increase number of pages in pagination dropdown to 1000 (#6207) --- ui/v2.5/src/components/List/Pagination.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/List/Pagination.tsx b/ui/v2.5/src/components/List/Pagination.tsx index e117b532e..bfa6697ee 100644 --- a/ui/v2.5/src/components/List/Pagination.tsx +++ b/ui/v2.5/src/components/List/Pagination.tsx @@ -44,7 +44,7 @@ const PageCount: React.FC<{ useStopWheelScroll(pageInput); const pageOptions = useMemo(() => { - const maxPagesToShow = 10; + const maxPagesToShow = 1000; const min = Math.max(1, currentPage - maxPagesToShow / 2); const max = Math.min(min + maxPagesToShow, totalPages); const pages = []; From f04be76224abe1225f149ee8641d9520ae748273 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:13:46 +1100 Subject: [PATCH 085/157] Don't trim query string from decoded URL params (#6211) --- ui/v2.5/src/models/list-filter/filter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 2a68cd6a2..4780f1ab6 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -183,7 +183,7 @@ export class ListFilterModel { ret.disp = Number.parseInt(params.disp, 10); } if (params.q) { - ret.q = params.q.trim(); + ret.q = params.q; } if (params.p) { ret.p = Number.parseInt(params.p, 10); From fb7bd89834577d8601f235080a7c450615bf1b97 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:33:20 +1100 Subject: [PATCH 086/157] Fix update loop in Group Sub Groups panel (#6212) * Fix location equality testing causing update loop * Move defaultFilter out of component * Fix add sub groups dialog dropdown render issue --- .../Groups/GroupDetails/AddGroupsDialog.tsx | 1 + .../GroupDetails/GroupSubGroupsPanel.tsx | 26 +++++++++---------- ui/v2.5/src/components/List/util.ts | 9 ++++++- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/ui/v2.5/src/components/Groups/GroupDetails/AddGroupsDialog.tsx b/ui/v2.5/src/components/Groups/GroupDetails/AddGroupsDialog.tsx index b89356810..79c6075c0 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/AddGroupsDialog.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/AddGroupsDialog.tsx @@ -114,6 +114,7 @@ export const AddSubGroupsDialog: React.FC = ( onUpdate={(input) => setEntries(input)} excludeIDs={excludeIDs} filterHook={filterHook} + menuPortalTarget={document.body} /> diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx index a2bb26e95..a02cb6108 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React from "react"; import * as GQL from "src/core/generated-graphql"; import { GroupList } from "../GroupList"; import { ListFilterModel } from "src/models/list-filter/filter"; @@ -101,6 +101,18 @@ interface IGroupSubGroupsPanel { group: GQL.GroupDataFragment; } +const defaultFilter = (() => { + const sortBy = "sub_group_order"; + const ret = new ListFilterModel(GQL.FilterMode.Groups, undefined, { + defaultSortBy: sortBy, + }); + + // unset the sort by so that its not included in the URL + ret.sortBy = undefined; + + return ret; +})(); + export const GroupSubGroupsPanel: React.FC = ({ active, group, @@ -114,18 +126,6 @@ export const GroupSubGroupsPanel: React.FC = ({ const filterHook = useContainingGroupFilterHook(group); - const defaultFilter = useMemo(() => { - const sortBy = "sub_group_order"; - const ret = new ListFilterModel(GQL.FilterMode.Groups, undefined, { - defaultSortBy: sortBy, - }); - - // unset the sort by so that its not included in the URL - ret.sortBy = undefined; - - return ret; - }, []); - async function removeSubGroups( result: GQL.FindGroupsQueryResult, filter: ListFilterModel, diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index c52fcc8d3..b9cb125f4 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -12,6 +12,13 @@ import * as GQL from "src/core/generated-graphql"; import { DisplayMode } from "src/models/list-filter/types"; import { Criterion } from "src/models/list-filter/criteria/criterion"; +function locationEquals( + loc1: ReturnType | undefined, + loc2: ReturnType +) { + return loc1 && loc1.pathname === loc2.pathname && loc1.search === loc2.search; +} + export function useFilterURL( filter: ListFilterModel, setFilter: React.Dispatch>, @@ -49,7 +56,7 @@ export function useFilterURL( useEffect(() => { // don't apply if active is false // also don't apply if location is unchanged - if (!active || prevLocation === location) return; + if (!active || locationEquals(prevLocation, location)) return; // re-init to load default filter on empty new query params if (!location.search) { From 299e1ac1f9f5226ae6788a7ddcba968be7743cbf Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:29:01 +1100 Subject: [PATCH 087/157] Scene list toolbar style update (#6215) * Add saved filter button to toolbar * Rearrange and add portal target * Only overlap sidebar on sm viewports * Hide dropdown button on smaller viewports when sidebar open * Center operations during selection * Restyle results header * Add classname for sidebar pane content * Move sidebar toggle to left during scene selection --- .../components/List/ListOperationButtons.tsx | 14 +- .../src/components/List/ListResultsHeader.tsx | 19 +-- ui/v2.5/src/components/List/ListToolbar.tsx | 65 +++++--- .../src/components/List/SavedFilterList.tsx | 18 ++- ui/v2.5/src/components/List/styles.scss | 140 +++++++++++++++--- ui/v2.5/src/components/Scenes/SceneList.tsx | 44 ++++-- ui/v2.5/src/components/Shared/Sidebar.tsx | 9 +- ui/v2.5/src/components/Shared/styles.scss | 8 +- 8 files changed, 235 insertions(+), 82 deletions(-) diff --git a/ui/v2.5/src/components/List/ListOperationButtons.tsx b/ui/v2.5/src/components/List/ListOperationButtons.tsx index 6bb31339a..bdb87fa3f 100644 --- a/ui/v2.5/src/components/List/ListOperationButtons.tsx +++ b/ui/v2.5/src/components/List/ListOperationButtons.tsx @@ -16,22 +16,28 @@ import { faTrash, } from "@fortawesome/free-solid-svg-icons"; import cx from "classnames"; +import { createPortal } from "react-dom"; export const OperationDropdown: React.FC< PropsWithChildren<{ className?: string; + menuPortalTarget?: HTMLElement; }> -> = ({ className, children }) => { +> = ({ className, menuPortalTarget, children }) => { if (!children) return null; + const menu = ( + + {children} + + ); + return ( - - {children} - + {menuPortalTarget ? createPortal(menu, menuPortalTarget) : menu} ); }; diff --git a/ui/v2.5/src/components/List/ListResultsHeader.tsx b/ui/v2.5/src/components/List/ListResultsHeader.tsx index 091317ec8..a2583c2e1 100644 --- a/ui/v2.5/src/components/List/ListResultsHeader.tsx +++ b/ui/v2.5/src/components/List/ListResultsHeader.tsx @@ -23,15 +23,6 @@ export const ListResultsHeader: React.FC<{ }) => { return ( -
- -
onChangeFilter(filter.setZoom(zoom))} />
+
+ +
+
); }; diff --git a/ui/v2.5/src/components/List/ListToolbar.tsx b/ui/v2.5/src/components/List/ListToolbar.tsx index 31ef7f7ee..25ee281c0 100644 --- a/ui/v2.5/src/components/List/ListToolbar.tsx +++ b/ui/v2.5/src/components/List/ListToolbar.tsx @@ -4,13 +4,15 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { faTimes } from "@fortawesome/free-solid-svg-icons"; import { FilterTags } from "../List/FilterTags"; import cx from "classnames"; -import { Button, ButtonToolbar } from "react-bootstrap"; +import { Button, ButtonGroup, ButtonToolbar } from "react-bootstrap"; import { FilterButton } from "../List/Filters/FilterButton"; import { Icon } from "../Shared/Icon"; import { SearchTermInput } from "../List/ListFilter"; import { Criterion } from "src/models/list-filter/criteria/criterion"; import { SidebarToggleButton } from "../Shared/Sidebar"; import { PatchComponent } from "src/patch"; +import { SavedFilterDropdown } from "./SavedFilterList"; +import { View } from "./views"; export const ToolbarFilterSection: React.FC<{ filter: ListFilterModel; @@ -21,6 +23,7 @@ export const ToolbarFilterSection: React.FC<{ onRemoveAllCriterion: () => void; onEditSearchTerm: () => void; onRemoveSearchTerm: () => void; + view?: View; }> = PatchComponent( "ToolbarFilterSection", ({ @@ -32,6 +35,7 @@ export const ToolbarFilterSection: React.FC<{ onRemoveAllCriterion, onEditSearchTerm, onRemoveSearchTerm, + view, }) => { const { criteria, searchTerm } = filter; @@ -41,10 +45,19 @@ export const ToolbarFilterSection: React.FC<{
- onEditCriterion()} - count={criteria.length} - /> + + + + onEditCriterion()} + count={criteria.length} + /> + -
); @@ -65,28 +77,33 @@ export const ToolbarFilterSection: React.FC<{ export const ToolbarSelectionSection: React.FC<{ selected: number; onToggleSidebar: () => void; + operations?: React.ReactNode; onSelectAll: () => void; onSelectNone: () => void; }> = PatchComponent( "ToolbarSelectionSection", - ({ selected, onToggleSidebar, onSelectAll, onSelectNone }) => { + ({ selected, onToggleSidebar, operations, onSelectAll, onSelectNone }) => { const intl = useIntl(); return ( -
- - {selected} selected - - +
+
+ + + {selected} selected + +
+ {operations} +
); } @@ -114,7 +131,11 @@ export const FilteredListToolbar2: React.FC<{ })} > {!hasSelection ? filterSection : selectionSection} -
{operationSection}
+ {!hasSelection ? ( +
+ {operationSection} +
+ ) : null} ); }; diff --git a/ui/v2.5/src/components/List/SavedFilterList.tsx b/ui/v2.5/src/components/List/SavedFilterList.tsx index 7e03404d2..83c6d8a65 100644 --- a/ui/v2.5/src/components/List/SavedFilterList.tsx +++ b/ui/v2.5/src/components/List/SavedFilterList.tsx @@ -31,6 +31,7 @@ import { AlertModal } from "../Shared/Alert"; import cx from "classnames"; import { TruncatedInlineText } from "../Shared/TruncatedText"; import { OperationButton } from "../Shared/OperationButton"; +import { createPortal } from "react-dom"; const ExistingSavedFilterList: React.FC<{ name: string; @@ -243,6 +244,7 @@ interface ISavedFilterListProps { filter: ListFilterModel; onSetFilter: (f: ListFilterModel) => void; view?: View; + menuPortalTarget?: Element | DocumentFragment; } export const SavedFilterList: React.FC = ({ @@ -841,8 +843,15 @@ export const SavedFilterDropdown: React.FC = (props) => { )); SavedFilterDropdownRef.displayName = "SavedFilterDropdown"; + const menu = ( + + ); + return ( - + = (props) => { - + {props.menuPortalTarget + ? createPortal(menu, props.menuPortalTarget) + : menu} ); }; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index c42c43a56..aba1f39df 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -1055,7 +1055,7 @@ input[type="range"].zoom-slider { } // hide sidebar Edit Filter button on larger screens -@include media-breakpoint-up(lg) { +@include media-breakpoint-up(md) { .sidebar .edit-filter-button { display: none; } @@ -1071,6 +1071,7 @@ input[type="range"].zoom-slider { display: flex; flex-wrap: wrap; justify-content: space-between; + margin-bottom: 0; row-gap: 1rem; > div { @@ -1101,10 +1102,6 @@ input[type="range"].zoom-slider { top: 0; } - .selected-items-info .btn { - margin-right: 0.5rem; - } - // hide drop down menu items for play and create new // when the buttons are visible @include media-breakpoint-up(sm) { @@ -1125,7 +1122,7 @@ input[type="range"].zoom-slider { } } - .selected-items-info, + .toolbar-selection-section, div.filter-section { border: 1px solid $secondary; border-radius: 0.25rem; @@ -1133,13 +1130,69 @@ input[type="range"].zoom-slider { overflow-x: hidden; } - .sidebar-toggle-button { - margin-left: auto; + div.toolbar-selection-section { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: center; + + .sidebar-toggle-button { + margin-right: 0.5rem; + } + + .selected-items-info { + align-items: center; + display: flex; + } + + > div:first-child, + > div:last-child { + flex: 1; + } + + > div:last-child { + display: flex; + justify-content: flex-end; + } + + .scene-list-operations { + display: flex; + } + + // on smaller viewports move the operation buttons to the right + @include media-breakpoint-down(md) { + div.scene-list-operations { + flex: 1; + justify-content: flex-end; + order: 3; + } + + > div:last-child { + flex: 0; + order: 2; + } + } + } + + // on larger viewports, move the operation buttons to the center + @include media-breakpoint-up(lg) { + div.toolbar-selection-section div.scene-list-operations { + justify-content: center; + + > .btn-group { + gap: 0.5rem; + } + } + + div.toolbar-selection-section .empty-space { + flex: 1; + order: 3; + } } .search-container { border-right: 1px solid $secondary; - display: block; + display: flex; margin-right: -0.5rem; min-width: calc($sidebar-width - 15px); padding-right: 10px; @@ -1175,21 +1228,27 @@ input[type="range"].zoom-slider { } } -@include media-breakpoint-up(xl) { +// hide the search box in the toolbar when sidebar is shown on larger screens +// larger screens don't overlap the sidebar +@include media-breakpoint-up(md) { .sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar .search-container { display: none; } } +// hide the search box when sidebar is hidden on smaller screens @include media-breakpoint-down(md) { .sidebar-pane.hide-sidebar .filtered-list-toolbar .search-container { display: none; } } -// hide the filter icon button when sidebar is shown on smaller screens -@include media-breakpoint-down(md) { - .sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar .filter-button { - display: none; +// hide the filter and saved filters icon buttons when sidebar is shown on smaller screens +@include media-breakpoint-down(sm) { + .sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar { + .filter-button, + .saved-filter-dropdown { + display: none; + } } // adjust the width of the filter-tags as well @@ -1198,8 +1257,8 @@ input[type="range"].zoom-slider { } } -// move the sidebar toggle to the left on xl viewports -@include media-breakpoint-up(xl) { +// move the sidebar toggle to the left on larger viewports +@include media-breakpoint-up(md) { .filtered-list-toolbar .filter-section { .sidebar-toggle-button { margin-left: 0; @@ -1249,14 +1308,18 @@ input[type="range"].zoom-slider { align-items: center; background-color: $body-bg; display: flex; - justify-content: space-between; > div { align-items: center; display: flex; + flex: 1; gap: 0.5rem; justify-content: flex-start; + &.pagination-index-container { + justify-content: center; + } + &:last-child { flex-shrink: 0; justify-content: flex-end; @@ -1265,18 +1328,55 @@ input[type="range"].zoom-slider { } .list-results-header { - flex-wrap: wrap-reverse; - gap: 0.5rem; + gap: 0.25rem; margin-bottom: 0.5rem; .paginationIndex { margin: 0; } + // move pagination info to right on medium screens + @include media-breakpoint-down(md) { + & > .empty-space { + flex: 0; + } + + & > div.pagination-index-container { + justify-content: flex-end; + order: 3; + } + } + // center the header on smaller screens @include media-breakpoint-down(sm) { & > div, - & > div:last-child { + & > div.pagination-index-container { + flex-basis: 100%; + justify-content: center; + margin-left: auto; + margin-right: auto; + } + } +} + +// sidebar visible styling +.sidebar-pane:not(.hide-sidebar) .list-results-header { + // move pagination info to right on medium screens when sidebar + @include media-breakpoint-down(lg) { + & > .empty-space { + flex: 0; + } + + & > div.pagination-index-container { + justify-content: flex-end; + order: 3; + } + } + + // center the header on smaller screens when sidebar is visible + @include media-breakpoint-down(md) { + & > div, + & > div.pagination-index-container { flex-basis: 100%; justify-content: center; margin-left: auto; diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 982a11fed..f9257c9ad 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -37,7 +37,12 @@ import { OperationDropdownItem, } from "../List/ListOperationButtons"; import { useFilteredItemList } from "../List/ItemList"; -import { Sidebar, SidebarPane, useSidebarState } from "../Shared/Sidebar"; +import { + Sidebar, + SidebarPane, + SidebarPaneContent, + useSidebarState, +} from "../Shared/Sidebar"; import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter"; import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter"; import { PerformersCriterionOption } from "src/models/list-filter/criteria/performers"; @@ -355,7 +360,7 @@ const SceneListOperations: React.FC<{ const intl = useIntl(); return ( -
+
{!!items && (
diff --git a/ui/v2.5/src/components/Shared/Sidebar.tsx b/ui/v2.5/src/components/Shared/Sidebar.tsx index 52f9328f0..2fe0c48af 100644 --- a/ui/v2.5/src/components/Shared/Sidebar.tsx +++ b/ui/v2.5/src/components/Shared/Sidebar.tsx @@ -16,7 +16,8 @@ import { useIntl } from "react-intl"; import { Icon } from "./Icon"; import { faSliders } from "@fortawesome/free-solid-svg-icons"; -const fixedSidebarMediaQuery = "only screen and (max-width: 1199px)"; +// this needs to correspond to the CSS media query that overlaps the sidebar over content +const fixedSidebarMediaQuery = "only screen and (max-width: 767px)"; export const Sidebar: React.FC< PropsWithChildren<{ @@ -56,6 +57,10 @@ export const SidebarPane: React.FC< ); }; +export const SidebarPaneContent: React.FC = ({ children }) => { + return
{children}
; +}; + export const SidebarSection: React.FC< PropsWithChildren<{ text: React.ReactNode; @@ -87,7 +92,7 @@ export const SidebarToggleButton: React.FC<{ const intl = useIntl(); return (
); diff --git a/ui/v2.5/src/components/Shared/CollapseButton.tsx b/ui/v2.5/src/components/Shared/CollapseButton.tsx index d0b192162..0d05f6e64 100644 --- a/ui/v2.5/src/components/Shared/CollapseButton.tsx +++ b/ui/v2.5/src/components/Shared/CollapseButton.tsx @@ -3,7 +3,7 @@ import { faChevronRight, faChevronUp, } from "@fortawesome/free-solid-svg-icons"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Button, Collapse, CollapseProps } from "react-bootstrap"; import { Icon } from "./Icon"; @@ -12,22 +12,27 @@ interface IProps { text: React.ReactNode; collapseProps?: Partial; outsideCollapse?: React.ReactNode; - onOpen?: () => void; + onOpenChanged?: (o: boolean) => void; + open?: boolean; } export const CollapseButton: React.FC> = ( props: React.PropsWithChildren ) => { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(props.open ?? false); function toggleOpen() { const nv = !open; setOpen(nv); - if (props.onOpen && nv) { - props.onOpen(); - } + props.onOpenChanged?.(nv); } + useEffect(() => { + if (props.open !== undefined) { + setOpen(props.open); + } + }, [props.open]); + return (
diff --git a/ui/v2.5/src/components/Shared/Sidebar.tsx b/ui/v2.5/src/components/Shared/Sidebar.tsx index 2fe0c48af..0130fe85f 100644 --- a/ui/v2.5/src/components/Shared/Sidebar.tsx +++ b/ui/v2.5/src/components/Shared/Sidebar.tsx @@ -15,6 +15,9 @@ import { Button, CollapseProps } from "react-bootstrap"; import { useIntl } from "react-intl"; import { Icon } from "./Icon"; import { faSliders } from "@fortawesome/free-solid-svg-icons"; +import { useHistory } from "react-router-dom"; + +export type SidebarSectionStates = Record; // this needs to correspond to the CSS media query that overlaps the sidebar over content const fixedSidebarMediaQuery = "only screen and (max-width: 767px)"; @@ -61,14 +64,35 @@ export const SidebarPaneContent: React.FC = ({ children }) => { return
{children}
; }; +interface IContext { + sectionOpen: SidebarSectionStates; + setSectionOpen: (section: string, open: boolean) => void; +} + +export const SidebarStateContext = React.createContext(null); + export const SidebarSection: React.FC< PropsWithChildren<{ text: React.ReactNode; className?: string; outsideCollapse?: React.ReactNode; - onOpen?: () => void; + // used to store open/closed state in SidebarStateContext + sectionID?: string; }> -> = ({ className = "", text, outsideCollapse, onOpen, children }) => { +> = ({ className = "", text, outsideCollapse, sectionID = "", children }) => { + // this is optional + const contextState = React.useContext(SidebarStateContext); + const openState = + !contextState || !sectionID + ? undefined + : contextState.sectionOpen[sectionID] ?? undefined; + + function onOpenInternal(open: boolean) { + if (contextState && sectionID) { + contextState.setSectionOpen(sectionID, open); + } + } + const collapseProps: Partial = { mountOnEnter: true, unmountOnExit: true, @@ -79,7 +103,8 @@ export const SidebarSection: React.FC< collapseProps={collapseProps} text={text} outsideCollapse={outsideCollapse} - onOpen={onOpen} + onOpenChanged={onOpenInternal} + open={openState} > {children} @@ -110,6 +135,7 @@ export function defaultShowSidebar() { export function useSidebarState(view?: View) { const [interfaceLocalForage, setInterfaceLocalForage] = useInterfaceLocalForage(); + const history = useHistory(); const { data: interfaceLocalForageData, loading } = interfaceLocalForage; @@ -118,6 +144,7 @@ export function useSidebarState(view?: View) { }, [view, interfaceLocalForageData]); const [showSidebar, setShowSidebar] = useState(); + const [sectionOpen, setSectionOpen] = useState(); // set initial state once loading is done useEffect(() => { @@ -132,7 +159,17 @@ export function useSidebarState(view?: View) { // only show sidebar by default on large screens setShowSidebar(!!viewConfig.showSidebar && defaultShowSidebar()); - }, [view, loading, showSidebar, viewConfig.showSidebar]); + setSectionOpen( + (history.location.state as { sectionOpen?: SidebarSectionStates }) + ?.sectionOpen || {} + ); + }, [ + view, + loading, + showSidebar, + viewConfig.showSidebar, + history.location.state, + ]); const onSetShowSidebar = useCallback( (show: boolean | ((prevState: boolean | undefined) => boolean)) => { @@ -154,9 +191,28 @@ export function useSidebarState(view?: View) { [showSidebar, setInterfaceLocalForage, view, viewConfig] ); + const onSetSectionOpen = useCallback( + (section: string, open: boolean) => { + const newSectionOpen = { ...sectionOpen, [section]: open }; + setSectionOpen(newSectionOpen); + if (view === undefined) return; + + history.replace({ + ...history.location, + state: { + ...(history.location.state as {}), + sectionOpen: newSectionOpen, + }, + }); + }, + [sectionOpen, view, history] + ); + return { showSidebar: showSidebar ?? defaultShowSidebar(), + sectionOpen: sectionOpen || {}, setShowSidebar: onSetShowSidebar, + setSectionOpen: onSetSectionOpen, loading: showSidebar === undefined, }; } From 1b2b4c52218b25f053e6818fe6f089ffdd50e34e Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 31 Oct 2025 19:54:35 +1100 Subject: [PATCH 089/157] Fix panic when scraping with unknown field (#6220) * Fix URL in group scraper causing panic * Return error instead of panicking on unknown field --- pkg/models/model_scraped_item.go | 1 + pkg/scraper/mapped.go | 85 +++++++++++++++++--------------- pkg/scraper/postprocessing.go | 30 +++++++++++ 3 files changed, 76 insertions(+), 40 deletions(-) diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 008a05c3d..f7a9d6255 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -462,6 +462,7 @@ type ScrapedGroup struct { Date *string `json:"date"` Rating *string `json:"rating"` Director *string `json:"director"` + URL *string `json:"url"` // included for backward compatibility URLs []string `json:"urls"` Synopsis *string `json:"synopsis"` Studio *ScrapedStudio `json:"studio"` diff --git a/pkg/scraper/mapped.go b/pkg/scraper/mapped.go index 4b2559334..dcd1af1dd 100644 --- a/pkg/scraper/mapped.go +++ b/pkg/scraper/mapped.go @@ -873,50 +873,55 @@ func (r mappedResult) apply(dest interface{}) { func mapFieldValue(destVal reflect.Value, key string, value interface{}) error { field := destVal.FieldByName(key) + + if !field.IsValid() { + return fmt.Errorf("field %s does not exist on %s", key, destVal.Type().Name()) + } + + if !field.CanSet() { + return fmt.Errorf("field %s cannot be set on %s", key, destVal.Type().Name()) + } + fieldType := field.Type() - if field.IsValid() && field.CanSet() { - switch v := value.(type) { - case string: - // if the field is a pointer to a string, then we need to convert the string to a pointer - // if the field is a string slice, then we need to convert the string to a slice - switch { - case fieldType.Kind() == reflect.String: - field.SetString(v) - case fieldType.Kind() == reflect.Ptr && fieldType.Elem().Kind() == reflect.String: - ptr := reflect.New(fieldType.Elem()) - ptr.Elem().SetString(v) - field.Set(ptr) - case fieldType.Kind() == reflect.Slice && fieldType.Elem().Kind() == reflect.String: - field.Set(reflect.ValueOf([]string{v})) - default: - return fmt.Errorf("cannot convert %T to %s", value, fieldType) - } - case []string: - // expect the field to be a string slice - if fieldType.Kind() == reflect.Slice && fieldType.Elem().Kind() == reflect.String { - field.Set(reflect.ValueOf(v)) - } else { - return fmt.Errorf("cannot convert %T to %s", value, fieldType) - } + switch v := value.(type) { + case string: + // if the field is a pointer to a string, then we need to convert the string to a pointer + // if the field is a string slice, then we need to convert the string to a slice + switch { + case fieldType.Kind() == reflect.String: + field.SetString(v) + case fieldType.Kind() == reflect.Ptr && fieldType.Elem().Kind() == reflect.String: + ptr := reflect.New(fieldType.Elem()) + ptr.Elem().SetString(v) + field.Set(ptr) + case fieldType.Kind() == reflect.Slice && fieldType.Elem().Kind() == reflect.String: + field.Set(reflect.ValueOf([]string{v})) default: - // fallback to reflection - reflectValue := reflect.ValueOf(value) - reflectValueType := reflectValue.Type() - - switch { - case reflectValueType.ConvertibleTo(fieldType): - field.Set(reflectValue.Convert(fieldType)) - case fieldType.Kind() == reflect.Pointer && reflectValueType.ConvertibleTo(fieldType.Elem()): - ptr := reflect.New(fieldType.Elem()) - ptr.Elem().Set(reflectValue.Convert(fieldType.Elem())) - field.Set(ptr) - default: - return fmt.Errorf("cannot convert %T to %s", value, fieldType) - } + return fmt.Errorf("cannot convert %T to %s", value, fieldType) + } + case []string: + // expect the field to be a string slice + if fieldType.Kind() == reflect.Slice && fieldType.Elem().Kind() == reflect.String { + field.Set(reflect.ValueOf(v)) + } else { + return fmt.Errorf("cannot convert %T to %s", value, fieldType) + } + default: + // fallback to reflection + reflectValue := reflect.ValueOf(value) + reflectValueType := reflectValue.Type() + + switch { + case reflectValueType.ConvertibleTo(fieldType): + field.Set(reflectValue.Convert(fieldType)) + case fieldType.Kind() == reflect.Pointer && reflectValueType.ConvertibleTo(fieldType.Elem()): + ptr := reflect.New(fieldType.Elem()) + ptr.Elem().Set(reflectValue.Convert(fieldType.Elem())) + field.Set(ptr) + default: + return fmt.Errorf("cannot convert %T to %s", value, fieldType) } - } else { - return fmt.Errorf("field does not exist or cannot be set") } return nil diff --git a/pkg/scraper/postprocessing.go b/pkg/scraper/postprocessing.go index e12c1664f..62aa53c72 100644 --- a/pkg/scraper/postprocessing.go +++ b/pkg/scraper/postprocessing.go @@ -143,6 +143,21 @@ func (c Cache) postScrapeMovie(ctx context.Context, m models.ScrapedMovie, exclu return nil, nil, err } + // populate URL/URLs + // if URLs are provided, only use those + if len(m.URLs) > 0 { + m.URL = &m.URLs[0] + } else { + urls := []string{} + if m.URL != nil { + urls = append(urls, *m.URL) + } + + if len(urls) > 0 { + m.URLs = urls + } + } + // post-process - set the image if applicable if err := setMovieFrontImage(ctx, c.client, &m, c.globalConfig); err != nil { logger.Warnf("could not set front image using URL %s: %v", *m.FrontImage, err) @@ -175,6 +190,21 @@ func (c Cache) postScrapeGroup(ctx context.Context, m models.ScrapedGroup, exclu return nil, nil, err } + // populate URL/URLs + // if URLs are provided, only use those + if len(m.URLs) > 0 { + m.URL = &m.URLs[0] + } else { + urls := []string{} + if m.URL != nil { + urls = append(urls, *m.URL) + } + + if len(urls) > 0 { + m.URLs = urls + } + } + // post-process - set the image if applicable if err := setGroupFrontImage(ctx, c.client, &m, c.globalConfig); err != nil { logger.Warnf("could not set front image using URL %s: %v", *m.FrontImage, err) From fa2fd31ac7c30af8f6e1600111c274a7c1264abe Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Wed, 5 Nov 2025 23:24:33 +0200 Subject: [PATCH 090/157] Update library section in Configuration.md for clarity (#6232) --- ui/v2.5/src/docs/en/Manual/Configuration.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/docs/en/Manual/Configuration.md b/ui/v2.5/src/docs/en/Manual/Configuration.md index 9b0469114..d7c1b4804 100644 --- a/ui/v2.5/src/docs/en/Manual/Configuration.md +++ b/ui/v2.5/src/docs/en/Manual/Configuration.md @@ -2,7 +2,13 @@ ## Library -This section allows you to add and remove directories from your library list. Files in these directories will be included when scanning. Files that are outside of these directories will be removed when running the Clean task. +This section enables you to add or remove directories that will be discoverable by Stash. The directories you add will be utilized for scanning new files and for updating their locations in Stash database. + +You can configure these directories to apply specifically to: + +- **Videos** +- **Images** +- **Both** > **⚠️ Note:** Don't forget to click `Save` after updating these directories! From 6cace4ff8826db3ea6c663fb186f24ba13c3700a Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:53:43 -0800 Subject: [PATCH 091/157] Update parser to accept groups (#6228) --- pkg/scraper/mapped.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/pkg/scraper/mapped.go b/pkg/scraper/mapped.go index dcd1af1dd..f89499176 100644 --- a/pkg/scraper/mapped.go +++ b/pkg/scraper/mapped.go @@ -126,6 +126,7 @@ type mappedSceneScraperConfig struct { Performers mappedPerformerScraperConfig `yaml:"Performers"` Studio mappedConfig `yaml:"Studio"` Movies mappedConfig `yaml:"Movies"` + Groups mappedConfig `yaml:"Groups"` } type _mappedSceneScraperConfig mappedSceneScraperConfig @@ -134,6 +135,7 @@ const ( mappedScraperConfigScenePerformers = "Performers" mappedScraperConfigSceneStudio = "Studio" mappedScraperConfigSceneMovies = "Movies" + mappedScraperConfigSceneGroups = "Groups" ) func (s *mappedSceneScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { @@ -151,11 +153,13 @@ func (s *mappedSceneScraperConfig) UnmarshalYAML(unmarshal func(interface{}) err thisMap[mappedScraperConfigScenePerformers] = parentMap[mappedScraperConfigScenePerformers] thisMap[mappedScraperConfigSceneStudio] = parentMap[mappedScraperConfigSceneStudio] thisMap[mappedScraperConfigSceneMovies] = parentMap[mappedScraperConfigSceneMovies] + thisMap[mappedScraperConfigSceneGroups] = parentMap[mappedScraperConfigSceneGroups] delete(parentMap, mappedScraperConfigSceneTags) delete(parentMap, mappedScraperConfigScenePerformers) delete(parentMap, mappedScraperConfigSceneStudio) delete(parentMap, mappedScraperConfigSceneMovies) + delete(parentMap, mappedScraperConfigSceneGroups) // re-unmarshal the sub-fields yml, err := yaml.Marshal(thisMap) @@ -1013,6 +1017,7 @@ func (s mappedScraper) processSceneRelationships(ctx context.Context, q mappedQu sceneTagsMap := sceneScraperConfig.Tags sceneStudioMap := sceneScraperConfig.Studio sceneMoviesMap := sceneScraperConfig.Movies + sceneGroupsMap := sceneScraperConfig.Groups ret.Performers = s.processPerformers(ctx, scenePerformersMap, q) @@ -1039,7 +1044,12 @@ func (s mappedScraper) processSceneRelationships(ctx context.Context, q mappedQu ret.Movies = processRelationships[models.ScrapedMovie](ctx, s, sceneMoviesMap, q) } - return len(ret.Performers) > 0 || len(ret.Tags) > 0 || ret.Studio != nil || len(ret.Movies) > 0 + if sceneGroupsMap != nil { + logger.Debug(`Processing scene groups:`) + ret.Groups = processRelationships[models.ScrapedGroup](ctx, s, sceneGroupsMap, q) + } + + return len(ret.Performers) > 0 || len(ret.Tags) > 0 || ret.Studio != nil || len(ret.Movies) > 0 || len(ret.Groups) > 0 } func (s mappedScraper) processPerformers(ctx context.Context, performersMap mappedPerformerScraperConfig, q mappedQuery) []*models.ScrapedPerformer { From f2a787a2bab8cc64c874f59f8cef94afcffb508a Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 6 Nov 2025 10:45:57 +1100 Subject: [PATCH 092/157] Add (hidden) pagination to list results header (#6234) --- .../src/components/List/ListResultsHeader.tsx | 8 +++++++- ui/v2.5/src/components/List/styles.scss | 20 +++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/List/ListResultsHeader.tsx b/ui/v2.5/src/components/List/ListResultsHeader.tsx index a2583c2e1..8a1bba05e 100644 --- a/ui/v2.5/src/components/List/ListResultsHeader.tsx +++ b/ui/v2.5/src/components/List/ListResultsHeader.tsx @@ -1,6 +1,6 @@ import React from "react"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { PaginationIndex } from "../List/Pagination"; +import { Pagination, PaginationIndex } from "../List/Pagination"; import { ButtonToolbar } from "react-bootstrap"; import { ListViewOptions } from "../List/ListViewOptions"; import { PageSizeSelector, SortBySelect } from "../List/ListFilter"; @@ -53,6 +53,12 @@ export const ListResultsHeader: React.FC<{ />
+ onChangeFilter(filter.changePage(page))} + />
); } From 2e766952ddc11a9b016e740e9de8b1d5ebcf9683 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:43:04 +1100 Subject: [PATCH 115/157] Bump github.com/go-chi/chi/v5 from 5.0.12 to 5.2.2 (#5948) Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from 5.0.12 to 5.2.2. - [Release notes](https://github.com/go-chi/chi/releases) - [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md) - [Commits](https://github.com/go-chi/chi/compare/v5.0.12...v5.2.2) --- updated-dependencies: - dependency-name: github.com/go-chi/chi/v5 dependency-version: 5.2.2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 1fc666b4f..bf2eb0f6e 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/disintegration/imaging v1.6.2 github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d github.com/doug-martin/goqu/v9 v9.18.0 - github.com/go-chi/chi/v5 v5.0.12 + github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/cors v1.2.1 github.com/go-chi/httplog v0.3.1 github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 diff --git a/go.sum b/go.sum index 2ce973b4a..dced0768f 100644 --- a/go.sum +++ b/go.sum @@ -197,8 +197,8 @@ github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeME github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= -github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= -github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httplog v0.3.1 h1:uC3IUWCZagtbCinb3ypFh36SEcgd6StWw2Bu0XSXRtg= From d5b10462670db85d6f8b2c112114b9fc558b8fb7 Mon Sep 17 00:00:00 2001 From: EventHoriizon <78643361+EventHoriizon@users.noreply.github.com> Date: Mon, 10 Nov 2025 00:53:53 +0000 Subject: [PATCH 116/157] Group O-Counter Filter/Sort (#6122) --- graphql/schema/types/filters.graphql | 2 ++ graphql/schema/types/group.graphql | 1 + internal/api/resolver_model_movie.go | 11 +++++++ pkg/models/group.go | 2 ++ pkg/models/mocks/SceneReaderWriter.go | 21 ++++++++++++ pkg/models/repository_scene.go | 1 + pkg/sqlite/group.go | 8 +++++ pkg/sqlite/group_filter.go | 36 +++++++++++++++++++++ pkg/sqlite/scene.go | 23 +++++++++++++ ui/v2.5/graphql/data/group.graphql | 1 + ui/v2.5/src/components/Groups/GroupCard.tsx | 17 ++++++++++ ui/v2.5/src/models/list-filter/groups.ts | 5 +++ 12 files changed, 128 insertions(+) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index da309bead..4eb91aa77 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -403,6 +403,8 @@ input GroupFilterType { created_at: TimestampCriterionInput "Filter by last update time" updated_at: TimestampCriterionInput + "Filter by o-counter" + o_counter: IntCriterionInput "Filter by containing groups" containing_groups: HierarchicalMultiCriterionInput diff --git a/graphql/schema/types/group.graphql b/graphql/schema/types/group.graphql index 35fc17a68..a46932054 100644 --- a/graphql/schema/types/group.graphql +++ b/graphql/schema/types/group.graphql @@ -30,6 +30,7 @@ type Group { performer_count(depth: Int): Int! # Resolver sub_group_count(depth: Int): Int! # Resolver scenes: [Scene!]! + o_counter: Int # Resolver } input GroupDescriptionInput { diff --git a/internal/api/resolver_model_movie.go b/internal/api/resolver_model_movie.go index e3fba57c0..317123c6e 100644 --- a/internal/api/resolver_model_movie.go +++ b/internal/api/resolver_model_movie.go @@ -204,3 +204,14 @@ func (r *groupResolver) Scenes(ctx context.Context, obj *models.Group) (ret []*m return ret, nil } + +func (r *groupResolver) OCounter(ctx context.Context, obj *models.Group) (ret *int, err error) { + var count int + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + count, err = r.repository.Scene.OCountByGroupID(ctx, obj.ID) + return err + }); err != nil { + return nil, err + } + return &count, nil +} diff --git a/pkg/models/group.go b/pkg/models/group.go index 6afda3f48..6943b1055 100644 --- a/pkg/models/group.go +++ b/pkg/models/group.go @@ -23,6 +23,8 @@ type GroupFilterType struct { TagCount *IntCriterionInput `json:"tag_count"` // Filter by date Date *DateCriterionInput `json:"date"` + // Filter by O counter + OCounter *IntCriterionInput `json:"o_counter"` // Filter by containing groups ContainingGroups *HierarchicalMultiCriterionInput `json:"containing_groups"` // Filter by sub groups diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index 8e4e5ae5a..bec31b6f2 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -1141,6 +1141,27 @@ func (_m *SceneReaderWriter) HasCover(ctx context.Context, sceneID int) (bool, e return r0, r1 } +// OCountByGroupID provides a mock function with given fields: ctx, groupID +func (_m *SceneReaderWriter) OCountByGroupID(ctx context.Context, groupID int) (int, error) { + ret := _m.Called(ctx, groupID) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, groupID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, groupID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // OCountByPerformerID provides a mock function with given fields: ctx, performerID func (_m *SceneReaderWriter) OCountByPerformerID(ctx context.Context, performerID int) (int, error) { ret := _m.Called(ctx, performerID) diff --git a/pkg/models/repository_scene.go b/pkg/models/repository_scene.go index f0fff4ac7..fe0f473fb 100644 --- a/pkg/models/repository_scene.go +++ b/pkg/models/repository_scene.go @@ -44,6 +44,7 @@ type SceneCounter interface { CountMissingChecksum(ctx context.Context) (int, error) CountMissingOSHash(ctx context.Context) (int, error) OCountByPerformerID(ctx context.Context, performerID int) (int, error) + OCountByGroupID(ctx context.Context, groupID int) (int, error) } // SceneCreator provides methods to create scenes. diff --git a/pkg/sqlite/group.go b/pkg/sqlite/group.go index 686bf4e1e..f0f8d6b40 100644 --- a/pkg/sqlite/group.go +++ b/pkg/sqlite/group.go @@ -488,6 +488,7 @@ var groupSortOptions = sortOptions{ "random", "rating", "scenes_count", + "o_counter", "sub_group_order", "tag_count", "updated_at", @@ -524,6 +525,8 @@ func (qb *GroupStore) setGroupSort(query *queryBuilder, findFilter *models.FindF query.sortAndPagination += getCountSort(groupTable, groupsTagsTable, groupIDColumn, direction) case "scenes_count": // generic getSort won't work for this query.sortAndPagination += getCountSort(groupTable, groupsScenesTable, groupIDColumn, direction) + case "o_counter": + query.sortAndPagination += qb.sortByOCounter(direction) default: query.sortAndPagination += getSort(sort, direction, "groups") } @@ -701,3 +704,8 @@ func (qb *GroupStore) FindInAncestors(ctx context.Context, ascestorIDs []int, id return ret, nil } + +func (qb *GroupStore) sortByOCounter(direction string) string { + // need to sum the o_counter from scenes and images + return " ORDER BY (" + selectGroupOCountSQL + ") " + direction +} diff --git a/pkg/sqlite/group_filter.go b/pkg/sqlite/group_filter.go index dcb7bcdfc..f29023785 100644 --- a/pkg/sqlite/group_filter.go +++ b/pkg/sqlite/group_filter.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" ) type groupFilterHandler struct { @@ -73,6 +74,7 @@ func (qb *groupFilterHandler) criterionHandler() criterionHandler { qb.performersCriterionHandler(groupFilter.Performers), qb.tagsCriterionHandler(groupFilter.Tags), qb.tagCountCriterionHandler(groupFilter.TagCount), + qb.groupOCounterCriterionHandler(groupFilter.OCounter), &dateCriterionHandler{groupFilter.Date, "groups.date", nil}, groupHierarchyHandler.ParentsCriterionHandler(groupFilter.ContainingGroups), groupHierarchyHandler.ChildrenCriterionHandler(groupFilter.SubGroups), @@ -201,3 +203,37 @@ func (qb *groupFilterHandler) tagCountCriterionHandler(count *models.IntCriterio return h.handler(count) } + +// used for sorting and filtering on group o-count +var selectGroupOCountSQL = utils.StrFormat( + "SELECT SUM(o_counter) "+ + "FROM ("+ + "SELECT COUNT({scenes_o_dates}.{o_date}) as o_counter from {groups_scenes} s "+ + "LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} "+ + "LEFT JOIN {scenes_o_dates} ON {scenes_o_dates}.{scene_id} = {scenes}.id "+ + "WHERE s.{group_id} = {group}.id "+ + ")", + map[string]interface{}{ + "group": groupTable, + "group_id": groupIDColumn, + "groups_scenes": groupsScenesTable, + "scenes": sceneTable, + "scene_id": sceneIDColumn, + "scenes_o_dates": scenesODatesTable, + "o_date": sceneODateColumn, + }, +) + +func (qb *groupFilterHandler) groupOCounterCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if count == nil { + return + } + + lhs := "(" + selectGroupOCountSQL + ")" + clause, args := getIntCriterionWhereClause(lhs, *count) + + f.addWhere(clause, args...) + } + +} diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 6cc5aa339..23f5ef482 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -795,6 +795,29 @@ func (qb *SceneStore) OCountByPerformerID(ctx context.Context, performerID int) return ret, nil } +func (qb *SceneStore) OCountByGroupID(ctx context.Context, groupID int) (int, error) { + table := qb.table() + joinTable := scenesGroupsJoinTable + oHistoryTable := goqu.T(scenesODatesTable) + + q := dialect.Select(goqu.COUNT("*")).From(table).InnerJoin( + oHistoryTable, + goqu.On(table.Col(idColumn).Eq(oHistoryTable.Col(sceneIDColumn))), + ).InnerJoin( + joinTable, + goqu.On( + table.Col(idColumn).Eq(joinTable.Col(sceneIDColumn)), + ), + ).Where(joinTable.Col(groupIDColumn).Eq(groupID)) + + var ret int + if err := querySimple(ctx, q, &ret); err != nil { + return 0, err + } + + return ret, nil +} + func (qb *SceneStore) FindByGroupID(ctx context.Context, groupID int) ([]*models.Scene, error) { sq := dialect.From(scenesGroupsJoinTable).Select(scenesGroupsJoinTable.Col(sceneIDColumn)).Where( scenesGroupsJoinTable.Col(groupIDColumn).Eq(groupID), diff --git a/ui/v2.5/graphql/data/group.graphql b/ui/v2.5/graphql/data/group.graphql index 41114f5aa..5251bed89 100644 --- a/ui/v2.5/graphql/data/group.graphql +++ b/ui/v2.5/graphql/data/group.graphql @@ -32,6 +32,7 @@ fragment GroupData on Group { performer_count_all: performer_count(depth: -1) sub_group_count sub_group_count_all: sub_group_count(depth: -1) + o_counter scenes { id diff --git a/ui/v2.5/src/components/Groups/GroupCard.tsx b/ui/v2.5/src/components/Groups/GroupCard.tsx index f1d6089d0..87a594446 100644 --- a/ui/v2.5/src/components/Groups/GroupCard.tsx +++ b/ui/v2.5/src/components/Groups/GroupCard.tsx @@ -10,6 +10,7 @@ import { FormattedMessage } from "react-intl"; import { RatingBanner } from "../Shared/RatingBanner"; import { faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons"; import { RelatedGroupPopoverButton } from "./RelatedGroupPopover"; +import { SweatDrops } from "../Shared/SweatDrops"; const Description: React.FC<{ sceneNumber?: number; @@ -107,6 +108,21 @@ export const GroupCard: React.FC = ({ ); } + function maybeRenderOCounter() { + if (!group.o_counter) return; + + return ( +
+ +
+ ); + } + function maybeRenderPopoverButtonGroup() { if ( sceneNumber || @@ -130,6 +146,7 @@ export const GroupCard: React.FC = ({ group.containing_groups.length > 0) && ( )} + {maybeRenderOCounter()} ); diff --git a/ui/v2.5/src/models/list-filter/groups.ts b/ui/v2.5/src/models/list-filter/groups.ts index c96fd8dc6..6aed48fdc 100644 --- a/ui/v2.5/src/models/list-filter/groups.ts +++ b/ui/v2.5/src/models/list-filter/groups.ts @@ -35,6 +35,10 @@ const sortByOptions = [ messageID: "scene_count", value: "scenes_count", }, + { + messageID: "o_count", + value: "o_counter", + }, ]); const displayModeOptions = [DisplayMode.Grid]; const criterionOptions = [ @@ -49,6 +53,7 @@ const criterionOptions = [ RatingCriterionOption, PerformersCriterionOption, createDateCriterionOption("date"), + createMandatoryNumberCriterionOption("o_counter", "o_count"), ContainingGroupsCriterionOption, SubGroupsCriterionOption, createMandatoryNumberCriterionOption("containing_group_count"), From 34becdf4364a353fe9823f2f97068478f4822b37 Mon Sep 17 00:00:00 2001 From: theqwertyqwert Date: Mon, 10 Nov 2025 02:54:44 +0200 Subject: [PATCH 117/157] Add external links display option for performer thumbnails (#6153) * Add external links display option for performer thumbnails - Introduced a new setting to show links on performer thumbnails. - Updated PerformerCard to conditionally render social media links (Twitter, Instagram) and other external links. - Enhanced ExternalLinksButton to open single links directly if specified. - Updated configuration and localization files to support the new feature. --- .../components/Performers/PerformerCard.tsx | 65 ++++++++++++++++++- ui/v2.5/src/components/Performers/styles.scss | 4 ++ .../SettingsInterfacePanel.tsx | 10 +++ .../components/Shared/ExternalLinksButton.tsx | 30 ++++++--- ui/v2.5/src/core/config.ts | 1 + ui/v2.5/src/locales/en-GB.json | 8 +++ 6 files changed, 109 insertions(+), 9 deletions(-) diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index 02e2a68fd..02c304547 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -17,12 +17,15 @@ import { } from "src/models/list-filter/criteria/criterion"; import { PopoverCountButton } from "../Shared/PopoverCountButton"; import GenderIcon from "./GenderIcon"; -import { faTag } from "@fortawesome/free-solid-svg-icons"; +import { faLink, faTag } from "@fortawesome/free-solid-svg-icons"; +import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons"; import { RatingBanner } from "../Shared/RatingBanner"; import { usePerformerUpdate } from "src/core/StashService"; import { ILabeledId } from "src/models/list-filter/types"; import { FavoriteIcon } from "../Shared/FavoriteIcon"; import { PatchComponent } from "src/patch"; +import { ExternalLinksButton } from "../Shared/ExternalLinksButton"; +import { ConfigurationContext } from "src/hooks/Config"; export interface IPerformerCardExtraCriteria { scenes?: ModifierCriterion[]; @@ -176,6 +179,8 @@ const PerformerCardPopovers: React.FC = PatchComponent( const PerformerCardOverlays: React.FC = PatchComponent( "PerformerCard.Overlays", ({ performer }) => { + const { configuration } = React.useContext(ConfigurationContext); + const uiConfig = configuration?.ui; const [updatePerformer] = usePerformerUpdate(); function onToggleFavorite(v: boolean) { @@ -215,6 +220,63 @@ const PerformerCardOverlays: React.FC = PatchComponent( } } + function maybeRenderLinks() { + if (!uiConfig?.showLinksOnPerformerCard) { + return; + } + + if (performer.urls && performer.urls.length > 0) { + const twitter = performer.urls.filter((u) => + u.match(/https?:\/\/(?:www\.)?(?:twitter|x).com\//) + ); + const instagram = performer.urls.filter((u) => + u.match(/https?:\/\/(?:www\.)?instagram.com\//) + ); + const others = performer.urls.filter( + (u) => !twitter.includes(u) && !instagram.includes(u) + ); + + return ( +
+ {twitter.length > 0 && ( + + )} + {instagram.length > 0 && ( + + )} + {others.length > 0 && ( + + )} +
+ ); + } + } + return ( <> = PatchComponent( className="hide-not-favorite" /> {maybeRenderRatingBanner()} + {maybeRenderLinks()} {maybeRenderFlag()} ); diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 3769bc44b..1840ad960 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -107,6 +107,10 @@ .thumbnail-section { position: relative; + + .instagram { + color: pink; + } } &-image { diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 7d63f9df9..e0c538cd0 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -470,6 +470,7 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( onChange={(v) => saveUI({ showChildTagContent: v })} /> + + + saveUI({ showLinksOnPerformerCard: v })} + /> + + = PatchComponent( "ExternalLinksButton", - ({ urls, icon = faLink, className = "" }) => { + ({ urls, icon = faLink, className = "", openIfSingle = false }) => { if (!urls.length) { return null; } @@ -36,14 +37,27 @@ export const ExternalLinksButton: React.FC<{ document.body ); - return ( - - + if (openIfSingle && urls.length === 1) { + return ( + - - - - ); + + ); + } else { + return ( + + + + + + + ); + } } ); diff --git a/ui/v2.5/src/core/config.ts b/ui/v2.5/src/core/config.ts index fcef8fef5..36d915eeb 100644 --- a/ui/v2.5/src/core/config.ts +++ b/ui/v2.5/src/core/config.ts @@ -45,6 +45,7 @@ export interface IUIConfig { showChildTagContent?: boolean; showChildStudioContent?: boolean; + showLinksOnPerformerCard?: boolean; showTagCardOnHover?: boolean; abbreviateCounters?: boolean; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 48cf2f6d3..f0fe87f61 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -801,6 +801,14 @@ } } }, + "performer_list": { + "heading": "Performer list", + "options": { + "show_links_on_grid_card": { + "heading": "Display links on performer grid cards" + } + } + }, "tag_panel": { "heading": "Tag view", "options": { From 12a9a0b5f6ce9aff167ac530e81ac6b6ee8c8812 Mon Sep 17 00:00:00 2001 From: n0ld069 <218682028+n0ld069@users.noreply.github.com> Date: Sun, 9 Nov 2025 19:11:37 -0600 Subject: [PATCH 118/157] Add keyboard shortcuts for Scene Cover generation (#5984) * Add keyboard shortcuts for screenshot generation - Add 'c c' shortcut to generate screenshot at current time - Add 'c d' shortcut to generate default screenshot - Update keyboard shortcuts documentation --- ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx | 8 ++++++++ ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md | 3 +++ 2 files changed, 11 insertions(+) diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 7d326b3cd..f7e844392 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -248,6 +248,12 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { Mousetrap.bind("p p", () => onQueuePrevious()); Mousetrap.bind("p r", () => onQueueRandom()); Mousetrap.bind(",", () => setCollapsed(!collapsed)); + Mousetrap.bind("c c", () => { + onGenerateScreenshot(getPlayerPosition()); + }); + Mousetrap.bind("c d", () => { + onGenerateScreenshot(); + }); return () => { Mousetrap.unbind("a"); @@ -261,6 +267,8 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { Mousetrap.unbind("p p"); Mousetrap.unbind("p r"); Mousetrap.unbind(","); + Mousetrap.unbind("c c"); + Mousetrap.unbind("c d"); }; }); diff --git a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md index 870de61b5..55b52bdf4 100644 --- a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md +++ b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md @@ -67,6 +67,9 @@ | `r 0` | Unset rating (stars) | | `r {0-9} {0-9}` | Set rating (decimal - `00` for `10.0`) | | ``r ` `` | Unset rating (decimal) | +| Cover generation || +| `c c` | Generate screenshot at current time | +| `c d` | Generate default screenshot | | Playback || | `p n` | Play next scene in queue | | `p p` | Play previous scene in queue | From f434c1f529c3a40a921d512625454d439d9b27d2 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Sun, 9 Nov 2025 19:34:21 -0800 Subject: [PATCH 119/157] Feature: Support Multiple URLs in Studios (#6223) * Backend support for studio URLs * FrontEnd addition * Support URLs in BulkStudioUpdate * Update tagger modal for URLs --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- .idea/go.iml | 3 +- graphql/schema/types/scraper.graphql | 3 +- graphql/schema/types/studio.graphql | 12 +++-- internal/api/resolver_model_studio.go | 29 +++++++++++ internal/api/resolver_mutation_studio.go | 52 +++++++++++++++++-- pkg/models/jsonschema/studio.go | 5 +- pkg/models/mocks/StudioReaderWriter.go | 23 ++++++++ pkg/models/model_scraped_item.go | 40 ++++++++++++-- pkg/models/model_scraped_item_test.go | 32 ++++++++++-- pkg/models/model_studio.go | 10 +++- pkg/models/repository_studio.go | 1 + pkg/models/studio.go | 16 +++--- pkg/performer/import.go | 2 +- pkg/sqlite/anonymise.go | 8 +-- pkg/sqlite/database.go | 2 +- pkg/sqlite/migrations/73_studio_urls.up.sql | 24 +++++++++ pkg/sqlite/migrations/README.md | 7 +++ pkg/sqlite/scene_test.go | 15 ++++++ pkg/sqlite/setup_test.go | 20 ++++++- pkg/sqlite/studio.go | 37 ++++++++++--- pkg/sqlite/studio_filter.go | 19 ++++++- pkg/sqlite/studio_test.go | 31 +++++++++-- pkg/sqlite/tables.go | 9 ++++ pkg/stashbox/studio.go | 5 +- pkg/studio/export.go | 7 ++- pkg/studio/export_test.go | 6 ++- pkg/studio/import.go | 14 ++++- ui/v2.5/graphql/data/scrapers.graphql | 10 ++-- ui/v2.5/graphql/data/studio.graphql | 2 + .../Studios/StudioDetails/Studio.tsx | 7 +-- .../StudioDetails/StudioDetailsPanel.tsx | 19 +++++++ .../Studios/StudioDetails/StudioEditPanel.tsx | 6 +-- .../components/Tagger/scenes/StudioModal.tsx | 44 ++++++++++++++-- 33 files changed, 451 insertions(+), 69 deletions(-) create mode 100644 pkg/sqlite/migrations/73_studio_urls.up.sql create mode 100644 pkg/sqlite/migrations/README.md diff --git a/.idea/go.iml b/.idea/go.iml index eddfcc6c3..86461b085 100644 --- a/.idea/go.iml +++ b/.idea/go.iml @@ -1,5 +1,6 @@ + @@ -10,4 +11,4 @@ - + \ No newline at end of file diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index 8d430be5f..a8e1fccb0 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -55,7 +55,8 @@ type ScrapedStudio { "Set if studio matched" stored_id: ID name: String! - url: String + url: String @deprecated(reason: "use urls") + urls: [String!] parent: ScrapedStudio image: String diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index f7e2fcb24..097f04eb3 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -1,7 +1,8 @@ type Studio { id: ID! name: String! - url: String + url: String @deprecated(reason: "Use urls") + urls: [String!]! parent_studio: Studio child_studios: [Studio!]! aliases: [String!]! @@ -28,7 +29,8 @@ type Studio { input StudioCreateInput { name: String! - url: String + url: String @deprecated(reason: "Use urls") + urls: [String!] parent_id: ID "This should be a URL or a base64 encoded data URL" image: String @@ -45,7 +47,8 @@ input StudioCreateInput { input StudioUpdateInput { id: ID! name: String - url: String + url: String @deprecated(reason: "Use urls") + urls: [String!] parent_id: ID "This should be a URL or a base64 encoded data URL" image: String @@ -61,7 +64,8 @@ input StudioUpdateInput { input BulkStudioUpdateInput { ids: [ID!]! - url: String + url: String @deprecated(reason: "Use urls") + urls: BulkUpdateStrings parent_id: ID # rating expressed as 1-100 rating100: Int diff --git a/internal/api/resolver_model_studio.go b/internal/api/resolver_model_studio.go index 2111039c8..850d42b54 100644 --- a/internal/api/resolver_model_studio.go +++ b/internal/api/resolver_model_studio.go @@ -40,6 +40,35 @@ func (r *studioResolver) Aliases(ctx context.Context, obj *models.Studio) ([]str return obj.Aliases.List(), nil } +func (r *studioResolver) URL(ctx context.Context, obj *models.Studio) (*string, error) { + if !obj.URLs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadURLs(ctx, r.repository.Studio) + }); err != nil { + return nil, err + } + } + + urls := obj.URLs.List() + if len(urls) == 0 { + return nil, nil + } + + return &urls[0], nil +} + +func (r *studioResolver) Urls(ctx context.Context, obj *models.Studio) ([]string, error) { + if !obj.URLs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadURLs(ctx, r.repository.Studio) + }); err != nil { + return nil, err + } + } + + return obj.URLs.List(), nil +} + func (r *studioResolver) Tags(ctx context.Context, obj *models.Studio) (ret []*models.Tag, err error) { if !obj.TagIDs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_mutation_studio.go b/internal/api/resolver_mutation_studio.go index caecf39b9..03c13d85f 100644 --- a/internal/api/resolver_mutation_studio.go +++ b/internal/api/resolver_mutation_studio.go @@ -33,7 +33,6 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio newStudio := models.NewStudio() newStudio.Name = input.Name - newStudio.URL = translator.string(input.URL) newStudio.Rating = input.Rating100 newStudio.Favorite = translator.bool(input.Favorite) newStudio.Details = translator.string(input.Details) @@ -43,6 +42,15 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio var err error + newStudio.URLs = models.NewRelatedStrings([]string{}) + if input.URL != nil { + newStudio.URLs.Add(*input.URL) + } + + if input.Urls != nil { + newStudio.URLs.Add(input.Urls...) + } + newStudio.ParentID, err = translator.intPtrFromString(input.ParentID) if err != nil { return nil, fmt.Errorf("converting parent id: %w", err) @@ -106,7 +114,6 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio updatedStudio.ID = studioID updatedStudio.Name = translator.optionalString(input.Name, "name") - updatedStudio.URL = translator.optionalString(input.URL, "url") updatedStudio.Details = translator.optionalString(input.Details, "details") updatedStudio.Rating = translator.optionalInt(input.Rating100, "rating100") updatedStudio.Favorite = translator.optionalBool(input.Favorite, "favorite") @@ -124,6 +131,26 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio return nil, fmt.Errorf("converting tag ids: %w", err) } + if translator.hasField("urls") { + // ensure url not included in the input + if err := r.validateNoLegacyURLs(translator); err != nil { + return nil, err + } + + updatedStudio.URLs = translator.updateStrings(input.Urls, "urls") + } else if translator.hasField("url") { + // handle legacy url field + legacyURLs := []string{} + if input.URL != nil { + legacyURLs = append(legacyURLs, *input.URL) + } + + updatedStudio.URLs = &models.UpdateStrings{ + Mode: models.RelationshipUpdateModeSet, + Values: legacyURLs, + } + } + // Process the base 64 encoded image string var imageData []byte imageIncluded := translator.hasField("image") @@ -181,7 +208,26 @@ func (r *mutationResolver) BulkStudioUpdate(ctx context.Context, input BulkStudi return nil, fmt.Errorf("converting parent id: %w", err) } - partial.URL = translator.optionalString(input.URL, "url") + if translator.hasField("urls") { + // ensure url/twitter/instagram are not included in the input + if err := r.validateNoLegacyURLs(translator); err != nil { + return nil, err + } + + partial.URLs = translator.updateStringsBulk(input.Urls, "urls") + } else if translator.hasField("url") { + // handle legacy url field + legacyURLs := []string{} + if input.URL != nil { + legacyURLs = append(legacyURLs, *input.URL) + } + + partial.URLs = &models.UpdateStrings{ + Mode: models.RelationshipUpdateModeSet, + Values: legacyURLs, + } + } + partial.Favorite = translator.optionalBool(input.Favorite, "favorite") partial.Rating = translator.optionalInt(input.Rating100, "rating100") partial.Details = translator.optionalString(input.Details, "details") diff --git a/pkg/models/jsonschema/studio.go b/pkg/models/jsonschema/studio.go index 80ed97d92..a3706df66 100644 --- a/pkg/models/jsonschema/studio.go +++ b/pkg/models/jsonschema/studio.go @@ -12,7 +12,7 @@ import ( type Studio struct { Name string `json:"name,omitempty"` - URL string `json:"url,omitempty"` + URLs []string `json:"urls,omitempty"` ParentStudio string `json:"parent_studio,omitempty"` Image string `json:"image,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"` @@ -24,6 +24,9 @@ type Studio struct { StashIDs []models.StashID `json:"stash_ids,omitempty"` Tags []string `json:"tags,omitempty"` IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` + + // deprecated - for import only + URL string `json:"url,omitempty"` } func (s Studio) Filename() string { diff --git a/pkg/models/mocks/StudioReaderWriter.go b/pkg/models/mocks/StudioReaderWriter.go index d4932ca71..481565d6f 100644 --- a/pkg/models/mocks/StudioReaderWriter.go +++ b/pkg/models/mocks/StudioReaderWriter.go @@ -360,6 +360,29 @@ func (_m *StudioReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]i return r0, r1 } +// GetURLs provides a mock function with given fields: ctx, relatedID +func (_m *StudioReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) { + ret := _m.Called(ctx, relatedID) + + var r0 []string + if rf, ok := ret.Get(0).(func(context.Context, int) []string); ok { + r0 = rf(ctx, relatedID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, relatedID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // HasImage provides a mock function with given fields: ctx, studioID func (_m *StudioReaderWriter) HasImage(ctx context.Context, studioID int) (bool, error) { ret := _m.Called(ctx, studioID) diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index f7a9d6255..a06463134 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -14,7 +14,8 @@ type ScrapedStudio struct { // Set if studio matched StoredID *string `json:"stored_id"` Name string `json:"name"` - URL *string `json:"url"` + URL *string `json:"url"` // deprecated + URLs []string `json:"urls"` Parent *ScrapedStudio `json:"parent"` Image *string `json:"image"` Images []string `json:"images"` @@ -38,8 +39,20 @@ func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *Stu }) } - if s.URL != nil && !excluded["url"] { - ret.URL = *s.URL + // if URLs are provided, only use those + if len(s.URLs) > 0 { + if !excluded["urls"] { + ret.URLs = NewRelatedStrings(s.URLs) + } + } else { + urls := []string{} + if s.URL != nil && !excluded["url"] { + urls = append(urls, *s.URL) + } + + if len(urls) > 0 { + ret.URLs = NewRelatedStrings(urls) + } } if s.Parent != nil && s.Parent.StoredID != nil && !excluded["parent"] && !excluded["parent_studio"] { @@ -74,8 +87,25 @@ func (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[strin ret.Name = NewOptionalString(s.Name) } - if s.URL != nil && !excluded["url"] { - ret.URL = NewOptionalString(*s.URL) + if len(s.URLs) > 0 { + if !excluded["urls"] { + ret.URLs = &UpdateStrings{ + Values: s.URLs, + Mode: RelationshipUpdateModeSet, + } + } + } else { + urls := []string{} + if s.URL != nil && !excluded["url"] { + urls = append(urls, *s.URL) + } + + if len(urls) > 0 { + ret.URLs = &UpdateStrings{ + Values: urls, + Mode: RelationshipUpdateModeSet, + } + } } if s.Parent != nil && !excluded["parent"] { diff --git a/pkg/models/model_scraped_item_test.go b/pkg/models/model_scraped_item_test.go index 1e8edccb4..b6b44025f 100644 --- a/pkg/models/model_scraped_item_test.go +++ b/pkg/models/model_scraped_item_test.go @@ -11,6 +11,7 @@ import ( func Test_scrapedToStudioInput(t *testing.T) { const name = "name" url := "url" + url2 := "url2" emptyEndpoint := "" endpoint := "endpoint" remoteSiteID := "remoteSiteID" @@ -25,13 +26,33 @@ func Test_scrapedToStudioInput(t *testing.T) { "set all", &ScrapedStudio{ Name: name, + URLs: []string{url, url2}, URL: &url, RemoteSiteID: &remoteSiteID, }, endpoint, &Studio{ Name: name, - URL: url, + URLs: NewRelatedStrings([]string{url, url2}), + StashIDs: NewRelatedStashIDs([]StashID{ + { + Endpoint: endpoint, + StashID: remoteSiteID, + }, + }), + }, + }, + { + "set url instead of urls", + &ScrapedStudio{ + Name: name, + URL: &url, + RemoteSiteID: &remoteSiteID, + }, + endpoint, + &Studio{ + Name: name, + URLs: NewRelatedStrings([]string{url}), StashIDs: NewRelatedStashIDs([]StashID{ { Endpoint: endpoint, @@ -321,9 +342,12 @@ func TestScrapedStudio_ToPartial(t *testing.T) { fullStudio, stdArgs, StudioPartial{ - ID: id, - Name: NewOptionalString(name), - URL: NewOptionalString(url), + ID: id, + Name: NewOptionalString(name), + URLs: &UpdateStrings{ + Values: []string{url}, + Mode: RelationshipUpdateModeSet, + }, ParentID: NewOptionalInt(parentStoredID), StashIDs: &UpdateStashIDs{ StashIDs: append(existingStashIDs, StashID{ diff --git a/pkg/models/model_studio.go b/pkg/models/model_studio.go index 0f4a09bc2..8c7a687af 100644 --- a/pkg/models/model_studio.go +++ b/pkg/models/model_studio.go @@ -8,7 +8,6 @@ import ( type Studio struct { ID int `json:"id"` Name string `json:"name"` - URL string `json:"url"` ParentID *int `json:"parent_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -19,6 +18,7 @@ type Studio struct { IgnoreAutoTag bool `json:"ignore_auto_tag"` Aliases RelatedStrings `json:"aliases"` + URLs RelatedStrings `json:"urls"` TagIDs RelatedIDs `json:"tag_ids"` StashIDs RelatedStashIDs `json:"stash_ids"` } @@ -35,7 +35,6 @@ func NewStudio() Studio { type StudioPartial struct { ID int Name OptionalString - URL OptionalString ParentID OptionalInt // Rating expressed in 1-100 scale Rating OptionalInt @@ -46,6 +45,7 @@ type StudioPartial struct { IgnoreAutoTag OptionalBool Aliases *UpdateStrings + URLs *UpdateStrings TagIDs *UpdateIDs StashIDs *UpdateStashIDs } @@ -63,6 +63,12 @@ func (s *Studio) LoadAliases(ctx context.Context, l AliasLoader) error { }) } +func (s *Studio) LoadURLs(ctx context.Context, l URLLoader) error { + return s.URLs.load(func() ([]string, error) { + return l.GetURLs(ctx, s.ID) + }) +} + func (s *Studio) LoadTagIDs(ctx context.Context, l TagIDLoader) error { return s.TagIDs.load(func() ([]int, error) { return l.GetTagIDs(ctx, s.ID) diff --git a/pkg/models/repository_studio.go b/pkg/models/repository_studio.go index a2b9202f3..99f98bffc 100644 --- a/pkg/models/repository_studio.go +++ b/pkg/models/repository_studio.go @@ -77,6 +77,7 @@ type StudioReader interface { AliasLoader StashIDLoader TagIDLoader + URLLoader All(ctx context.Context) ([]*Studio, error) GetImage(ctx context.Context, studioID int) ([]byte, error) diff --git a/pkg/models/studio.go b/pkg/models/studio.go index 03ea8a84d..171168129 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -47,9 +47,10 @@ type StudioFilterType struct { } type StudioCreateInput struct { - Name string `json:"name"` - URL *string `json:"url"` - ParentID *string `json:"parent_id"` + Name string `json:"name"` + URL *string `json:"url"` // deprecated + Urls []string `json:"urls"` + ParentID *string `json:"parent_id"` // This should be a URL or a base64 encoded data URL Image *string `json:"image"` StashIds []StashIDInput `json:"stash_ids"` @@ -62,10 +63,11 @@ type StudioCreateInput struct { } type StudioUpdateInput struct { - ID string `json:"id"` - Name *string `json:"name"` - URL *string `json:"url"` - ParentID *string `json:"parent_id"` + ID string `json:"id"` + Name *string `json:"name"` + URL *string `json:"url"` // deprecated + Urls []string `json:"urls"` + ParentID *string `json:"parent_id"` // This should be a URL or a base64 encoded data URL Image *string `json:"image"` StashIds []StashIDInput `json:"stash_ids"` diff --git a/pkg/performer/import.go b/pkg/performer/import.go index 3aaacdb8b..622af2b1a 100644 --- a/pkg/performer/import.go +++ b/pkg/performer/import.go @@ -233,7 +233,7 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform } if len(urls) > 0 { - newPerformer.URLs = models.NewRelatedStrings([]string{performerJSON.URL}) + newPerformer.URLs = models.NewRelatedStrings(urls) } } diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index 20926ed25..ba376d785 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -619,7 +619,6 @@ func (db *Anonymiser) anonymiseStudios(ctx context.Context) error { query := dialect.From(table).Select( table.Col(idColumn), table.Col("name"), - table.Col("url"), table.Col("details"), ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) @@ -630,14 +629,12 @@ func (db *Anonymiser) anonymiseStudios(ctx context.Context) error { var ( id int name sql.NullString - url sql.NullString details sql.NullString ) if err := rows.Scan( &id, &name, - &url, &details, ); err != nil { return err @@ -645,7 +642,6 @@ func (db *Anonymiser) anonymiseStudios(ctx context.Context) error { set := goqu.Record{} db.obfuscateNullString(set, "name", name) - db.obfuscateNullString(set, "url", url) db.obfuscateNullString(set, "details", details) if len(set) > 0 { @@ -677,6 +673,10 @@ func (db *Anonymiser) anonymiseStudios(ctx context.Context) error { return err } + if err := db.anonymiseURLs(ctx, goqu.T(studioURLsTable), "studio_id"); err != nil { + return err + } + return nil } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 8bf0f0bda..b846efaf4 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 72 +var appSchemaVersion uint = 73 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/73_studio_urls.up.sql b/pkg/sqlite/migrations/73_studio_urls.up.sql new file mode 100644 index 000000000..c356713c0 --- /dev/null +++ b/pkg/sqlite/migrations/73_studio_urls.up.sql @@ -0,0 +1,24 @@ +CREATE TABLE `studio_urls` ( + `studio_id` integer NOT NULL, + `position` integer NOT NULL, + `url` varchar(255) NOT NULL, + foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE, + PRIMARY KEY(`studio_id`, `position`, `url`) +); + +CREATE INDEX `studio_urls_url` on `studio_urls` (`url`); + +INSERT INTO `studio_urls` + ( + `studio_id`, + `position`, + `url` + ) + SELECT + `id`, + '0', + `url` + FROM `studios` + WHERE `studios`.`url` IS NOT NULL AND `studios`.`url` != ''; + +ALTER TABLE `studios` DROP COLUMN `url`; diff --git a/pkg/sqlite/migrations/README.md b/pkg/sqlite/migrations/README.md new file mode 100644 index 000000000..f0abb9bc0 --- /dev/null +++ b/pkg/sqlite/migrations/README.md @@ -0,0 +1,7 @@ +# Creating a migration + +1. Create new migration file in the migrations directory with the format `NN_description.up.sql`, where `NN` is the next sequential number. + +2. Update `pkg/sqlite/database.go` to update the `appSchemaVersion` value to the new migration number. + +For migrations requiring complex logic or config file changes, see existing custom migrations for examples. \ No newline at end of file diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index b39b47129..1efc4d705 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -2659,6 +2659,21 @@ func verifyString(t *testing.T, value string, criterion models.StringCriterionIn } } +func verifyStringList(t *testing.T, values []string, criterion models.StringCriterionInput) { + t.Helper() + assert := assert.New(t) + switch criterion.Modifier { + case models.CriterionModifierIsNull: + assert.Empty(values) + case models.CriterionModifierNotNull: + assert.NotEmpty(values) + default: + for _, v := range values { + verifyString(t, v, criterion) + } + } +} + func TestSceneQueryRating100(t *testing.T) { const rating = 60 ratingCriterion := models.IntCriterionInput{ diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index a1df897ca..704dde8a2 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1770,6 +1770,24 @@ func getStudioBoolValue(index int) bool { return index == 1 } +func getStudioEmptyString(index int, field string) string { + v := getPrefixedNullStringValue("studio", index, field) + if !v.Valid { + return "" + } + + return v.String +} + +func getStudioStringList(index int, field string) []string { + v := getStudioEmptyString(index, field) + if v == "" { + return []string{} + } + + return []string{v} +} + // createStudios creates n studios with plain Name and o studios with camel cased NaMe included func createStudios(ctx context.Context, n int, o int) error { sqb := db.Studio @@ -1790,7 +1808,7 @@ func createStudios(ctx context.Context, n int, o int) error { tids := indexesToIDs(tagIDs, studioTags[i]) studio := models.Studio{ Name: name, - URL: getStudioStringValue(index, urlField), + URLs: models.NewRelatedStrings(getStudioStringList(i, urlField)), Favorite: getStudioBoolValue(index), IgnoreAutoTag: getIgnoreAutoTag(i), TagIDs: models.NewRelatedIDs(tids), diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 5affb73d6..bddc17c12 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -18,8 +18,12 @@ import ( ) const ( - studioTable = "studios" - studioIDColumn = "studio_id" + studioTable = "studios" + studioIDColumn = "studio_id" + + studioURLsTable = "studio_urls" + studioURLColumn = "url" + studioAliasesTable = "studio_aliases" studioAliasColumn = "alias" studioParentIDColumn = "parent_id" @@ -31,7 +35,6 @@ const ( type studioRow struct { ID int `db:"id" goqu:"skipinsert"` Name zero.String `db:"name"` - URL zero.String `db:"url"` ParentID null.Int `db:"parent_id,omitempty"` CreatedAt Timestamp `db:"created_at"` UpdatedAt Timestamp `db:"updated_at"` @@ -48,7 +51,6 @@ type studioRow struct { func (r *studioRow) fromStudio(o models.Studio) { r.ID = o.ID r.Name = zero.StringFrom(o.Name) - r.URL = zero.StringFrom(o.URL) r.ParentID = intFromPtr(o.ParentID) r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} @@ -62,7 +64,6 @@ func (r *studioRow) resolve() *models.Studio { ret := &models.Studio{ ID: r.ID, Name: r.Name.String, - URL: r.URL.String, ParentID: nullIntPtr(r.ParentID), CreatedAt: r.CreatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp, @@ -81,7 +82,6 @@ type studioRowRecord struct { func (r *studioRowRecord) fromPartial(o models.StudioPartial) { r.setNullString("name", o.Name) - r.setNullString("url", o.URL) r.setNullInt("parent_id", o.ParentID) r.setTimestamp("created_at", o.CreatedAt) r.setTimestamp("updated_at", o.UpdatedAt) @@ -190,6 +190,13 @@ func (qb *StudioStore) Create(ctx context.Context, newObject *models.Studio) err } } + if newObject.URLs.Loaded() { + const startPos = 0 + if err := studiosURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { + return err + } + } + if err := qb.tagRelationshipStore.createRelationships(ctx, id, newObject.TagIDs); err != nil { return err } @@ -234,6 +241,12 @@ func (qb *StudioStore) UpdatePartial(ctx context.Context, input models.StudioPar } } + if input.URLs != nil { + if err := studiosURLsTableMgr.modifyJoins(ctx, input.ID, input.URLs.Values, input.URLs.Mode); err != nil { + return nil, err + } + } + if err := qb.tagRelationshipStore.modifyRelationships(ctx, input.ID, input.TagIDs); err != nil { return nil, err } @@ -262,6 +275,12 @@ func (qb *StudioStore) Update(ctx context.Context, updatedObject *models.Studio) } } + if updatedObject.URLs.Loaded() { + if err := studiosURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { + return err + } + } + if err := qb.tagRelationshipStore.replaceRelationships(ctx, updatedObject.ID, updatedObject.TagIDs); err != nil { return err } @@ -507,7 +526,7 @@ func (qb *StudioStore) QueryForAutoTag(ctx context.Context, words []string) ([]* ret, err := qb.findBySubquery(ctx, sq) if err != nil { - return nil, fmt.Errorf("getting performers for autotag: %w", err) + return nil, fmt.Errorf("getting studios for autotag: %w", err) } return ret, nil @@ -663,3 +682,7 @@ func (qb *StudioStore) GetStashIDs(ctx context.Context, studioID int) ([]models. func (qb *StudioStore) GetAliases(ctx context.Context, studioID int) ([]string, error) { return studiosAliasesTableMgr.get(ctx, studioID) } + +func (qb *StudioStore) GetURLs(ctx context.Context, studioID int) ([]string, error) { + return studiosURLsTableMgr.get(ctx, studioID) +} diff --git a/pkg/sqlite/studio_filter.go b/pkg/sqlite/studio_filter.go index c514364c4..6ff7fcced 100644 --- a/pkg/sqlite/studio_filter.go +++ b/pkg/sqlite/studio_filter.go @@ -55,7 +55,7 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler { return compoundHandler{ stringCriterionHandler(studioFilter.Name, studioTable+".name"), stringCriterionHandler(studioFilter.Details, studioTable+".details"), - stringCriterionHandler(studioFilter.URL, studioTable+".url"), + qb.urlsCriterionHandler(studioFilter.URL), intCriterionHandler(studioFilter.Rating100, studioTable+".rating", nil), boolCriterionHandler(studioFilter.Favorite, studioTable+".favorite", nil), boolCriterionHandler(studioFilter.IgnoreAutoTag, studioTable+".ignore_auto_tag", nil), @@ -118,6 +118,9 @@ func (qb *studioFilterHandler) isMissingCriterionHandler(isMissing *string) crit return func(ctx context.Context, f *filterBuilder) { if isMissing != nil && *isMissing != "" { switch *isMissing { + case "url": + studiosURLsTableMgr.join(f, "", "studios.id") + f.addWhere("studio_urls.url IS NULL") case "image": f.addWhere("studios.image_blob IS NULL") case "stash_id": @@ -202,6 +205,20 @@ func (qb *studioFilterHandler) aliasCriterionHandler(alias *models.StringCriteri return h.handler(alias) } +func (qb *studioFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + primaryTable: studioTable, + primaryFK: studioIDColumn, + joinTable: studioURLsTable, + stringColumn: studioURLColumn, + addJoinTable: func(f *filterBuilder) { + studiosURLsTableMgr.join(f, "", "studios.id") + }, + } + + return h.handler(url) +} + func (qb *studioFilterHandler) childCountCriterionHandler(childCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if childCount != nil { diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index c327a6316..003877c77 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -82,6 +82,14 @@ func TestStudioQueryNameOr(t *testing.T) { }) } +func loadStudioRelationships(ctx context.Context, t *testing.T, s *models.Studio) error { + if err := s.LoadURLs(ctx, db.Studio); err != nil { + return err + } + + return nil +} + func TestStudioQueryNameAndUrl(t *testing.T) { const studioIdx = 1 studioName := getStudioStringValue(studioIdx, "Name") @@ -107,9 +115,16 @@ func TestStudioQueryNameAndUrl(t *testing.T) { studios := queryStudio(ctx, t, sqb, &studioFilter, nil) - assert.Len(t, studios, 1) + if !assert.Len(t, studios, 1) { + return nil + } + + if err := studios[0].LoadURLs(ctx, db.Studio); err != nil { + t.Errorf("Error loading studio relationships: %v", err) + } + assert.Equal(t, studioName, studios[0].Name) - assert.Equal(t, studioUrl, studios[0].URL) + assert.Equal(t, []string{studioUrl}, studios[0].URLs.List()) return nil }) @@ -145,9 +160,13 @@ func TestStudioQueryNameNotUrl(t *testing.T) { studios := queryStudio(ctx, t, sqb, &studioFilter, nil) for _, studio := range studios { + if err := studio.LoadURLs(ctx, db.Studio); err != nil { + t.Errorf("Error loading studio relationships: %v", err) + } + verifyString(t, studio.Name, nameCriterion) urlCriterion.Modifier = models.CriterionModifierNotEquals - verifyString(t, studio.URL, urlCriterion) + verifyStringList(t, studio.URLs.List(), urlCriterion) } return nil @@ -659,7 +678,11 @@ func TestStudioQueryURL(t *testing.T) { verifyFn := func(ctx context.Context, g *models.Studio) { t.Helper() - verifyString(t, g.URL, urlCriterion) + if err := g.LoadURLs(ctx, db.Studio); err != nil { + t.Errorf("Error loading studio relationships: %v", err) + return + } + verifyStringList(t, g.URLs.List(), urlCriterion) } verifyStudioQuery(t, filter, verifyFn) diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 0188cfebc..b28dd777c 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -37,6 +37,7 @@ var ( performersCustomFieldsTable = goqu.T("performer_custom_fields") studiosAliasesJoinTable = goqu.T(studioAliasesTable) + studiosURLsJoinTable = goqu.T(studioURLsTable) studiosTagsJoinTable = goqu.T(studiosTagsTable) studiosStashIDsJoinTable = goqu.T("studio_stash_ids") @@ -319,6 +320,14 @@ var ( stringColumn: studiosAliasesJoinTable.Col(studioAliasColumn), } + studiosURLsTableMgr = &orderedValueTable[string]{ + table: table{ + table: studiosURLsJoinTable, + idColumn: studiosURLsJoinTable.Col(studioIDColumn), + }, + valueColumn: studiosURLsJoinTable.Col(studioURLColumn), + } + studiosTagsTableMgr = &joinTable{ table: table{ table: studiosTagsJoinTable, diff --git a/pkg/stashbox/studio.go b/pkg/stashbox/studio.go index b424ac6fa..a0e9a6ea6 100644 --- a/pkg/stashbox/studio.go +++ b/pkg/stashbox/studio.go @@ -65,11 +65,14 @@ func studioFragmentToScrapedStudio(s graphql.StudioFragment) *models.ScrapedStud st := &models.ScrapedStudio{ Name: s.Name, - URL: findURL(s.Urls, "HOME"), Images: images, RemoteSiteID: &s.ID, } + for _, u := range s.Urls { + st.URLs = append(st.URLs, u.URL) + } + if len(st.Images) > 0 { st.Image = &st.Images[0] } diff --git a/pkg/studio/export.go b/pkg/studio/export.go index 483058c10..1440c3cdd 100644 --- a/pkg/studio/export.go +++ b/pkg/studio/export.go @@ -14,6 +14,7 @@ import ( type FinderImageStashIDGetter interface { models.StudioGetter models.AliasLoader + models.URLLoader models.StashIDLoader GetImage(ctx context.Context, studioID int) ([]byte, error) } @@ -22,7 +23,6 @@ type FinderImageStashIDGetter interface { func ToJSON(ctx context.Context, reader FinderImageStashIDGetter, studio *models.Studio) (*jsonschema.Studio, error) { newStudioJSON := jsonschema.Studio{ Name: studio.Name, - URL: studio.URL, Details: studio.Details, Favorite: studio.Favorite, IgnoreAutoTag: studio.IgnoreAutoTag, @@ -50,6 +50,11 @@ func ToJSON(ctx context.Context, reader FinderImageStashIDGetter, studio *models } newStudioJSON.Aliases = studio.Aliases.List() + if err := studio.LoadURLs(ctx, reader); err != nil { + return nil, fmt.Errorf("loading studio URLs: %w", err) + } + newStudioJSON.URLs = studio.URLs.List() + if err := studio.LoadStashIDs(ctx, reader); err != nil { return nil, fmt.Errorf("loading studio stash ids: %w", err) } diff --git a/pkg/studio/export_test.go b/pkg/studio/export_test.go index 0e42141ec..c333c0ad5 100644 --- a/pkg/studio/export_test.go +++ b/pkg/studio/export_test.go @@ -60,7 +60,7 @@ func createFullStudio(id int, parentID int) models.Studio { ret := models.Studio{ ID: id, Name: studioName, - URL: url, + URLs: models.NewRelatedStrings([]string{url}), Details: details, Favorite: true, CreatedAt: createTime, @@ -84,6 +84,7 @@ func createEmptyStudio(id int) models.Studio { ID: id, CreatedAt: createTime, UpdatedAt: updateTime, + URLs: models.NewRelatedStrings([]string{}), Aliases: models.NewRelatedStrings([]string{}), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), @@ -93,7 +94,7 @@ func createEmptyStudio(id int) models.Studio { func createFullJSONStudio(parentStudio, image string, aliases []string) *jsonschema.Studio { return &jsonschema.Studio{ Name: studioName, - URL: url, + URLs: []string{url}, Details: details, Favorite: true, CreatedAt: json.JSONTime{ @@ -120,6 +121,7 @@ func createEmptyJSONStudio() *jsonschema.Studio { Time: updateTime, }, Aliases: []string{}, + URLs: []string{}, StashIDs: []models.StashID{}, } } diff --git a/pkg/studio/import.go b/pkg/studio/import.go index 3aaceb093..405852e53 100644 --- a/pkg/studio/import.go +++ b/pkg/studio/import.go @@ -217,7 +217,6 @@ func (i *Importer) Update(ctx context.Context, id int) error { func studioJSONtoStudio(studioJSON jsonschema.Studio) models.Studio { newStudio := models.Studio{ Name: studioJSON.Name, - URL: studioJSON.URL, Aliases: models.NewRelatedStrings(studioJSON.Aliases), Details: studioJSON.Details, Favorite: studioJSON.Favorite, @@ -229,6 +228,19 @@ func studioJSONtoStudio(studioJSON jsonschema.Studio) models.Studio { StashIDs: models.NewRelatedStashIDs(studioJSON.StashIDs), } + if len(studioJSON.URLs) > 0 { + newStudio.URLs = models.NewRelatedStrings(studioJSON.URLs) + } else { + urls := []string{} + if studioJSON.URL != "" { + urls = append(urls, studioJSON.URL) + } + + if len(urls) > 0 { + newStudio.URLs = models.NewRelatedStrings(urls) + } + } + if studioJSON.Rating != 0 { newStudio.Rating = &studioJSON.Rating } diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index b2fe0603a..8150c1ba7 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -1,11 +1,11 @@ fragment ScrapedStudioData on ScrapedStudio { stored_id name - url + urls parent { stored_id name - url + urls image remote_site_id } @@ -76,7 +76,7 @@ fragment ScrapedScenePerformerData on ScrapedPerformer { fragment ScrapedGroupStudioData on ScrapedStudio { stored_id name - url + urls } fragment ScrapedGroupData on ScrapedGroup { @@ -123,11 +123,11 @@ fragment ScrapedSceneGroupData on ScrapedGroup { fragment ScrapedSceneStudioData on ScrapedStudio { stored_id name - url + urls parent { stored_id name - url + urls image remote_site_id } diff --git a/ui/v2.5/graphql/data/studio.graphql b/ui/v2.5/graphql/data/studio.graphql index 25e776755..d4ba79887 100644 --- a/ui/v2.5/graphql/data/studio.graphql +++ b/ui/v2.5/graphql/data/studio.graphql @@ -2,10 +2,12 @@ fragment StudioData on Studio { id name url + urls parent_studio { id name url + urls image_path } child_studios { diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 46c10d73c..fc416320f 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -287,11 +287,6 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { const showAllCounts = uiConfig?.showChildStudioContent; - // make array of url so that it doesn't re-render on every change - const urls = useMemo(() => { - return studio?.url ? [studio.url] : []; - }, [studio.url]); - const studioImage = useMemo(() => { const existingPath = studio.image_path; if (isEditing) { @@ -471,7 +466,7 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { favorite={studio.favorite} onToggleFavorite={(v) => setFavorite(v)} /> - + diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx index 81e389765..4d5af043f 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx @@ -46,9 +46,28 @@ export const StudioDetailsPanel: React.FC = ({ ); } + function renderURLs() { + if (!studio.urls?.length) { + return; + } + + return ( +
    + {studio.urls.map((url) => ( +
  • + + {url} + +
  • + ))} +
+ ); + } + return (
+ = ({ const schema = yup.object({ name: yup.string().required(), - url: yup.string().ensure(), + urls: yup.array(yup.string().required()).defined(), details: yup.string().ensure(), parent_id: yup.string().required().nullable(), aliases: yupUniqueAliases(intl, "name"), @@ -60,7 +60,7 @@ export const StudioEditPanel: React.FC = ({ const initialValues = { id: studio.id, name: studio.name ?? "", - url: studio.url ?? "", + urls: studio.urls ?? [], details: studio.details ?? "", parent_id: studio.parent_studio?.id ?? null, aliases: studio.aliases ?? [], @@ -187,7 +187,7 @@ export const StudioEditPanel: React.FC = ({
{renderInputField("name")} {renderStringListField("aliases")} - {renderInputField("url")} + {renderStringListField("urls")} {renderInputField("details", "textarea")} {renderParentStudioField()} {renderTagsField()} diff --git a/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx b/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx index 249e34e74..1242adbc5 100644 --- a/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx @@ -84,6 +84,44 @@ const StudioDetails: React.FC = ({ ); } + function maybeRenderURLListField( + name: string, + text: string[] | null | undefined, + truncate: boolean = true + ) { + if (!text) return; + + return ( +
+
+ {!isNew && ( + + )} + + : + +
+
+
    + {text.map((t, i) => ( +
  • + + {truncate ? : t} + +
  • + ))} +
+
+
+ ); + } + function maybeRenderStashBoxLink() { if (!link) return; @@ -103,7 +141,7 @@ const StudioDetails: React.FC = ({
{maybeRenderField("name", studio.name, !isNew)} - {maybeRenderField("url", studio.url)} + {maybeRenderURLListField("urls", studio.urls)} {maybeRenderField("parent_studio", studio.parent?.name, false)} {maybeRenderStashBoxLink()}
@@ -191,7 +229,7 @@ const StudioModal: React.FC = ({ const studioData: GQL.StudioCreateInput = { name: studio.name, - url: studio.url, + urls: studio.urls, image: studio.image, parent_id: studio.parent?.stored_id, }; @@ -221,7 +259,7 @@ const StudioModal: React.FC = ({ parentData = { name: studio.parent?.name, - url: studio.parent?.url, + urls: studio.parent?.urls, image: studio.parent?.image, }; From 678b3de7c83f72a998ed1399ae340ad6c285adb6 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Sun, 9 Nov 2025 20:00:47 -0800 Subject: [PATCH 120/157] Feature: Support inputURL and inputHostname in scrapers (#6250) --- pkg/scraper/json.go | 22 +++++--- pkg/scraper/mapped.go | 26 +++++++++- pkg/scraper/xpath.go | 22 +++++--- .../src/docs/en/Manual/ScraperDevelopment.md | 50 ++++++++++++++++++- 4 files changed, 102 insertions(+), 18 deletions(-) diff --git a/pkg/scraper/json.go b/pkg/scraper/json.go index fc7eb17a2..9f479f1c2 100644 --- a/pkg/scraper/json.go +++ b/pkg/scraper/json.go @@ -80,7 +80,7 @@ func (s *jsonScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeCont return nil, err } - q := s.getJsonQuery(doc) + q := s.getJsonQuery(doc, u) // if these just return the return values from scraper.scrape* functions then // it ends up returning ScrapedContent(nil) rather than nil switch ty { @@ -140,7 +140,7 @@ func (s *jsonScraper) scrapeByName(ctx context.Context, name string, ty ScrapeCo return nil, err } - q := s.getJsonQuery(doc) + q := s.getJsonQuery(doc, url) q.setType(SearchQuery) var content []ScrapedContent @@ -192,7 +192,7 @@ func (s *jsonScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scen return nil, err } - q := s.getJsonQuery(doc) + q := s.getJsonQuery(doc, url) return scraper.scrapeScene(ctx, q) } @@ -227,7 +227,7 @@ func (s *jsonScraper) scrapeByFragment(ctx context.Context, input Input) (Scrape return nil, err } - q := s.getJsonQuery(doc) + q := s.getJsonQuery(doc, url) return scraper.scrapeScene(ctx, q) } @@ -251,7 +251,7 @@ func (s *jsonScraper) scrapeImageByImage(ctx context.Context, image *models.Imag return nil, err } - q := s.getJsonQuery(doc) + q := s.getJsonQuery(doc, url) return scraper.scrapeImage(ctx, q) } @@ -275,14 +275,15 @@ func (s *jsonScraper) scrapeGalleryByGallery(ctx context.Context, gallery *model return nil, err } - q := s.getJsonQuery(doc) + q := s.getJsonQuery(doc, url) return scraper.scrapeGallery(ctx, q) } -func (s *jsonScraper) getJsonQuery(doc string) *jsonQuery { +func (s *jsonScraper) getJsonQuery(doc string, url string) *jsonQuery { return &jsonQuery{ doc: doc, scraper: s, + url: url, } } @@ -290,6 +291,7 @@ type jsonQuery struct { doc string scraper *jsonScraper queryType QueryType + url string } func (q *jsonQuery) getType() QueryType { @@ -300,6 +302,10 @@ func (q *jsonQuery) setType(t QueryType) { q.queryType = t } +func (q *jsonQuery) getURL() string { + return q.url +} + func (q *jsonQuery) runQuery(selector string) ([]string, error) { value := gjson.Get(q.doc, selector) @@ -331,5 +337,5 @@ func (q *jsonQuery) subScrape(ctx context.Context, value string) mappedQuery { return nil } - return q.scraper.getJsonQuery(doc) + return q.scraper.getJsonQuery(doc, value) } diff --git a/pkg/scraper/mapped.go b/pkg/scraper/mapped.go index f89499176..3fac22ec3 100644 --- a/pkg/scraper/mapped.go +++ b/pkg/scraper/mapped.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math" + "net/url" "reflect" "regexp" "strconv" @@ -24,6 +25,7 @@ type mappedQuery interface { getType() QueryType setType(QueryType) subScrape(ctx context.Context, value string) mappedQuery + getURL() string } type commonMappedConfig map[string]string @@ -43,6 +45,22 @@ func (s mappedConfig) applyCommon(c commonMappedConfig, src string) string { return ret } +// extractHostname parses a URL string and returns the hostname. +// Returns empty string if the URL cannot be parsed. +func extractHostname(urlStr string) string { + if urlStr == "" { + return "" + } + + u, err := url.Parse(urlStr) + if err != nil { + logger.Warnf("Error parsing URL '%s': %s", urlStr, err.Error()) + return "" + } + + return u.Hostname() +} + type isMultiFunc func(key string) bool func (s mappedConfig) process(ctx context.Context, q mappedQuery, common commonMappedConfig, isMulti isMultiFunc) mappedResults { @@ -53,10 +71,16 @@ func (s mappedConfig) process(ctx context.Context, q mappedQuery, common commonM if attrConfig.Fixed != "" { // TODO - not sure if this needs to set _all_ indexes for the key const i = 0 - ret = ret.setSingleValue(i, k, attrConfig.Fixed) + // Support {inputURL} and {inputHostname} placeholders in fixed values + value := strings.ReplaceAll(attrConfig.Fixed, "{inputURL}", q.getURL()) + value = strings.ReplaceAll(value, "{inputHostname}", extractHostname(q.getURL())) + ret = ret.setSingleValue(i, k, value) } else { selector := attrConfig.Selector selector = s.applyCommon(common, selector) + // Support {inputURL} and {inputHostname} placeholders in selectors + selector = strings.ReplaceAll(selector, "{inputURL}", q.getURL()) + selector = strings.ReplaceAll(selector, "{inputHostname}", extractHostname(q.getURL())) found, err := q.runQuery(selector) if err != nil { diff --git a/pkg/scraper/xpath.go b/pkg/scraper/xpath.go index 9993aa3ff..e042c861a 100644 --- a/pkg/scraper/xpath.go +++ b/pkg/scraper/xpath.go @@ -61,7 +61,7 @@ func (s *xpathScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeCon return nil, err } - q := s.getXPathQuery(doc) + q := s.getXPathQuery(doc, u) // if these just return the return values from scraper.scrape* functions then // it ends up returning ScrapedContent(nil) rather than nil switch ty { @@ -121,7 +121,7 @@ func (s *xpathScraper) scrapeByName(ctx context.Context, name string, ty ScrapeC return nil, err } - q := s.getXPathQuery(doc) + q := s.getXPathQuery(doc, url) q.setType(SearchQuery) var content []ScrapedContent @@ -171,7 +171,7 @@ func (s *xpathScraper) scrapeSceneByScene(ctx context.Context, scene *models.Sce return nil, err } - q := s.getXPathQuery(doc) + q := s.getXPathQuery(doc, url) return scraper.scrapeScene(ctx, q) } @@ -206,7 +206,7 @@ func (s *xpathScraper) scrapeByFragment(ctx context.Context, input Input) (Scrap return nil, err } - q := s.getXPathQuery(doc) + q := s.getXPathQuery(doc, url) return scraper.scrapeScene(ctx, q) } @@ -230,7 +230,7 @@ func (s *xpathScraper) scrapeGalleryByGallery(ctx context.Context, gallery *mode return nil, err } - q := s.getXPathQuery(doc) + q := s.getXPathQuery(doc, url) return scraper.scrapeGallery(ctx, q) } @@ -254,7 +254,7 @@ func (s *xpathScraper) scrapeImageByImage(ctx context.Context, image *models.Ima return nil, err } - q := s.getXPathQuery(doc) + q := s.getXPathQuery(doc, url) return scraper.scrapeImage(ctx, q) } @@ -277,10 +277,11 @@ func (s *xpathScraper) loadURL(ctx context.Context, url string) (*html.Node, err return ret, err } -func (s *xpathScraper) getXPathQuery(doc *html.Node) *xpathQuery { +func (s *xpathScraper) getXPathQuery(doc *html.Node, url string) *xpathQuery { return &xpathQuery{ doc: doc, scraper: s, + url: url, } } @@ -288,6 +289,7 @@ type xpathQuery struct { doc *html.Node scraper *xpathScraper queryType QueryType + url string } func (q *xpathQuery) getType() QueryType { @@ -298,6 +300,10 @@ func (q *xpathQuery) setType(t QueryType) { q.queryType = t } +func (q *xpathQuery) getURL() string { + return q.url +} + func (q *xpathQuery) runQuery(selector string) ([]string, error) { found, err := htmlquery.QueryAll(q.doc, selector) if err != nil { @@ -346,5 +352,5 @@ func (q *xpathQuery) subScrape(ctx context.Context, value string) mappedQuery { return nil } - return q.scraper.getXPathQuery(doc) + return q.scraper.getXPathQuery(doc, value) } diff --git a/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md b/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md index 0ee1c0880..bd87d71ab 100644 --- a/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md +++ b/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md @@ -325,10 +325,58 @@ Alternatively, an attribute value may be set to a fixed value, rather than scrap ```yaml performer: - Gender: + Gender: fixed: Female ``` +### Input URL placeholders + +The `{inputURL}` and `{inputHostname}` placeholders can be used in both `fixed` values and `selector` expressions to access information about the original URL that was used to scrape the content. + +#### {inputURL} + +The `{inputURL}` placeholder provides access to the full URL. This is useful when you want to return or reference the source URL as part of the scraped data. + +For example: + +```yaml +scene: + URL: + fixed: "{inputURL}" + Title: + selector: //h1[@class="title"] +``` + +When scraping from `https://example.com/scene/12345`, the `{inputURL}` placeholder will be replaced with `https://example.com/scene/12345`. + +#### {inputHostname} + +The `{inputHostname}` placeholder extracts just the hostname from the URL. This is useful when you need to reference the domain without manually parsing the URL. + +For example: + +```yaml +scene: + Studio: + fixed: "{inputHostname}" + Details: + selector: //div[@data-domain="{inputHostname}"]//p[@class="description"] +``` + +When scraping from `https://example.com/scene/12345`, the `{inputHostname}` placeholder will be replaced with `example.com`. + +These placeholders can also be used within selectors for more advanced use cases: + +```yaml +scene: + Details: + selector: //div[@data-url="{inputURL}"]//p[@class="description"] + Site: + selector: //div[@data-host="{inputHostname}"]//span[@class="site-name"] +``` + +> **Note:** These placeholders represent the actual URL used to fetch the content, after any URL replacements have been applied. + ### Common fragments The `common` field is used to configure selector fragments that can be referenced in the selector strings. These are key-value pairs where the key is the string to reference the fragment, and the value is the string that the fragment will be replaced with. For example: From 5e34df7b7bba5d96802344986935c2dadc111560 Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Tue, 11 Nov 2025 22:09:14 -0500 Subject: [PATCH 121/157] [ui] add playsInline to every image/video elem (#6259) --- ui/v2.5/src/components/Images/ImageCard.tsx | 1 + ui/v2.5/src/components/Images/ImageDetails/Image.tsx | 1 + ui/v2.5/src/components/Images/ImageWallItem.tsx | 1 + ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx | 1 + ui/v2.5/src/components/Scenes/SceneWallPanel.tsx | 1 + ui/v2.5/src/hooks/Lightbox/Lightbox.tsx | 1 + 6 files changed, 6 insertions(+) diff --git a/ui/v2.5/src/components/Images/ImageCard.tsx b/ui/v2.5/src/components/Images/ImageCard.tsx index d530b253e..9a8c86a10 100644 --- a/ui/v2.5/src/components/Images/ImageCard.tsx +++ b/ui/v2.5/src/components/Images/ImageCard.tsx @@ -169,6 +169,7 @@ export const ImageCard: React.FC = ( = ({ image }) => { = ( = ({ React.createElement(image.paths.preview != "" ? "video" : "img", { loop: image.paths.preview != "", autoPlay: image.paths.preview != "", + playsInline: image.paths.preview != "", src: image.paths.preview != "" ? image.paths.preview ?? "" From b2c8f0958509603b89e88e6de933cad890024df3 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Tue, 11 Nov 2025 21:58:30 -0800 Subject: [PATCH 122/157] add tagger shortcut (#6261) --- ui/v2.5/src/components/List/ListViewOptions.tsx | 6 ++++++ ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md | 1 + 2 files changed, 7 insertions(+) diff --git a/ui/v2.5/src/components/List/ListViewOptions.tsx b/ui/v2.5/src/components/List/ListViewOptions.tsx index 1ea928983..04adcaa74 100644 --- a/ui/v2.5/src/components/List/ListViewOptions.tsx +++ b/ui/v2.5/src/components/List/ListViewOptions.tsx @@ -84,11 +84,17 @@ export const ListViewOptions: React.FC = ({ onSetDisplayMode(DisplayMode.Wall); } }); + Mousetrap.bind("v t", () => { + if (displayModeOptions.includes(DisplayMode.Tagger)) { + onSetDisplayMode(DisplayMode.Tagger); + } + }); return () => { Mousetrap.unbind("v g"); Mousetrap.unbind("v l"); Mousetrap.unbind("v w"); + Mousetrap.unbind("v t"); }; }); diff --git a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md index 55b52bdf4..69006d429 100644 --- a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md +++ b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md @@ -30,6 +30,7 @@ | `v g` | Set view to grid | | `v l` | Set view to list | | `v w` | Set view to wall | +| `v t` | Set view to tagger | | `+` | Increase zoom slider | | `-` | Decrease zoom slider | | `←` | Previous page of results | From a08d2e258a7346af70e4328101faff27d4e65878 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Wed, 12 Nov 2025 15:14:04 -0800 Subject: [PATCH 123/157] Feature: Add Various Scraper Fields (#6249) * Support aliases in stashbox studio query --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/schema/types/scraper.graphql | 4 ++++ graphql/stash-box/query.graphql | 1 + pkg/models/model_scraped_item.go | 22 ++++++++++++++++++ pkg/stashbox/graphql/generated_client.go | 23 +++++++++++++++---- pkg/stashbox/studio.go | 4 ++++ ui/v2.5/graphql/data/scrapers.graphql | 20 ++++++++++++++++ .../Shared/ScrapeDialog/ScrapedObjectsRow.tsx | 7 ++++-- .../components/Tagger/scenes/StudioModal.tsx | 13 +++++++++++ .../src/docs/en/Manual/ScraperDevelopment.md | 19 ++++++++++++++- 9 files changed, 105 insertions(+), 8 deletions(-) diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index a8e1fccb0..84b8b5c85 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -59,6 +59,10 @@ type ScrapedStudio { urls: [String!] parent: ScrapedStudio image: String + details: String + "Aliases must be comma-delimited to be parsed correctly" + aliases: String + tags: [ScrapedTag!] remote_site_id: String } diff --git a/graphql/stash-box/query.graphql b/graphql/stash-box/query.graphql index f7528e728..4fa023070 100644 --- a/graphql/stash-box/query.graphql +++ b/graphql/stash-box/query.graphql @@ -13,6 +13,7 @@ fragment ImageFragment on Image { fragment StudioFragment on Studio { name id + aliases urls { ...URLFragment } diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index a06463134..dc400ce4e 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -19,6 +19,9 @@ type ScrapedStudio struct { Parent *ScrapedStudio `json:"parent"` Image *string `json:"image"` Images []string `json:"images"` + Details *string `json:"details"` + Aliases *string `json:"aliases"` + Tags []*ScrapedTag `json:"tags"` RemoteSiteID *string `json:"remote_site_id"` } @@ -55,6 +58,14 @@ func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *Stu } } + if s.Details != nil && !excluded["details"] { + ret.Details = *s.Details + } + + if s.Aliases != nil && !excluded["aliases"] { + ret.Aliases = NewRelatedStrings(stringslice.FromString(*s.Aliases, ",")) + } + if s.Parent != nil && s.Parent.StoredID != nil && !excluded["parent"] && !excluded["parent_studio"] { parentId, _ := strconv.Atoi(*s.Parent.StoredID) ret.ParentID = &parentId @@ -108,6 +119,17 @@ func (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[strin } } + if s.Details != nil && !excluded["details"] { + ret.Details = NewOptionalString(*s.Details) + } + + if s.Aliases != nil && !excluded["aliases"] { + ret.Aliases = &UpdateStrings{ + Values: stringslice.FromString(*s.Aliases, ","), + Mode: RelationshipUpdateModeSet, + } + } + if s.Parent != nil && !excluded["parent"] { if s.Parent.StoredID != nil { parentID, _ := strconv.Atoi(*s.Parent.StoredID) diff --git a/pkg/stashbox/graphql/generated_client.go b/pkg/stashbox/graphql/generated_client.go index f0224900f..90553b14a 100644 --- a/pkg/stashbox/graphql/generated_client.go +++ b/pkg/stashbox/graphql/generated_client.go @@ -82,11 +82,12 @@ func (t *ImageFragment) GetHeight() int { } type StudioFragment struct { - Name string "json:\"name\" graphql:\"name\"" - ID string "json:\"id\" graphql:\"id\"" - Urls []*URLFragment "json:\"urls\" graphql:\"urls\"" - Parent *StudioFragment_Parent "json:\"parent,omitempty\" graphql:\"parent\"" - Images []*ImageFragment "json:\"images\" graphql:\"images\"" + Name string "json:\"name\" graphql:\"name\"" + ID string "json:\"id\" graphql:\"id\"" + Aliases []string "json:\"aliases\" graphql:\"aliases\"" + Urls []*URLFragment "json:\"urls\" graphql:\"urls\"" + Parent *StudioFragment_Parent "json:\"parent,omitempty\" graphql:\"parent\"" + Images []*ImageFragment "json:\"images\" graphql:\"images\"" } func (t *StudioFragment) GetName() string { @@ -101,6 +102,12 @@ func (t *StudioFragment) GetID() string { } return t.ID } +func (t *StudioFragment) GetAliases() []string { + if t == nil { + t = &StudioFragment{} + } + return t.Aliases +} func (t *StudioFragment) GetUrls() []*URLFragment { if t == nil { t = &StudioFragment{} @@ -845,6 +852,7 @@ fragment ImageFragment on Image { fragment StudioFragment on Studio { name id + aliases urls { ... URLFragment } @@ -980,6 +988,7 @@ fragment ImageFragment on Image { fragment StudioFragment on Studio { name id + aliases urls { ... URLFragment } @@ -1115,6 +1124,7 @@ fragment ImageFragment on Image { fragment StudioFragment on Studio { name id + aliases urls { ... URLFragment } @@ -1250,6 +1260,7 @@ fragment ImageFragment on Image { fragment StudioFragment on Studio { name id + aliases urls { ... URLFragment } @@ -1543,6 +1554,7 @@ fragment ImageFragment on Image { fragment StudioFragment on Studio { name id + aliases urls { ... URLFragment } @@ -1641,6 +1653,7 @@ const FindStudioDocument = `query FindStudio ($id: ID, $name: String) { fragment StudioFragment on Studio { name id + aliases urls { ... URLFragment } diff --git a/pkg/stashbox/studio.go b/pkg/stashbox/studio.go index a0e9a6ea6..8934972f2 100644 --- a/pkg/stashbox/studio.go +++ b/pkg/stashbox/studio.go @@ -2,6 +2,7 @@ package stashbox import ( "context" + "strings" "github.com/google/uuid" "github.com/stashapp/stash/pkg/models" @@ -63,8 +64,11 @@ func studioFragmentToScrapedStudio(s graphql.StudioFragment) *models.ScrapedStud images = append(images, image.URL) } + aliases := strings.Join(s.Aliases, ", ") + st := &models.ScrapedStudio{ Name: s.Name, + Aliases: &aliases, Images: images, RemoteSiteID: &s.ID, } diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index 8150c1ba7..25c1036d8 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -7,9 +7,19 @@ fragment ScrapedStudioData on ScrapedStudio { name urls image + details + aliases + tags { + ...ScrapedSceneTagData + } remote_site_id } image + details + aliases + tags { + ...ScrapedSceneTagData + } remote_site_id } @@ -129,9 +139,19 @@ fragment ScrapedSceneStudioData on ScrapedStudio { name urls image + details + aliases + tags { + ...ScrapedSceneTagData + } remote_site_id } image + details + aliases + tags { + ...ScrapedSceneTagData + } remote_site_id } diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx index 3d7bbe4ad..f3cff8d4e 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx @@ -41,7 +41,9 @@ export const ScrapedStudioRow: React.FC = ({ const value = resultValue ? [resultValue] : []; const selectValue = value.map((p) => { - const aliases: string[] = []; + const aliases: string[] = p.aliases + ? p.aliases.split(",").map((a) => a.trim()) + : []; return { id: p.stored_id ?? "", name: p.name ?? "", @@ -55,10 +57,11 @@ export const ScrapedStudioRow: React.FC = ({ isDisabled={!isNew} onSelect={(items) => { if (onChangeFn) { - const { id, ...data } = items[0]; + const { id, aliases, ...data } = items[0]; onChangeFn({ ...data, stored_id: id, + aliases: aliases?.join(", "), }); } }} diff --git a/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx b/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx index 1242adbc5..e01c40881 100644 --- a/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx @@ -142,6 +142,9 @@ const StudioDetails: React.FC = ({
{maybeRenderField("name", studio.name, !isNew)} {maybeRenderURLListField("urls", studio.urls)} + {maybeRenderField("details", studio.details)} + {maybeRenderField("aliases", studio.aliases)} + {maybeRenderField("tags", studio.tags?.map((t) => t.name).join(", "))} {maybeRenderField("parent_studio", studio.parent?.name, false)} {maybeRenderStashBoxLink()}
@@ -232,6 +235,11 @@ const StudioModal: React.FC = ({ urls: studio.urls, image: studio.image, parent_id: studio.parent?.stored_id, + details: studio.details, + aliases: studio.aliases?.split(",").map((a) => a.trim()), + tag_ids: studio.tags?.map((t) => t.stored_id).filter((id) => id) as + | string[] + | undefined, }; // stashid handling code @@ -261,6 +269,11 @@ const StudioModal: React.FC = ({ name: studio.parent?.name, urls: studio.parent?.urls, image: studio.parent?.image, + details: studio.parent?.details, + aliases: studio.parent?.aliases?.split(",").map((a) => a.trim()), + tag_ids: studio.parent?.tags + ?.map((t) => t.stored_id) + .filter((id) => id) as string[] | undefined, }; // stashid handling code diff --git a/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md b/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md index bd87d71ab..1f52028f8 100644 --- a/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md +++ b/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md @@ -739,7 +739,11 @@ xPathScrapers: URL: $performer/@href Studio: Name: $studio - URL: $studio/@href + URL: $studio/@href + Details: //div[@class="studioDescription"] + Aliases: //div[@class="studioAliases"]/span + Tags: + Name: //div[@class="studioTags"]/a ``` See also [#333](https://github.com/stashapp/stash/pull/333) for more examples. @@ -822,6 +826,11 @@ jsonScrapers: Name: data.performers.#.name Studio: Name: data.site.name + URL: data.site.url + Details: data.site.description + Aliases: data.site.aliases + Tags: + Name: data.site.tags.#.name Tags: Name: data.tags.#.tag @@ -839,6 +848,11 @@ jsonScrapers: Name: $data.performers.#.name Studio: Name: $data.site.name + URL: $data.site.url + Details: $data.site.description + Aliases: $data.site.aliases + Tags: + Name: $data.site.tags.#.name Tags: Name: $data.tags.#.tag driver: @@ -955,7 +969,10 @@ URLs ### Studio ``` +Aliases +Details Name +Tags (see Tag fields) URL ``` From c99825a453c240ed0d9c45e420f8db78e3604b91 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Wed, 12 Nov 2025 19:24:09 -0800 Subject: [PATCH 124/157] Feature: Tag StashID support (#6255) --- graphql/schema/types/scraper.graphql | 2 + graphql/schema/types/tag.graphql | 3 + internal/api/resolver_model_tag.go | 10 +++ internal/api/resolver_mutation_stash_box.go | 8 +++ internal/api/resolver_mutation_tag.go | 16 +++++ internal/identify/scene.go | 7 +- pkg/match/scraped.go | 22 ++++++- pkg/models/jsonschema/tag.go | 22 ++++--- pkg/models/mocks/TagReaderWriter.go | 46 +++++++++++++ pkg/models/model_scraped_item.go | 23 ++++++- pkg/models/model_tag.go | 14 +++- pkg/models/repository_tag.go | 2 + pkg/scraper/tag.go | 3 +- pkg/sqlite/anonymise.go | 1 + pkg/sqlite/database.go | 2 +- pkg/sqlite/migrations/74_tag_stash_ids.up.sql | 7 ++ pkg/sqlite/stash_id_test.go | 5 +- pkg/sqlite/tables.go | 8 +++ pkg/sqlite/tag.go | 66 ++++++++++++++++++- pkg/sqlite/tag_test.go | 60 +++++++++++++++++ pkg/stashbox/scene.go | 20 ++++-- pkg/tag/export.go | 10 +++ pkg/tag/export_test.go | 7 ++ pkg/tag/import.go | 1 + ui/v2.5/graphql/data/scrapers.graphql | 1 + ui/v2.5/graphql/data/tag.graphql | 5 ++ ui/v2.5/src/components/Shared/StashID.tsx | 2 +- .../Tagger/scenes/StashSearchResult.tsx | 12 ++++ .../Tags/TagDetails/TagDetailsPanel.tsx | 22 +++++++ .../Tags/TagDetails/TagEditPanel.tsx | 14 ++-- 30 files changed, 387 insertions(+), 34 deletions(-) create mode 100644 pkg/sqlite/migrations/74_tag_stash_ids.up.sql diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index 84b8b5c85..2c13872f3 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -71,6 +71,8 @@ type ScrapedTag { "Set if tag matched" stored_id: ID name: String! + "Remote site ID, if applicable" + remote_site_id: String } type ScrapedScene { diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index 504f23e3d..8424ab92a 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -9,6 +9,7 @@ type Tag { created_at: Time! updated_at: Time! favorite: Boolean! + stash_ids: [StashID!]! image_path: String # Resolver scene_count(depth: Int): Int! # Resolver scene_marker_count(depth: Int): Int! # Resolver @@ -35,6 +36,7 @@ input TagCreateInput { favorite: Boolean "This should be a URL or a base64 encoded data URL" image: String + stash_ids: [StashIDInput!] parent_ids: [ID!] child_ids: [ID!] @@ -51,6 +53,7 @@ input TagUpdateInput { favorite: Boolean "This should be a URL or a base64 encoded data URL" image: String + stash_ids: [StashIDInput!] parent_ids: [ID!] child_ids: [ID!] diff --git a/internal/api/resolver_model_tag.go b/internal/api/resolver_model_tag.go index 14237d2fe..deae41f21 100644 --- a/internal/api/resolver_model_tag.go +++ b/internal/api/resolver_model_tag.go @@ -54,6 +54,16 @@ func (r *tagResolver) Aliases(ctx context.Context, obj *models.Tag) (ret []strin return obj.Aliases.List(), nil } +func (r *tagResolver) StashIds(ctx context.Context, obj *models.Tag) ([]*models.StashID, error) { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadStashIDs(ctx, r.repository.Tag) + }); err != nil { + return nil, err + } + + return stashIDsSliceToPtrSlice(obj.StashIDs.List()), nil +} + func (r *tagResolver) SceneCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = scene.CountByTagID(ctx, r.repository.Scene, obj.ID, depth) diff --git a/internal/api/resolver_mutation_stash_box.go b/internal/api/resolver_mutation_stash_box.go index bbfe8b854..4026667eb 100644 --- a/internal/api/resolver_mutation_stash_box.go +++ b/internal/api/resolver_mutation_stash_box.go @@ -153,6 +153,14 @@ func (r *mutationResolver) makeSceneDraft(ctx context.Context, s *models.Scene, return nil, err } + // Load StashIDs for tags + tqb := r.repository.Tag + for _, t := range draft.Tags { + if err := t.LoadStashIDs(ctx, tqb); err != nil { + return nil, err + } + } + draft.Cover = cover return draft, nil diff --git a/internal/api/resolver_mutation_tag.go b/internal/api/resolver_mutation_tag.go index 1e8b6066a..05d756acf 100644 --- a/internal/api/resolver_mutation_tag.go +++ b/internal/api/resolver_mutation_tag.go @@ -39,6 +39,14 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput) newTag.Description = translator.string(input.Description) newTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag) + var stashIDInputs models.StashIDInputs + for _, sid := range input.StashIds { + if sid != nil { + stashIDInputs = append(stashIDInputs, *sid) + } + } + newTag.StashIDs = models.NewRelatedStashIDs(stashIDInputs.ToStashIDs()) + var err error newTag.ParentIDs, err = translator.relatedIds(input.ParentIds) @@ -110,6 +118,14 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) updatedTag.Aliases = translator.updateStrings(input.Aliases, "aliases") + var updateStashIDInputs models.StashIDInputs + for _, sid := range input.StashIds { + if sid != nil { + updateStashIDInputs = append(updateStashIDInputs, *sid) + } + } + updatedTag.StashIDs = translator.updateStashIDs(updateStashIDInputs, "stash_ids") + updatedTag.ParentIDs, err = translator.updateIds(input.ParentIds, "parent_ids") if err != nil { return nil, fmt.Errorf("converting parent tag ids: %w", err) diff --git a/internal/identify/scene.go b/internal/identify/scene.go index 847a140c5..789674693 100644 --- a/internal/identify/scene.go +++ b/internal/identify/scene.go @@ -153,6 +153,8 @@ func (g sceneRelationships) tags(ctx context.Context) ([]int, error) { tagIDs = originalTagIDs } + endpoint := g.result.source.RemoteSite + for _, t := range scraped { if t.StoredID != nil { // existing tag, just add it @@ -163,10 +165,9 @@ func (g sceneRelationships) tags(ctx context.Context) ([]int, error) { tagIDs = sliceutil.AppendUnique(tagIDs, int(tagID)) } else if createMissing { - newTag := models.NewTag() - newTag.Name = t.Name + newTag := t.ToTag(endpoint, nil) - err := g.tagCreator.Create(ctx, &newTag) + err := g.tagCreator.Create(ctx, newTag) if err != nil { return nil, fmt.Errorf("error creating tag: %w", err) } diff --git a/pkg/match/scraped.go b/pkg/match/scraped.go index b66f39a35..d3039f4c6 100644 --- a/pkg/match/scraped.go +++ b/pkg/match/scraped.go @@ -45,7 +45,7 @@ func (r SceneRelationships) MatchRelationships(ctx context.Context, s *models.Sc } for _, t := range s.Tags { - err := ScrapedTag(ctx, r.TagFinder, t) + err := ScrapedTag(ctx, r.TagFinder, t, endpoint) if err != nil { return err } @@ -190,11 +190,29 @@ func ScrapedGroup(ctx context.Context, qb GroupNamesFinder, storedID *string, na // 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) error { +func ScrapedTag(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error { if s.StoredID != nil { return nil } + // Check if a tag with the StashID already exists + if stashBoxEndpoint != "" && s.RemoteSiteID != nil { + if finder, ok := qb.(models.TagFinder); ok { + tags, err := finder.FindByStashID(ctx, models.StashID{ + StashID: *s.RemoteSiteID, + Endpoint: stashBoxEndpoint, + }) + if err != nil { + return err + } + if len(tags) > 0 { + id := strconv.Itoa(tags[0].ID) + s.StoredID = &id + return nil + } + } + } + t, err := tag.ByName(ctx, qb, s.Name) if err != nil { diff --git a/pkg/models/jsonschema/tag.go b/pkg/models/jsonschema/tag.go index ed2bc1c9c..faab1bfb2 100644 --- a/pkg/models/jsonschema/tag.go +++ b/pkg/models/jsonschema/tag.go @@ -6,20 +6,22 @@ import ( jsoniter "github.com/json-iterator/go" "github.com/stashapp/stash/pkg/fsutil" + "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" ) type Tag struct { - Name string `json:"name,omitempty"` - SortName string `json:"sort_name,omitempty"` - Description string `json:"description,omitempty"` - Favorite bool `json:"favorite,omitempty"` - Aliases []string `json:"aliases,omitempty"` - Image string `json:"image,omitempty"` - Parents []string `json:"parents,omitempty"` - IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` - CreatedAt json.JSONTime `json:"created_at,omitempty"` - UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + Name string `json:"name,omitempty"` + SortName string `json:"sort_name,omitempty"` + Description string `json:"description,omitempty"` + Favorite bool `json:"favorite,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Image string `json:"image,omitempty"` + Parents []string `json:"parents,omitempty"` + IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` + StashIDs []models.StashID `json:"stash_ids,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` } func (s Tag) Filename() string { diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index a285b97bf..ac6b10584 100644 --- a/pkg/models/mocks/TagReaderWriter.go +++ b/pkg/models/mocks/TagReaderWriter.go @@ -427,6 +427,29 @@ func (_m *TagReaderWriter) FindBySceneMarkerID(ctx context.Context, sceneMarkerI return r0, r1 } +// FindByStashID provides a mock function with given fields: ctx, stashID +func (_m *TagReaderWriter) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Tag, error) { + ret := _m.Called(ctx, stashID) + + var r0 []*models.Tag + if rf, ok := ret.Get(0).(func(context.Context, models.StashID) []*models.Tag); ok { + r0 = rf(ctx, stashID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Tag) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, models.StashID) error); ok { + r1 = rf(ctx, stashID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindByStudioID provides a mock function with given fields: ctx, studioID func (_m *TagReaderWriter) FindByStudioID(ctx context.Context, studioID int) ([]*models.Tag, error) { ret := _m.Called(ctx, studioID) @@ -565,6 +588,29 @@ func (_m *TagReaderWriter) GetParentIDs(ctx context.Context, relatedID int) ([]i return r0, r1 } +// GetStashIDs provides a mock function with given fields: ctx, relatedID +func (_m *TagReaderWriter) GetStashIDs(ctx context.Context, relatedID int) ([]models.StashID, error) { + ret := _m.Called(ctx, relatedID) + + var r0 []models.StashID + if rf, ok := ret.Get(0).(func(context.Context, int) []models.StashID); ok { + r0 = rf(ctx, relatedID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.StashID) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, relatedID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // HasImage provides a mock function with given fields: ctx, tagID func (_m *TagReaderWriter) HasImage(ctx context.Context, tagID int) (bool, error) { ret := _m.Called(ctx, tagID) diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index dc400ce4e..131f08be1 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -447,12 +447,31 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, type ScrapedTag struct { // Set if tag matched - StoredID *string `json:"stored_id"` - Name string `json:"name"` + StoredID *string `json:"stored_id"` + Name string `json:"name"` + RemoteSiteID *string `json:"remote_site_id"` } func (ScrapedTag) IsScrapedContent() {} +func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag { + currentTime := time.Now() + ret := NewTag() + ret.Name = t.Name + + if t.RemoteSiteID != nil && endpoint != "" { + ret.StashIDs = NewRelatedStashIDs([]StashID{ + { + Endpoint: endpoint, + StashID: *t.RemoteSiteID, + UpdatedAt: currentTime, + }, + }) + } + + return &ret +} + func ScrapedTagSortFunction(a, b *ScrapedTag) int { return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) } diff --git a/pkg/models/model_tag.go b/pkg/models/model_tag.go index 0d845750f..4cd038f7e 100644 --- a/pkg/models/model_tag.go +++ b/pkg/models/model_tag.go @@ -15,9 +15,10 @@ type Tag struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - Aliases RelatedStrings `json:"aliases"` - ParentIDs RelatedIDs `json:"parent_ids"` - ChildIDs RelatedIDs `json:"tag_ids"` + Aliases RelatedStrings `json:"aliases"` + ParentIDs RelatedIDs `json:"parent_ids"` + ChildIDs RelatedIDs `json:"tag_ids"` + StashIDs RelatedStashIDs `json:"stash_ids"` } func NewTag() Tag { @@ -46,6 +47,12 @@ func (s *Tag) LoadChildIDs(ctx context.Context, l TagRelationLoader) error { }) } +func (s *Tag) LoadStashIDs(ctx context.Context, l StashIDLoader) error { + return s.StashIDs.load(func() ([]StashID, error) { + return l.GetStashIDs(ctx, s.ID) + }) +} + type TagPartial struct { Name OptionalString SortName OptionalString @@ -58,6 +65,7 @@ type TagPartial struct { Aliases *UpdateStrings ParentIDs *UpdateIDs ChildIDs *UpdateIDs + StashIDs *UpdateStashIDs } func NewTagPartial() TagPartial { diff --git a/pkg/models/repository_tag.go b/pkg/models/repository_tag.go index 2b073cae0..a7f828f0b 100644 --- a/pkg/models/repository_tag.go +++ b/pkg/models/repository_tag.go @@ -25,6 +25,7 @@ type TagFinder interface { 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) } // TagQueryer provides methods to query tags. @@ -87,6 +88,7 @@ type TagReader interface { AliasLoader TagRelationLoader + StashIDLoader All(ctx context.Context) ([]*Tag, error) GetImage(ctx context.Context, tagID int) ([]byte, error) diff --git a/pkg/scraper/tag.go b/pkg/scraper/tag.go index c26aa855e..14f02e397 100644 --- a/pkg/scraper/tag.go +++ b/pkg/scraper/tag.go @@ -15,7 +15,8 @@ func postProcessTags(ctx context.Context, tqb models.TagQueryer, scrapedTags []* ret = make([]*models.ScrapedTag, 0, len(scrapedTags)) for _, t := range scrapedTags { - err := match.ScrapedTag(ctx, tqb, t) + // Pass empty string for endpoint since this is used by general scrapers, not just stash-box + err := match.ScrapedTag(ctx, tqb, t, "") if err != nil { return nil, err } diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index ba376d785..764f569c0 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -102,6 +102,7 @@ func (db *Anonymiser) deleteStashIDs() error { func() error { return db.truncateTable("scene_stash_ids") }, func() error { return db.truncateTable("studio_stash_ids") }, func() error { return db.truncateTable("performer_stash_ids") }, + func() error { return db.truncateTable("tag_stash_ids") }, }) } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index b846efaf4..29e39270d 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 73 +var appSchemaVersion uint = 74 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/74_tag_stash_ids.up.sql b/pkg/sqlite/migrations/74_tag_stash_ids.up.sql new file mode 100644 index 000000000..c281149c7 --- /dev/null +++ b/pkg/sqlite/migrations/74_tag_stash_ids.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE `tag_stash_ids` ( + `tag_id` integer, + `endpoint` varchar(255), + `stash_id` varchar(36), + `updated_at` datetime not null default '1970-01-01T00:00:00Z', + foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE +); \ No newline at end of file diff --git a/pkg/sqlite/stash_id_test.go b/pkg/sqlite/stash_id_test.go index 10949b475..a273c7960 100644 --- a/pkg/sqlite/stash_id_test.go +++ b/pkg/sqlite/stash_id_test.go @@ -27,8 +27,9 @@ func testStashIDReaderWriter(ctx context.Context, t *testing.T, r stashIDReaderW const stashIDStr = "stashID" const endpoint = "endpoint" stashID := models.StashID{ - StashID: stashIDStr, - Endpoint: endpoint, + StashID: stashIDStr, + Endpoint: endpoint, + UpdatedAt: epochTime, } // update stash ids and ensure was updated diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index b28dd777c..7cddf25cc 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -47,6 +47,7 @@ var ( tagsAliasesJoinTable = goqu.T(tagAliasesTable) tagRelationsJoinTable = goqu.T(tagRelationsTable) + tagsStashIDsJoinTable = goqu.T("tag_stash_ids") ) var ( @@ -375,6 +376,13 @@ var ( } tagsChildTagsTableMgr = *tagsParentTagsTableMgr.invert() + + tagsStashIDsTableMgr = &stashIDTable{ + table: table{ + table: tagsStashIDsJoinTable, + idColumn: tagsStashIDsJoinTable.Col(tagIDColumn), + }, + } ) var ( diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 87ec01f5d..977ac0433 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -101,7 +101,8 @@ func (r *tagRowRecord) fromPartial(o models.TagPartial) { type tagRepositoryType struct { repository - aliases stringRepository + aliases stringRepository + stashIDs stashIDRepository scenes joinRepository images joinRepository @@ -121,6 +122,12 @@ var ( }, stringColumn: tagAliasColumn, }, + stashIDs: stashIDRepository{ + repository{ + tableName: "tag_stash_ids", + idColumn: tagIDColumn, + }, + }, scenes: joinRepository{ repository: repository{ tableName: scenesTagsTable, @@ -199,6 +206,12 @@ func (qb *TagStore) Create(ctx context.Context, newObject *models.Tag) error { } } + if newObject.StashIDs.Loaded() { + if err := tagsStashIDsTableMgr.insertJoins(ctx, id, newObject.StashIDs.List()); err != nil { + return err + } + } + updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) @@ -242,6 +255,12 @@ func (qb *TagStore) UpdatePartial(ctx context.Context, id int, partial models.Ta } } + if partial.StashIDs != nil { + if err := tagsStashIDsTableMgr.modifyJoins(ctx, id, partial.StashIDs.StashIDs, partial.StashIDs.Mode); err != nil { + return nil, err + } + } + return qb.find(ctx, id) } @@ -271,6 +290,12 @@ func (qb *TagStore) Update(ctx context.Context, updatedObject *models.Tag) error } } + if updatedObject.StashIDs.Loaded() { + if err := tagsStashIDsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.StashIDs.List()); err != nil { + return err + } + } + return nil } @@ -509,6 +534,24 @@ func (qb *TagStore) FindByNames(ctx context.Context, names []string, nocase bool return ret, 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), + tagsStashIDsJoinTable.Col("endpoint").Eq(stashID.Endpoint), + ) + + idsQuery := qb.selectDataset().Where( + qb.table().Col(idColumn).In(sq), + ) + + ret, err := qb.getMany(ctx, idsQuery) + if err != nil { + return nil, fmt.Errorf("getting tags for stash ID %s: %w", stashID.StashID, err) + } + + return ret, nil +} + func (qb *TagStore) GetParentIDs(ctx context.Context, relatedID int) ([]int, error) { return tagsParentTagsTableMgr.get(ctx, relatedID) } @@ -779,6 +822,14 @@ func (qb *TagStore) UpdateAliases(ctx context.Context, tagID int, aliases []stri return tagRepository.aliases.replace(ctx, tagID, aliases) } +func (qb *TagStore) GetStashIDs(ctx context.Context, tagID int) ([]models.StashID, error) { + return tagsStashIDsTableMgr.get(ctx, tagID) +} + +func (qb *TagStore) UpdateStashIDs(ctx context.Context, tagID int, stashIDs []models.StashID) error { + return tagsStashIDsTableMgr.replaceJoins(ctx, tagID, stashIDs) +} + func (qb *TagStore) Merge(ctx context.Context, source []int, destination int) error { if len(source) == 0 { return nil @@ -840,6 +891,19 @@ AND NOT EXISTS(SELECT 1 FROM `+table+` o WHERE o.`+idColumn+` = `+table+`.`+idCo return err } + // Merge StashIDs - move all source StashIDs to destination (ignoring duplicates) + _, err = dbWrapper.Exec(ctx, `UPDATE OR IGNORE `+"tag_stash_ids"+` +SET tag_id = ? +WHERE tag_id IN `+inBinding, args...) + if err != nil { + return err + } + + // Delete remaining source StashIDs that couldn't be moved (duplicates) + if _, err := dbWrapper.Exec(ctx, `DELETE FROM tag_stash_ids WHERE tag_id IN `+inBinding, srcArgs...); err != nil { + return err + } + for _, id := range source { err = qb.Destroy(ctx, id) if err != nil { diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index 770f39782..7d7d1bb09 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -900,6 +900,66 @@ func TestTagUpdateAlias(t *testing.T) { } } +func TestTagStashIDs(t *testing.T) { + if err := withTxn(func(ctx context.Context) error { + qb := db.Tag + + // create tag to test against + const name = "TestTagStashIDs" + tag := models.Tag{ + Name: name, + } + err := qb.Create(ctx, &tag) + if err != nil { + return fmt.Errorf("Error creating tag: %s", err.Error()) + } + + testStashIDReaderWriter(ctx, t, qb, tag.ID) + + return nil + }); err != nil { + t.Error(err.Error()) + } +} + +func TestTagFindByStashID(t *testing.T) { + withTxn(func(ctx context.Context) error { + qb := db.Tag + + // create tag to test against + const name = "TestTagFindByStashID" + const stashID = "stashid" + const endpoint = "endpoint" + tag := models.Tag{ + Name: name, + StashIDs: models.NewRelatedStashIDs([]models.StashID{{StashID: stashID, Endpoint: endpoint}}), + } + err := qb.Create(ctx, &tag) + if err != nil { + return fmt.Errorf("Error creating tag: %s", err.Error()) + } + + // find by stash ID + tags, err := qb.FindByStashID(ctx, models.StashID{StashID: stashID, Endpoint: endpoint}) + if err != nil { + return fmt.Errorf("Error finding by stash ID: %s", err.Error()) + } + + assert.Len(t, tags, 1) + assert.Equal(t, tag.ID, tags[0].ID) + + // find by non-existent stash ID + tags, err = qb.FindByStashID(ctx, models.StashID{StashID: "nonexistent", Endpoint: endpoint}) + if err != nil { + return fmt.Errorf("Error finding by stash ID: %s", err.Error()) + } + + assert.Len(t, tags, 0) + + return nil + }) +} + func TestTagMerge(t *testing.T) { assert := assert.New(t) diff --git a/pkg/stashbox/scene.go b/pkg/stashbox/scene.go index 33d427091..64c4defa2 100644 --- a/pkg/stashbox/scene.go +++ b/pkg/stashbox/scene.go @@ -205,7 +205,8 @@ func (c Client) sceneFragmentToScrapedScene(ctx context.Context, s *graphql.Scen for _, t := range s.Tags { st := &models.ScrapedTag{ - Name: t.Name, + Name: t.Name, + RemoteSiteID: &t.ID, } ss.Tags = append(ss.Tags, st) } @@ -242,8 +243,9 @@ type SceneDraft struct { Performers []*models.Performer // StashIDs must be loaded Studio *models.Studio - Tags []*models.Tag - Cover []byte + // StashIDs must be loaded + Tags []*models.Tag + Cover []byte } func (c Client) SubmitSceneDraft(ctx context.Context, d SceneDraft) (*string, error) { @@ -347,7 +349,17 @@ func newSceneDraftInput(d SceneDraft, endpoint string) graphql.SceneDraftInput { var tags []*graphql.DraftEntityInput sceneTags := d.Tags for _, tag := range sceneTags { - tags = append(tags, &graphql.DraftEntityInput{Name: tag.Name}) + tagDraft := graphql.DraftEntityInput{Name: tag.Name} + + stashIDs := tag.StashIDs.List() + for _, stashID := range stashIDs { + if stashID.Endpoint == endpoint { + tagDraft.ID = &stashID.StashID + break + } + } + + tags = append(tags, &tagDraft) } draft.Tags = tags diff --git a/pkg/tag/export.go b/pkg/tag/export.go index bd1573341..b07418667 100644 --- a/pkg/tag/export.go +++ b/pkg/tag/export.go @@ -16,6 +16,7 @@ type FinderAliasImageGetter interface { GetAliases(ctx context.Context, studioID int) ([]string, error) GetImage(ctx context.Context, tagID int) ([]byte, error) FindByChildTagID(ctx context.Context, childID int) ([]*models.Tag, error) + models.StashIDLoader } // ToJSON converts a Tag object into its JSON equivalent. @@ -37,6 +38,15 @@ func ToJSON(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag) newTagJSON.Aliases = aliases + if err := tag.LoadStashIDs(ctx, reader); err != nil { + return nil, fmt.Errorf("loading tag stash ids: %w", err) + } + + stashIDs := tag.StashIDs.List() + if len(stashIDs) > 0 { + newTagJSON.StashIDs = stashIDs + } + image, err := reader.GetImage(ctx, tag.ID) if err != nil { logger.Errorf("Error getting tag image: %v", err) diff --git a/pkg/tag/export_test.go b/pkg/tag/export_test.go index 6c008c170..84e082f30 100644 --- a/pkg/tag/export_test.go +++ b/pkg/tag/export_test.go @@ -126,6 +126,13 @@ func TestToJSON(t *testing.T) { db.Tag.On("GetAliases", testCtx, withParentsID).Return(nil, nil).Once() db.Tag.On("GetAliases", testCtx, errParentsID).Return(nil, nil).Once() + db.Tag.On("GetStashIDs", testCtx, tagID).Return(nil, nil).Once() + db.Tag.On("GetStashIDs", testCtx, noImageID).Return(nil, nil).Once() + db.Tag.On("GetStashIDs", testCtx, errImageID).Return(nil, nil).Once() + // errAliasID test fails before GetStashIDs is called, so no mock needed + db.Tag.On("GetStashIDs", testCtx, withParentsID).Return(nil, nil).Once() + db.Tag.On("GetStashIDs", testCtx, errParentsID).Return(nil, nil).Once() + db.Tag.On("GetImage", testCtx, tagID).Return(imageBytes, nil).Once() db.Tag.On("GetImage", testCtx, noImageID).Return(nil, nil).Once() db.Tag.On("GetImage", testCtx, errImageID).Return(nil, imageErr).Once() diff --git a/pkg/tag/import.go b/pkg/tag/import.go index 21203afb0..53b741886 100644 --- a/pkg/tag/import.go +++ b/pkg/tag/import.go @@ -42,6 +42,7 @@ func (i *Importer) PreImport(ctx context.Context) error { Description: i.Input.Description, Favorite: i.Input.Favorite, IgnoreAutoTag: i.Input.IgnoreAutoTag, + StashIDs: models.NewRelatedStashIDs(i.Input.StashIDs), CreatedAt: i.Input.CreatedAt.GetTime(), UpdatedAt: i.Input.UpdatedAt.GetTime(), } diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index 25c1036d8..4a0f588a4 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -158,6 +158,7 @@ fragment ScrapedSceneStudioData on ScrapedStudio { fragment ScrapedSceneTagData on ScrapedTag { stored_id name + remote_site_id } fragment ScrapedSceneData on ScrapedScene { diff --git a/ui/v2.5/graphql/data/tag.graphql b/ui/v2.5/graphql/data/tag.graphql index 5eae173ea..2395f48bd 100644 --- a/ui/v2.5/graphql/data/tag.graphql +++ b/ui/v2.5/graphql/data/tag.graphql @@ -6,6 +6,11 @@ fragment TagData on Tag { aliases ignore_auto_tag favorite + stash_ids { + endpoint + stash_id + updated_at + } image_path scene_count scene_count_all: scene_count(depth: -1) diff --git a/ui/v2.5/src/components/Shared/StashID.tsx b/ui/v2.5/src/components/Shared/StashID.tsx index 00bddf58e..8e1fecef2 100644 --- a/ui/v2.5/src/components/Shared/StashID.tsx +++ b/ui/v2.5/src/components/Shared/StashID.tsx @@ -4,7 +4,7 @@ import { ConfigurationContext } from "src/hooks/Config"; import { getStashboxBase } from "src/utils/stashbox"; import { ExternalLink } from "./ExternalLink"; -export type LinkType = "performers" | "scenes" | "studios"; +export type LinkType = "performers" | "scenes" | "studios" | "tags"; export const StashIDPill: React.FC<{ stashID: Pick; diff --git a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx index f76369387..a3429be9a 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx @@ -715,6 +715,18 @@ const StashSearchResult: React.FC = ({ async function onCreateTag(t: GQL.ScrapedTag) { const toCreate: GQL.TagCreateInput = { name: t.name }; + + // If the tag has a remote_site_id and we have an endpoint, include the stash_id + const endpoint = currentSource?.sourceInput.stash_box_endpoint; + if (t.remote_site_id && endpoint) { + toCreate.stash_ids = [ + { + endpoint: endpoint, + stash_id: t.remote_site_id, + }, + ]; + } + const newTagID = await createNewTag(t, toCreate); if (newTagID !== undefined) { setTagIDs([...tagIDs, newTagID]); diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx index 9e368aa8b..92c92d072 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx @@ -1,6 +1,7 @@ import React from "react"; import { TagLink } from "src/components/Shared/TagLink"; import { DetailItem } from "src/components/Shared/DetailItem"; +import { StashIDPill } from "src/components/Shared/StashID"; import * as GQL from "src/core/generated-graphql"; interface ITagDetails { @@ -51,6 +52,22 @@ export const TagDetailsPanel: React.FC = ({ tag, fullWidth }) => { ); } + function renderStashIDs() { + if (!tag.stash_ids?.length) { + return; + } + + return ( +
    + {tag.stash_ids.map((stashID) => ( +
  • + +
  • + ))} +
+ ); + } + return (
= ({ tag, fullWidth }) => { value={renderChildrenField()} fullWidth={fullWidth} /> +
); }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx index da79b6c4e..41756953b 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx @@ -14,6 +14,7 @@ import { useToast } from "src/hooks/Toast"; import { handleUnsavedChanges } from "src/utils/navigation"; import { formikUtils } from "src/utils/form"; import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup"; +import { getStashIDs } from "src/utils/stashIds"; import { Tag, TagSelect } from "../TagSelect"; interface ITagEditPanel { @@ -52,6 +53,7 @@ export const TagEditPanel: React.FC = ({ parent_ids: yup.array(yup.string().required()).defined(), child_ids: yup.array(yup.string().required()).defined(), ignore_auto_tag: yup.boolean().defined(), + stash_ids: yup.mixed().defined(), image: yup.string().nullable().optional(), }); @@ -63,6 +65,7 @@ export const TagEditPanel: React.FC = ({ parent_ids: (tag?.parents ?? []).map((t) => t.id), child_ids: (tag?.children ?? []).map((t) => t.id), ignore_auto_tag: tag?.ignore_auto_tag ?? false, + stash_ids: getStashIDs(tag?.stash_ids), }; type InputValues = yup.InferType; @@ -140,10 +143,12 @@ export const TagEditPanel: React.FC = ({ ImageUtils.onImageChange(event, onImageLoad); } - const { renderField, renderInputField, renderStringListField } = formikUtils( - intl, - formik - ); + const { + renderField, + renderInputField, + renderStringListField, + renderStashIDsField, + } = formikUtils(intl, formik); function renderParentTagsField() { const title = intl.formatMessage({ id: "parent_tags" }); @@ -210,6 +215,7 @@ export const TagEditPanel: React.FC = ({ {renderInputField("description", "textarea")} {renderParentTagsField()} {renderSubTagsField()} + {renderStashIDsField("stash_ids", "tags")}
{renderInputField("ignore_auto_tag", "checkbox")} From e3b3fbbf630dbafd1f26325e1943af280e1e7335 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Thu, 13 Nov 2025 14:12:06 -0800 Subject: [PATCH 125/157] FR: Add Duration Slider to Sidebar Filters (#6264) --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- .../List/Filters/SidebarDurationFilter.tsx | 360 ++++++++++++++++++ ui/v2.5/src/components/List/styles.scss | 30 ++ ui/v2.5/src/components/Scenes/SceneList.tsx | 9 + .../components/Shared/DoubleRangeInput.tsx | 61 +++ ui/v2.5/src/components/Shared/styles.scss | 97 +++++ ui/v2.5/src/models/list-filter/scenes.ts | 5 +- 6 files changed, 561 insertions(+), 1 deletion(-) create mode 100644 ui/v2.5/src/components/List/Filters/SidebarDurationFilter.tsx create mode 100644 ui/v2.5/src/components/Shared/DoubleRangeInput.tsx diff --git a/ui/v2.5/src/components/List/Filters/SidebarDurationFilter.tsx b/ui/v2.5/src/components/List/Filters/SidebarDurationFilter.tsx new file mode 100644 index 000000000..ff4b780af --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/SidebarDurationFilter.tsx @@ -0,0 +1,360 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { CriterionModifier } from "../../../core/generated-graphql"; +import { CriterionOption } from "../../../models/list-filter/criteria/criterion"; +import { DurationCriterion } from "src/models/list-filter/criteria/criterion"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { Option, SidebarListFilter } from "./SidebarListFilter"; +import TextUtils from "src/utils/text"; +import { DoubleRangeInput } from "src/components/Shared/DoubleRangeInput"; +import { useDebounce } from "src/hooks/debounce"; + +interface ISidebarFilter { + title?: React.ReactNode; + option: CriterionOption; + filter: ListFilterModel; + setFilter: (f: ListFilterModel) => void; + sectionID?: string; +} + +// Duration presets in seconds +const DURATION_PRESETS = [ + { id: "0-5", label: "0-5 min", min: 0, max: 300 }, + { id: "5-10", label: "5-10 min", min: 300, max: 600 }, + { id: "10-20", label: "10-20 min", min: 600, max: 1200 }, + { id: "20-40", label: "20-40 min", min: 1200, max: 2400 }, + { id: "40+", label: "40+ min", min: 2400, max: null }, +]; + +const MAX_DURATION = 7200; // 2 hours in seconds for the slider +const MAX_LABEL = "2+ hrs"; // Display label for maximum duration + +// Custom step values: 0, 2min (120s), 5min (300s), then 5 minute intervals +const DURATION_STEPS = [ + 0, 120, 300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000, 3300, 3600, + 3900, 4200, 4500, 4800, 5100, 5400, 5700, 6000, 6300, 6600, 6900, 7200, +]; + +// Snap a value to the nearest valid step +function snapToStep(value: number): number { + if (value <= 0) return 0; + if (value >= MAX_DURATION) return MAX_DURATION; + + // Find the closest step + let closest = DURATION_STEPS[0]; + let minDiff = Math.abs(value - closest); + + for (const step of DURATION_STEPS) { + const diff = Math.abs(value - step); + if (diff < minDiff) { + minDiff = diff; + closest = step; + } + } + + return closest; +} + +export const SidebarDurationFilter: React.FC = ({ + title, + option, + filter, + setFilter, + sectionID, +}) => { + const criteria = filter.criteriaFor(option.type) as DurationCriterion[]; + const criterion = criteria.length > 0 ? criteria[0] : null; + + // Get current values from criterion + const currentMin = criterion?.value?.value ?? 0; + const currentMax = criterion?.value?.value2 ?? MAX_DURATION; + + const [sliderMin, setSliderMin] = useState(currentMin); + const [sliderMax, setSliderMax] = useState(currentMax); + const [minInput, setMinInput] = useState( + currentMin === 0 ? "0m" : TextUtils.secondsAsTimeString(currentMin) + ); + const [maxInput, setMaxInput] = useState( + currentMax >= MAX_DURATION + ? MAX_LABEL + : TextUtils.secondsAsTimeString(currentMax) + ); + + // Reset slider when criterion is removed externally (via filter tag X) + useEffect(() => { + if (!criterion) { + setSliderMin(0); + setSliderMax(MAX_DURATION); + setMinInput("0m"); + setMaxInput(MAX_LABEL); + } + }, [criterion]); + + // Determine which preset is selected + const selectedPreset = useMemo(() => { + if (!criterion) return null; + + // Check if current values match any preset + for (const preset of DURATION_PRESETS) { + if (preset.max === null) { + // For "40+ min" preset + if ( + criterion.modifier === CriterionModifier.GreaterThan && + criterion.value.value === preset.min + ) { + return preset.id; + } + } else { + // For range presets + if ( + criterion.modifier === CriterionModifier.Between && + criterion.value.value === preset.min && + criterion.value.value2 === preset.max + ) { + return preset.id; + } + } + } + + // Check if it's a custom range or custom GreaterThan + if ( + criterion.modifier === CriterionModifier.Between || + criterion.modifier === CriterionModifier.GreaterThan + ) { + return "custom"; + } + + return null; + }, [criterion]); + + const options: Option[] = useMemo(() => { + return DURATION_PRESETS.map((preset) => ({ + id: preset.id, + label: preset.label, + className: "duration-preset", + })); + }, []); + + const selected: Option[] = useMemo(() => { + if (!selectedPreset) return []; + if (selectedPreset === "custom") return []; + + const preset = DURATION_PRESETS.find((p) => p.id === selectedPreset); + if (preset) { + return [ + { + id: preset.id, + label: preset.label, + className: "duration-preset", + }, + ]; + } + return []; + }, [selectedPreset]); + + function onSelectPreset(item: Option) { + const preset = DURATION_PRESETS.find((p) => p.id === item.id); + if (!preset) return; + + const newCriterion = criterion ? criterion.clone() : option.makeCriterion(); + + if (preset.max === null) { + // "40+ min" - use GreaterThan + newCriterion.modifier = CriterionModifier.GreaterThan; + newCriterion.value.value = preset.min; + newCriterion.value.value2 = undefined; + } else { + // Range preset - use Between + newCriterion.modifier = CriterionModifier.Between; + newCriterion.value.value = preset.min; + newCriterion.value.value2 = preset.max; + } + + setSliderMin(preset.min); + setSliderMax(preset.max ?? MAX_DURATION); + setMinInput( + preset.min === 0 ? "0m" : TextUtils.secondsAsTimeString(preset.min) + ); + setMaxInput( + preset.max === null + ? MAX_LABEL + : TextUtils.secondsAsTimeString(preset.max) + ); + setFilter(filter.replaceCriteria(option.type, [newCriterion])); + } + + function onUnselectPreset() { + setFilter(filter.removeCriterion(option.type)); + setSliderMin(0); + setSliderMax(MAX_DURATION); + setMinInput("0m"); + setMaxInput(MAX_LABEL); + } + + // Parse time input (supports formats like "10", "1:30", "1:30:00", "2+ hrs") + function parseTimeInput(input: string): number | null { + const trimmed = input.trim().toLowerCase(); + + if (trimmed === "max" || trimmed === MAX_LABEL.toLowerCase()) { + return MAX_DURATION; + } + + // Try to parse as pure number (minutes) + const minutesOnly = parseFloat(trimmed); + if (!isNaN(minutesOnly) && trimmed.indexOf(":") === -1) { + return Math.round(minutesOnly * 60); + } + + // Parse HH:MM:SS or MM:SS format + const parts = trimmed.split(":").map((p) => parseInt(p)); + if (parts.some(isNaN)) { + return null; + } + + if (parts.length === 2) { + // MM:SS + return parts[0] * 60 + parts[1]; + } else if (parts.length === 3) { + // HH:MM:SS + return parts[0] * 3600 + parts[1] * 60 + parts[2]; + } + + return null; + } + + // Debounced filter update + function updateFilter(min: number, max: number) { + // If slider is at full range (0 to max), remove the filter entirely + if (min === 0 && max >= MAX_DURATION) { + setFilter(filter.removeCriterion(option.type)); + return; + } + + const newCriterion = criterion ? criterion.clone() : option.makeCriterion(); + + // If max is at MAX_DURATION (but min > 0), use GreaterThan + if (max >= MAX_DURATION) { + newCriterion.modifier = CriterionModifier.GreaterThan; + newCriterion.value.value = min; + newCriterion.value.value2 = undefined; + } else { + newCriterion.modifier = CriterionModifier.Between; + newCriterion.value.value = min; + newCriterion.value.value2 = max; + } + + setFilter(filter.replaceCriteria(option.type, [newCriterion])); + } + + const updateFilterDebounceMS = 300; + const debounceUpdateFilter = useDebounce( + updateFilter, + updateFilterDebounceMS + ); + + function handleSliderChange(min: number, max: number) { + if (min < 0 || max > MAX_DURATION || min >= max) { + return; + } + + setSliderMin(min); + setSliderMax(max); + setMinInput(min === 0 ? "0m" : TextUtils.secondsAsTimeString(min)); + setMaxInput( + max >= MAX_DURATION ? MAX_LABEL : TextUtils.secondsAsTimeString(max) + ); + + debounceUpdateFilter(min, max); + } + + function handleMinInputChange(value: string) { + setMinInput(value); + } + + function handleMaxInputChange(value: string) { + setMaxInput(value); + } + + function handleMinInputBlur() { + const parsed = parseTimeInput(minInput); + if (parsed !== null && parsed >= 0 && parsed < sliderMax) { + handleSliderChange(parsed, sliderMax); + } else { + // Reset to current value if invalid + setMinInput( + sliderMin === 0 ? "0m" : TextUtils.secondsAsTimeString(sliderMin) + ); + } + } + + function handleMaxInputBlur() { + const parsed = parseTimeInput(maxInput); + if (parsed !== null && parsed > sliderMin && parsed <= MAX_DURATION) { + handleSliderChange(sliderMin, parsed); + } else { + // Reset to current value if invalid + setMaxInput( + sliderMax >= MAX_DURATION + ? MAX_LABEL + : TextUtils.secondsAsTimeString(sliderMax) + ); + } + } + + const customSlider = ( + handleMinInputChange(e.target.value)} + onBlur={handleMinInputBlur} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + } + }} + placeholder="0:00" + /> + } + maxInput={ + handleMaxInputChange(e.target.value)} + onBlur={handleMaxInputBlur} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + } + }} + placeholder={MAX_LABEL} + /> + } + min={0} + max={MAX_DURATION} + value={[sliderMin, sliderMax]} + onChange={(vals) => { + handleSliderChange(snapToStep(vals[0]), snapToStep(vals[1])); + }} + /> + ); + + return ( + + ); +}; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index df50430a2..1b5b4c6e1 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -1400,3 +1400,33 @@ input[type="range"].zoom-slider { } } } + +// Duration slider styles +.duration-slider { + padding: 0.5rem 0 1rem; + width: 100%; +} + +.duration-label-input { + background: transparent; + border: 1px solid transparent; + border-radius: 0.25rem; + color: $text-color; + font-size: 0.875rem; + font-weight: 500; + padding: 0.125rem 0.25rem; + width: 4rem; + + &:hover { + border-color: $secondary; + } + + &:focus { + border-color: $primary; + outline: none; + } +} + +.duration-preset { + cursor: pointer; +} diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 3936be6f2..14baf7188 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -55,6 +55,8 @@ import { RatingCriterionOption } from "src/models/list-filter/criteria/rating"; import { SidebarRatingFilter } from "../List/Filters/RatingFilter"; import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized"; import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; +import { DurationCriterionOption } from "src/models/list-filter/scenes"; +import { SidebarDurationFilter } from "../List/Filters/SidebarDurationFilter"; import { FilteredSidebarHeader, useFilteredSidebarKeybinds, @@ -320,6 +322,13 @@ const SidebarContent: React.FC<{ setFilter={setFilter} sectionID="rating" /> + } + option={DurationCriterionOption} + filter={filter} + setFilter={setFilter} + sectionID="duration" + /> } data-type={OrganizedCriterionOption.type} diff --git a/ui/v2.5/src/components/Shared/DoubleRangeInput.tsx b/ui/v2.5/src/components/Shared/DoubleRangeInput.tsx new file mode 100644 index 000000000..1d8e7fbfe --- /dev/null +++ b/ui/v2.5/src/components/Shared/DoubleRangeInput.tsx @@ -0,0 +1,61 @@ +import React from "react"; + +export const DoubleRangeInput: React.FC<{ + className?: string; + minInput: React.ReactNode; + maxInput: React.ReactNode; + min?: number; + max: number; + value: [number, number]; + onChange(value: [number, number]): void; +}> = ({ + className = "", + minInput, + maxInput, + min = 0, + max, + value, + onChange, +}) => { + const minValue = value[0]; + const maxValue = value[1]; + + return ( +
+
+ {minInput} + {maxInput} +
+
+ { + const rawValue = parseInt(e.target.value); + if (rawValue < maxValue) { + onChange([rawValue, maxValue]); + } + }} + className="double-range-slider double-range-slider-min" + /> + { + const rawValue = parseInt(e.target.value); + if (rawValue > minValue) { + onChange([minValue, rawValue]); + } + }} + className="double-range-slider double-range-slider-max" + /> +
+
+ ); +}; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index faaa00c53..8eaa3b90a 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -949,3 +949,100 @@ $sticky-header-height: calc(50px + 3.3rem); } } } + +// Duration slider styles +.duration-slider-container { + padding: 0.5rem 0 1rem; + width: 100%; +} + +.double-range-input-labels { + color: $text-color; + display: flex; + font-size: 0.875rem; + font-weight: 500; + justify-content: space-between; + margin-bottom: 0.5rem; + padding: 0 0.25rem; + + .duration-label-input { + &:first-child { + text-align: left; + } + + &:last-child { + text-align: right; + } + } +} + +.double-range-sliders { + height: 22px; + position: relative; +} + +.double-range-slider { + pointer-events: none; + position: absolute; + width: 100%; + + &::-webkit-slider-thumb { + appearance: none; + background-color: $primary; + border: 2px solid $primary; + border-radius: 50%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + cursor: pointer; + height: 18px; + pointer-events: all; + position: relative; + width: 18px; + } + + &::-moz-range-thumb { + appearance: none; + background-color: $primary; + border: 2px solid $primary; + border-radius: 50%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + cursor: pointer; + height: 18px; + pointer-events: all; + position: relative; + width: 18px; + } + + &::-ms-thumb { + appearance: none; + background-color: $primary; + border: 2px solid $primary; + border-radius: 50%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + cursor: pointer; + height: 18px; + pointer-events: all; + position: relative; + width: 18px; + } +} + +.double-range-slider-min { + z-index: 1; +} + +input[type="range"].double-range-slider-max { + z-index: 2; + + // combining these into one rule doesn't work for some reason + &::-webkit-slider-runnable-track { + background: transparent; + } + + &::-moz-range-track { + background: transparent; + } + + &::-ms-track { + background: transparent; + } +} diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index 8bd3918f9..09c60e483 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -79,6 +79,9 @@ const displayModeOptions = [ DisplayMode.Tagger, ]; +export const DurationCriterionOption = + createDurationCriterionOption("duration"); + const criterionOptions = [ createStringCriterionOption("title"), createStringCriterionOption("code", "scene_code"), @@ -98,7 +101,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("bitrate"), createStringCriterionOption("video_codec"), createStringCriterionOption("audio_codec"), - createDurationCriterionOption("duration"), + DurationCriterionOption, createDurationCriterionOption("resume_time"), createDurationCriterionOption("play_duration"), createMandatoryNumberCriterionOption("play_count"), From 957c4fe1b5617ccf6f208a35a3a40d3eb9c72a55 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Thu, 13 Nov 2025 16:49:26 -0800 Subject: [PATCH 126/157] Bugfix: Fix empty Aliases Being Created for Studios (#6273) * Filter out empty alias strings in studio modal create * Reject empty alias strings in backend * Remove invalid ValidateAliases call from UpdatePartial This was calling using the values which are not necessarily the final values. --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- pkg/sqlite/studio.go | 6 +----- pkg/studio/validate.go | 12 ++++++++---- ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx | 10 ++++++++-- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index bddc17c12..1a05be6f3 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -181,7 +181,7 @@ func (qb *StudioStore) Create(ctx context.Context, newObject *models.Studio) err } if newObject.Aliases.Loaded() { - if err := studio.EnsureAliasesUnique(ctx, id, newObject.Aliases.List(), qb); err != nil { + if err := studio.ValidateAliases(ctx, id, newObject.Aliases.List(), qb); err != nil { return err } @@ -232,10 +232,6 @@ func (qb *StudioStore) UpdatePartial(ctx context.Context, input models.StudioPar } if input.Aliases != nil { - if err := studio.EnsureAliasesUnique(ctx, input.ID, input.Aliases.Values, qb); err != nil { - return nil, err - } - if err := studiosAliasesTableMgr.modifyJoins(ctx, input.ID, input.Aliases.Values, input.Aliases.Mode); err != nil { return nil, err } diff --git a/pkg/studio/validate.go b/pkg/studio/validate.go index 8a8676351..4e2f51c84 100644 --- a/pkg/studio/validate.go +++ b/pkg/studio/validate.go @@ -10,6 +10,7 @@ import ( var ( ErrNameMissing = errors.New("studio name must not be blank") + ErrEmptyAlias = errors.New("studio alias must not be an empty string") ErrStudioOwnAncestor = errors.New("studio cannot be an ancestor of itself") ) @@ -61,9 +62,12 @@ func EnsureStudioNameUnique(ctx context.Context, id int, name string, qb models. return nil } -func EnsureAliasesUnique(ctx context.Context, id int, aliases []string, qb models.StudioQueryer) error { +func ValidateAliases(ctx context.Context, id int, aliases []string, qb models.StudioQueryer) error { for _, a := range aliases { - if err := EnsureStudioNameUnique(ctx, id, a, qb); err != nil { + if err := validateName(ctx, id, a, qb); err != nil { + if errors.Is(err, ErrNameMissing) { + return ErrEmptyAlias + } return err } } @@ -77,7 +81,7 @@ func ValidateCreate(ctx context.Context, studio models.Studio, qb models.StudioQ } if studio.Aliases.Loaded() && len(studio.Aliases.List()) > 0 { - if err := EnsureAliasesUnique(ctx, 0, studio.Aliases.List(), qb); err != nil { + if err := ValidateAliases(ctx, 0, studio.Aliases.List(), qb); err != nil { return err } } @@ -131,7 +135,7 @@ func ValidateModify(ctx context.Context, s models.StudioPartial, qb ValidateModi } effectiveAliases := s.Aliases.Apply(existing.Aliases.List()) - if err := EnsureAliasesUnique(ctx, s.ID, effectiveAliases, qb); err != nil { + if err := ValidateAliases(ctx, s.ID, effectiveAliases, qb); err != nil { return err } } diff --git a/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx b/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx index e01c40881..a77025d57 100644 --- a/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx @@ -236,7 +236,10 @@ const StudioModal: React.FC = ({ image: studio.image, parent_id: studio.parent?.stored_id, details: studio.details, - aliases: studio.aliases?.split(",").map((a) => a.trim()), + aliases: studio.aliases + ?.split(",") + .map((a) => a.trim()) + .filter((a) => a), tag_ids: studio.tags?.map((t) => t.stored_id).filter((id) => id) as | string[] | undefined, @@ -270,7 +273,10 @@ const StudioModal: React.FC = ({ urls: studio.parent?.urls, image: studio.parent?.image, details: studio.parent?.details, - aliases: studio.parent?.aliases?.split(",").map((a) => a.trim()), + aliases: studio.parent?.aliases + ?.split(",") + .map((a) => a.trim()) + .filter((a) => a), tag_ids: studio.parent?.tags ?.map((t) => t.stored_id) .filter((id) => id) as string[] | undefined, From d743787bb38a16520a0d96e8acdb310a90250ce5 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 14 Nov 2025 12:57:34 +1100 Subject: [PATCH 127/157] Include stash-ids when creating objects from scrape dialog (#6269) * Filter out empty aliases --- .../Scenes/SceneDetails/SceneScrapeDialog.tsx | 6 ++- .../Shared/ScrapeDialog/createObjects.ts | 46 ++++++++++++++++--- .../Shared/ScrapeDialog/scrapedTags.tsx | 4 +- ui/v2.5/src/core/performers.ts | 19 +++++++- 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx index d6acfabfb..6a89caf85 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx @@ -133,7 +133,8 @@ export const SceneScrapeDialog: React.FC = ({ const { tags, newTags, scrapedTagsRow } = useScrapedTags( sceneTags, - scraped.tags + scraped.tags, + endpoint ); const [details, setDetails] = useState>( @@ -148,6 +149,7 @@ export const SceneScrapeDialog: React.FC = ({ scrapeResult: studio, setScrapeResult: setStudio, setNewObject: setNewStudio, + endpoint, }); const createNewPerformer = useCreateScrapedPerformer({ @@ -155,6 +157,7 @@ export const SceneScrapeDialog: React.FC = ({ setScrapeResult: setPerformers, newObjects: newPerformers, setNewObjects: setNewPerformers, + endpoint, }); const createNewGroup = useCreateScrapedGroup({ @@ -162,6 +165,7 @@ export const SceneScrapeDialog: React.FC = ({ setScrapeResult: setGroups, newObjects: newGroups, setNewObjects: setNewGroups, + endpoint, }); const intl = useIntl(); diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts b/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts index c4ba3a3e7..e2d09294a 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts @@ -46,6 +46,7 @@ interface IUseCreateNewStudioProps { scrapeResult: ObjectScrapeResult ) => void; setNewObject: (newObject: GQL.ScrapedStudio | undefined) => void; + endpoint?: string; } export function useCreateScrapedStudio(props: IUseCreateNewStudioProps) { @@ -54,12 +55,33 @@ export function useCreateScrapedStudio(props: IUseCreateNewStudioProps) { const { scrapeResult, setScrapeResult, setNewObject } = props; async function createNewStudio(toCreate: GQL.ScrapedStudio) { + const input: GQL.StudioCreateInput = { + name: toCreate.name, + urls: toCreate.urls, + aliases: + toCreate.aliases + ?.split(",") + .map((a) => a.trim()) + .filter((a) => a) || [], + details: toCreate.details, + image: toCreate.image, + tag_ids: (toCreate.tags ?? []) + .filter((t) => t.stored_id) + .map((t) => t.stored_id!), + }; + + if (props.endpoint && toCreate.remote_site_id) { + input.stash_ids = [ + { + endpoint: props.endpoint, + stash_id: toCreate.remote_site_id, + }, + ]; + } + const result = await createStudio({ variables: { - input: { - name: toCreate.name, - url: toCreate.url, - }, + input, }, }); @@ -81,6 +103,7 @@ interface IUseCreateNewObjectProps { setScrapeResult: (scrapeResult: ScrapeResult) => void; newObjects: T[]; setNewObjects: (newObject: T[]) => void; + endpoint?: string; } export function useCreateScrapedPerformer( @@ -91,7 +114,7 @@ export function useCreateScrapedPerformer( const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props; async function createNewPerformer(toCreate: GQL.ScrapedPerformer) { - const input = scrapedPerformerToCreateInput(toCreate); + const input = scrapedPerformerToCreateInput(toCreate, props.endpoint); const result = await createPerformer({ variables: { input }, @@ -168,7 +191,18 @@ export function useCreateScrapedTag( const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props; async function createNewTag(toCreate: GQL.ScrapedTag) { - const input: GQL.TagCreateInput = { name: toCreate.name ?? "" }; + const input: GQL.TagCreateInput = { + name: toCreate.name ?? "", + }; + + if (props.endpoint && toCreate.remote_site_id) { + input.stash_ids = [ + { + endpoint: props.endpoint, + stash_id: toCreate.remote_site_id, + }, + ]; + } const result = await createTag({ variables: { input }, diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/scrapedTags.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/scrapedTags.tsx index 5527138d1..ca3658391 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/scrapedTags.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/scrapedTags.tsx @@ -9,7 +9,8 @@ import { ScrapedTagsRow } from "./ScrapedObjectsRow"; export function useScrapedTags( existingTags: Tag[], - scrapedTags?: GQL.Maybe + scrapedTags?: GQL.Maybe, + endpoint?: string ) { const intl = useIntl(); const [tags, setTags] = useState>( @@ -33,6 +34,7 @@ export function useScrapedTags( setScrapeResult: setTags, newObjects: newTags, setNewObjects: setNewTags, + endpoint, }); const scrapedTagsRow = ( diff --git a/ui/v2.5/src/core/performers.ts b/ui/v2.5/src/core/performers.ts index 455ada9f9..9712c9824 100644 --- a/ui/v2.5/src/core/performers.ts +++ b/ui/v2.5/src/core/performers.ts @@ -84,9 +84,14 @@ export function sortPerformers(performers: T[]) { } export const scrapedPerformerToCreateInput = ( - toCreate: GQL.ScrapedPerformer + toCreate: GQL.ScrapedPerformer, + endpoint?: string ) => { - const aliases = toCreate.aliases?.split(",").map((a) => a.trim()); + const aliases = + toCreate.aliases + ?.split(",") + .map((a) => a.trim()) + .filter((a) => a) || []; const input: GQL.PerformerCreateInput = { name: toCreate.name ?? "", @@ -118,5 +123,15 @@ export const scrapedPerformerToCreateInput = ( : undefined, circumcised: stringToCircumcised(toCreate.circumcised), }; + + if (endpoint && toCreate.remote_site_id) { + input.stash_ids = [ + { + endpoint, + stash_id: toCreate.remote_site_id, + }, + ]; + } + return input; }; From bc91ca0a2574e155c209743bebaf0bc99e416daa Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 14 Nov 2025 12:59:29 +1100 Subject: [PATCH 128/157] Fix inconsistency when scraping performer with multiple stash ids from same endpoint (#6260) --- .../PerformerDetails/PerformerScrapeDialog.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index eb5f26a83..0398f1eec 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -146,6 +146,22 @@ export const PerformerScrapeDialog: React.FC = ( return; } + // #6257 - it is possible (though unsupported) to have multiple stash IDs for the same + // endpoint; in that case, we should prefer the one matching the scraped remote site ID + // if it exists + const stashIDs = (props.performer.stash_ids ?? []).filter( + (s) => s.endpoint === endpoint + ); + if (stashIDs.length > 1 && props.scraped.remote_site_id) { + const matchingID = stashIDs.find( + (s) => s.stash_id === props.scraped.remote_site_id + ); + if (matchingID) { + return matchingID.stash_id; + } + } + + // otherwise, return the first stash ID for the endpoint return props.performer.stash_ids?.find((s) => s.endpoint === endpoint) ?.stash_id; } From 892858a80351f7fbe32c94e4ba87b1e172cbbf11 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 14 Nov 2025 13:08:12 +1100 Subject: [PATCH 129/157] Trigger build when release branch pushed --- .github/workflows/build.yml | 5 ++++- .github/workflows/golangci-lint.yml | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8d455a7d7..1e46ecd69 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,10 @@ name: Build on: push: - branches: [ develop, master ] + branches: + - develop + - master + - 'releases/**' pull_request: release: types: [ published ] diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index cd5b7d61c..71c743ced 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -6,6 +6,7 @@ on: branches: - master - develop + - 'releases/**' pull_request: env: From 15db2da3618625a8e988c0b457146fb822f9cb77 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 14 Nov 2025 13:41:29 +1100 Subject: [PATCH 130/157] Add v0.30.0 changelog --- .../src/components/Changelog/Changelog.tsx | 15 ++++++++---- ui/v2.5/src/docs/en/Changelog/v0300.md | 23 +++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 ui/v2.5/src/docs/en/Changelog/v0300.md diff --git a/ui/v2.5/src/components/Changelog/Changelog.tsx b/ui/v2.5/src/components/Changelog/Changelog.tsx index 5b2732977..97175e1c2 100644 --- a/ui/v2.5/src/components/Changelog/Changelog.tsx +++ b/ui/v2.5/src/components/Changelog/Changelog.tsx @@ -34,8 +34,10 @@ import V0260 from "src/docs/en/Changelog/v0260.md"; import V0270 from "src/docs/en/Changelog/v0270.md"; import V0280 from "src/docs/en/Changelog/v0280.md"; import V0290 from "src/docs/en/Changelog/v0290.md"; +import V0300 from "src/docs/en/Changelog/v0300.md"; + +import V0290ReleaseNotes from "src/docs/en/ReleaseNotes/v0290.md"; -import V020ReleaseNotes from "src/docs/en/ReleaseNotes/v0290.md"; import { MarkdownPage } from "../Shared/MarkdownPage"; import { FormattedMessage } from "react-intl"; @@ -73,9 +75,9 @@ const Changelog: React.FC = () => { // after new release: // add entry to releases, using the current* fields // then update the current fields. - const currentVersion = stashVersion || "v0.29.0"; + const currentVersion = stashVersion || "v0.30.0"; const currentDate = buildDate; - const currentPage = V0290; + const currentPage = V0300; const releases: IStashRelease[] = [ { @@ -83,7 +85,12 @@ const Changelog: React.FC = () => { date: currentDate, page: currentPage, defaultOpen: true, - releaseNotes: V020ReleaseNotes, + }, + { + version: "v0.29.3", + date: "2025-11-06", + page: V0290, + releaseNotes: V0290ReleaseNotes, }, { version: "v0.28.1", diff --git a/ui/v2.5/src/docs/en/Changelog/v0300.md b/ui/v2.5/src/docs/en/Changelog/v0300.md new file mode 100644 index 000000000..aaf07c234 --- /dev/null +++ b/ui/v2.5/src/docs/en/Changelog/v0300.md @@ -0,0 +1,23 @@ +### ✨ New Features +* Added stash-ids to Tags. ([#6255](https://github.com/stashapp/stash/pull/6255)) +* Added support for multiple Studio URLs. ([#6223](https://github.com/stashapp/stash/pull/6223)) +* Added option to add markers to front page. ([#6065](https://github.com/stashapp/stash/pull/6065)) +* Added duration filter to scene list sidebar. ([#6264](https://github.com/stashapp/stash/pull/6264)) +* Added experimental support for JPEG XL images. ([#6184](https://github.com/stashapp/stash/pull/6184)) + +### 🎨 Improvements +* Selected stash-box is now remembered in the scene tagger view. ([#6192](https://github.com/stashapp/stash/pull/6192)) +* Added hardware encoding support for Rockchip RKMPP devices. ([#6182](https://github.com/stashapp/stash/pull/6182)) +* Added `inputURL` and `inputHostname` fields to scraper specs. ([#6250](https://github.com/stashapp/stash/pull/6250)) +* Added extra studio fields to scraper specs. ([#6249](https://github.com/stashapp/stash/pull/6249)) +* Added o-count to group cards. ([#6122](https://github.com/stashapp/stash/pull/6122)) +* Added options to filter and sort groups by o-count. ([#6122](https://github.com/stashapp/stash/pull/6122)) +* Added o-count to performer details page. ([#6171](https://github.com/stashapp/stash/pull/6171)) +* Added option to sort by total scene direction for performers, studios and tags. ([#6172](https://github.com/stashapp/stash/pull/6172)) +* Added option to sort scenes by Performer age. ([#6009](https://github.com/stashapp/stash/pull/6009)) +* Added option to sort scenes by Studio. ([#6155](https://github.com/stashapp/stash/pull/6155)) +* Added option to show external links on Performer cards. ([#6153](https://github.com/stashapp/stash/pull/6153)) +* Added keyboard shortcuts to generate scene screenshot at current time (`c c`) and to regenerate default screenshot (`c d`). ([#5984](https://github.com/stashapp/stash/pull/5984)) + +### 🐛 Bug fixes +* stash-ids are now set when creating new objects from the scrape dialog. ([#6269](https://github.com/stashapp/stash/pull/6269)) \ No newline at end of file From 1ec8d4afe5162339ecf95d55d7f7ca1cb6b34c1d Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 17 Nov 2025 10:12:50 +1100 Subject: [PATCH 131/157] Add edit studios dialog (#6238) --- ui/v2.5/graphql/mutations/studio.graphql | 6 + .../components/Shared/BulkUpdateTextInput.tsx | 2 + .../components/Studios/EditStudiosDialog.tsx | 245 ++++++++++++++++++ ui/v2.5/src/components/Studios/StudioList.tsx | 9 + ui/v2.5/src/core/StashService.ts | 10 + 5 files changed, 272 insertions(+) create mode 100644 ui/v2.5/src/components/Studios/EditStudiosDialog.tsx diff --git a/ui/v2.5/graphql/mutations/studio.graphql b/ui/v2.5/graphql/mutations/studio.graphql index 6d1944dc1..679d75f6d 100644 --- a/ui/v2.5/graphql/mutations/studio.graphql +++ b/ui/v2.5/graphql/mutations/studio.graphql @@ -10,6 +10,12 @@ mutation StudioUpdate($input: StudioUpdateInput!) { } } +mutation BulkStudioUpdate($input: BulkStudioUpdateInput!) { + bulkStudioUpdate(input: $input) { + ...StudioData + } +} + mutation StudioDestroy($id: ID!) { studioDestroy(input: { id: $id }) } diff --git a/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx b/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx index 542ab5b3b..cf78798e1 100644 --- a/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx +++ b/ui/v2.5/src/components/Shared/BulkUpdateTextInput.tsx @@ -7,6 +7,7 @@ import { Icon } from "./Icon"; interface IBulkUpdateTextInputProps extends FormControlProps { valueChanged: (value: string | undefined) => void; unsetDisabled?: boolean; + as?: React.ElementType; } export const BulkUpdateTextInput: React.FC = ({ @@ -24,6 +25,7 @@ export const BulkUpdateTextInput: React.FC = ({ {...props} className="input-control" type="text" + as={props.as} value={props.value ?? ""} placeholder={ props.value === undefined diff --git a/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx b/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx new file mode 100644 index 000000000..293a8dfb3 --- /dev/null +++ b/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx @@ -0,0 +1,245 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { Col, Form, Row } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useBulkStudioUpdate } from "src/core/StashService"; +import * as GQL from "src/core/generated-graphql"; +import { ModalComponent } from "../Shared/Modal"; +import { useToast } from "src/hooks/Toast"; +import { MultiSet } from "../Shared/MultiSet"; +import { RatingSystem } from "../Shared/Rating/RatingSystem"; +import { + getAggregateInputValue, + getAggregateState, + getAggregateStateObject, +} from "src/utils/bulkUpdate"; +import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; +import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput"; +import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; +import * as FormUtils from "src/utils/form"; +import { StudioSelect } from "../Shared/Select"; + +interface IListOperationProps { + selected: GQL.SlimStudioDataFragment[]; + onClose: (applied: boolean) => void; +} + +const studioFields = ["favorite", "rating100", "details", "ignore_auto_tag"]; + +export const EditStudiosDialog: React.FC = ( + props: IListOperationProps +) => { + const intl = useIntl(); + const Toast = useToast(); + + const [updateInput, setUpdateInput] = useState({ + ids: props.selected.map((studio) => { + return studio.id; + }), + }); + + const [tagIds, setTagIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + + const [updateStudios] = useBulkStudioUpdate(); + + // Network state + const [isUpdating, setIsUpdating] = useState(false); + + const aggregateState = useMemo(() => { + const updateState: Partial = {}; + const state = props.selected; + let updateTagIds: string[] = []; + let first = true; + + state.forEach((studio: GQL.SlimStudioDataFragment) => { + getAggregateStateObject(updateState, studio, studioFields, first); + + // studio data fragment doesn't have parent_id, so handle separately + updateState.parent_id = getAggregateState( + updateState.parent_id, + studio.parent_studio?.id, + first + ); + + const studioTagIDs = (studio.tags ?? []).map((p) => p.id).sort(); + + updateTagIds = getAggregateState(updateTagIds, studioTagIDs, first) ?? []; + + first = false; + }); + + return { state: updateState, tagIds: updateTagIds }; + }, [props.selected]); + + // update initial state from aggregate + useEffect(() => { + setUpdateInput((current) => ({ ...current, ...aggregateState.state })); + }, [aggregateState]); + + function setUpdateField(input: Partial) { + setUpdateInput((current) => ({ ...current, ...input })); + } + + function getStudioInput(): GQL.BulkStudioUpdateInput { + const studioInput: GQL.BulkStudioUpdateInput = { + ...updateInput, + tag_ids: tagIds, + }; + + // we don't have unset functionality for the rating star control + // so need to determine if we are setting a rating or not + studioInput.rating100 = getAggregateInputValue( + updateInput.rating100, + aggregateState.state.rating100 + ); + + return studioInput; + } + + async function onSave() { + setIsUpdating(true); + try { + await updateStudios({ + variables: { + input: getStudioInput(), + }, + }); + Toast.success( + intl.formatMessage( + { id: "toast.updated_entity" }, + { + entity: intl.formatMessage({ id: "studios" }).toLocaleLowerCase(), + } + ) + ); + props.onClose(true); + } catch (e) { + Toast.error(e); + } + setIsUpdating(false); + } + + function renderTextField( + name: string, + value: string | undefined | null, + setter: (newValue: string | undefined) => void, + area: boolean = false + ) { + return ( + + + + + setter(newValue)} + unsetDisabled={props.selected.length < 2} + as={area ? "textarea" : undefined} + /> + + ); + } + + function render() { + return ( + props.onClose(false), + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "secondary", + }} + isRunning={isUpdating} + > + + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "parent_studio" }), + })} + + + setUpdateField({ + parent_id: items.length > 0 ? items[0]?.id : undefined, + }) + } + ids={updateInput.parent_id ? [updateInput.parent_id] : []} + isDisabled={isUpdating} + menuPortalTarget={document.body} + /> + + + + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "rating" }), + })} + + + setUpdateField({ rating100: value ?? undefined }) + } + disabled={isUpdating} + /> + + +
+ + setUpdateField({ favorite: checked })} + checked={updateInput.favorite ?? undefined} + label={intl.formatMessage({ id: "favourite" })} + /> + + + + + + + setTagIds((v) => ({ ...v, ids: itemIDs }))} + onSetMode={(newMode) => + setTagIds((v) => ({ ...v, mode: newMode })) + } + existingIds={aggregateState.tagIds ?? []} + ids={tagIds.ids ?? []} + mode={tagIds.mode} + menuPortalTarget={document.body} + /> + + + {renderTextField( + "details", + updateInput.details, + (newValue) => setUpdateField({ details: newValue }), + true + )} + + + + setUpdateField({ ignore_auto_tag: checked }) + } + checked={updateInput.ignore_auto_tag ?? undefined} + /> + +
+
+ ); + } + + return render(); +}; diff --git a/ui/v2.5/src/components/Studios/StudioList.tsx b/ui/v2.5/src/components/Studios/StudioList.tsx index dd67f560b..423cd1587 100644 --- a/ui/v2.5/src/components/Studios/StudioList.tsx +++ b/ui/v2.5/src/components/Studios/StudioList.tsx @@ -17,6 +17,7 @@ import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; import { StudioTagger } from "../Tagger/studios/StudioTagger"; import { StudioCardGrid } from "./StudioCardGrid"; import { View } from "../List/views"; +import { EditStudiosDialog } from "./EditStudiosDialog"; function getItems(result: GQL.FindStudiosQueryResult) { return result?.data?.findStudios?.studios ?? []; @@ -161,6 +162,13 @@ export const StudioList: React.FC = ({ ); } + function renderEditDialog( + selectedStudios: GQL.SlimStudioDataFragment[], + onClose: (applied: boolean) => void + ) { + return ; + } + function renderDeleteDialog( selectedStudios: GQL.SlimStudioDataFragment[], onClose: (confirmed: boolean) => void @@ -193,6 +201,7 @@ export const StudioList: React.FC = ({ otherOperations={otherOperations} addKeybinds={addKeybinds} renderContent={renderContent} + renderEditDialog={renderEditDialog} renderDeleteDialog={renderDeleteDialog} /> diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 51cdd3ca5..be5fb4dbe 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -1907,6 +1907,16 @@ export const useStudioUpdate = () => }, }); +export const useBulkStudioUpdate = () => + GQL.useBulkStudioUpdateMutation({ + update(cache, result) { + if (!result.data?.bulkStudioUpdate) return; + + evictTypeFields(cache, studioMutationImpactedTypeFields); + evictQueries(cache, studioMutationImpactedQueries); + }, + }); + export const useStudioDestroy = (input: GQL.StudioDestroyInput) => GQL.useStudioDestroyMutation({ variables: input, From 9ef216905575badf6e166825d633dec6c14172f6 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 17 Nov 2025 10:13:34 +1100 Subject: [PATCH 132/157] Add edit scene markers dialog (#6239) --- .../graphql/mutations/scene-marker.graphql | 6 + .../Scenes/EditSceneMarkersDialog.tsx | 200 ++++++++++++++++++ .../src/components/Scenes/SceneMarkerList.tsx | 11 + ui/v2.5/src/core/StashService.ts | 10 + 4 files changed, 227 insertions(+) create mode 100644 ui/v2.5/src/components/Scenes/EditSceneMarkersDialog.tsx diff --git a/ui/v2.5/graphql/mutations/scene-marker.graphql b/ui/v2.5/graphql/mutations/scene-marker.graphql index 766e318fc..a2162f799 100644 --- a/ui/v2.5/graphql/mutations/scene-marker.graphql +++ b/ui/v2.5/graphql/mutations/scene-marker.graphql @@ -44,6 +44,12 @@ mutation SceneMarkerUpdate( } } +mutation BulkSceneMarkerUpdate($input: BulkSceneMarkerUpdateInput!) { + bulkSceneMarkerUpdate(input: $input) { + ...SceneMarkerData + } +} + mutation SceneMarkerDestroy($id: ID!) { sceneMarkerDestroy(id: $id) } diff --git a/ui/v2.5/src/components/Scenes/EditSceneMarkersDialog.tsx b/ui/v2.5/src/components/Scenes/EditSceneMarkersDialog.tsx new file mode 100644 index 000000000..bb1d8067b --- /dev/null +++ b/ui/v2.5/src/components/Scenes/EditSceneMarkersDialog.tsx @@ -0,0 +1,200 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useBulkSceneMarkerUpdate } from "src/core/StashService"; +import * as GQL from "src/core/generated-graphql"; +import { ModalComponent } from "../Shared/Modal"; +import { useToast } from "src/hooks/Toast"; +import { MultiSet } from "../Shared/MultiSet"; +import { + getAggregateState, + getAggregateStateObject, +} from "src/utils/bulkUpdate"; +import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput"; +import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; +import { TagSelect } from "../Shared/Select"; + +interface IListOperationProps { + selected: GQL.SceneMarkerDataFragment[]; + onClose: (applied: boolean) => void; +} + +const scenemarkerFields = ["title"]; + +export const EditSceneMarkersDialog: React.FC = ( + props: IListOperationProps +) => { + const intl = useIntl(); + const Toast = useToast(); + + const [updateInput, setUpdateInput] = + useState({ + ids: props.selected.map((scenemarker) => { + return scenemarker.id; + }), + }); + + const [tagIds, setTagIds] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + + const [updateSceneMarkers] = useBulkSceneMarkerUpdate(); + + // Network state + const [isUpdating, setIsUpdating] = useState(false); + + const aggregateState = useMemo(() => { + const updateState: Partial = {}; + const state = props.selected; + let updateTagIds: string[] = []; + let first = true; + + state.forEach((scenemarker: GQL.SceneMarkerDataFragment) => { + getAggregateStateObject( + updateState, + scenemarker, + scenemarkerFields, + first + ); + + // sceneMarker data fragment doesn't have primary_tag_id, so handle separately + updateState.primary_tag_id = getAggregateState( + updateState.primary_tag_id, + scenemarker.primary_tag.id, + first + ); + + const thisTagIDs = (scenemarker.tags ?? []).map((p) => p.id).sort(); + + updateTagIds = getAggregateState(updateTagIds, thisTagIDs, first) ?? []; + + first = false; + }); + + return { state: updateState, tagIds: updateTagIds }; + }, [props.selected]); + + // update initial state from aggregate + useEffect(() => { + setUpdateInput((current) => ({ ...current, ...aggregateState.state })); + }, [aggregateState]); + + function setUpdateField(input: Partial) { + setUpdateInput((current) => ({ ...current, ...input })); + } + + function getSceneMarkerInput(): GQL.BulkSceneMarkerUpdateInput { + const sceneMarkerInput: GQL.BulkSceneMarkerUpdateInput = { + ...updateInput, + tag_ids: tagIds, + }; + + return sceneMarkerInput; + } + + async function onSave() { + setIsUpdating(true); + try { + await updateSceneMarkers({ + variables: { + input: getSceneMarkerInput(), + }, + }); + Toast.success( + intl.formatMessage( + { id: "toast.updated_entity" }, + { + entity: intl.formatMessage({ id: "markers" }).toLocaleLowerCase(), + } + ) + ); + props.onClose(true); + } catch (e) { + Toast.error(e); + } + setIsUpdating(false); + } + + function renderTextField( + name: string, + value: string | undefined | null, + setter: (newValue: string | undefined) => void, + area: boolean = false + ) { + return ( + + + + + setter(newValue)} + unsetDisabled={props.selected.length < 2} + as={area ? "textarea" : undefined} + /> + + ); + } + + function render() { + return ( + props.onClose(false), + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "secondary", + }} + isRunning={isUpdating} + > +
+ {renderTextField("title", updateInput.title, (newValue) => + setUpdateField({ title: newValue }) + )} + + + + + + setUpdateField({ primary_tag_id: t[0]?.id })} + ids={ + updateInput.primary_tag_id ? [updateInput.primary_tag_id] : [] + } + /> + + + + + + + setTagIds((v) => ({ ...v, ids: itemIDs }))} + onSetMode={(newMode) => + setTagIds((v) => ({ ...v, mode: newMode })) + } + existingIds={aggregateState.tagIds ?? []} + ids={tagIds.ids ?? []} + mode={tagIds.mode} + menuPortalTarget={document.body} + /> + +
+
+ ); + } + + return render(); +}; diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index 94eb6e133..dbe6e2e23 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -16,6 +16,7 @@ import { MarkerWallPanel } from "./SceneMarkerWallPanel"; import { View } from "../List/views"; import { SceneMarkerCardsGrid } from "./SceneMarkerCardsGrid"; import { DeleteSceneMarkersDialog } from "./DeleteSceneMarkersDialog"; +import { EditSceneMarkersDialog } from "./EditSceneMarkersDialog"; function getItems(result: GQL.FindSceneMarkersQueryResult) { return result?.data?.findSceneMarkers?.scene_markers ?? []; @@ -114,6 +115,15 @@ export const SceneMarkerList: React.FC = ({ } } + function renderEditDialog( + selectedMarkers: GQL.SceneMarkerDataFragment[], + onClose: (applied: boolean) => void + ) { + return ( + + ); + } + function renderDeleteDialog( selectedSceneMarkers: GQL.SceneMarkerDataFragment[], onClose: (confirmed: boolean) => void @@ -143,6 +153,7 @@ export const SceneMarkerList: React.FC = ({ otherOperations={otherOperations} addKeybinds={addKeybinds} renderContent={renderContent} + renderEditDialog={renderEditDialog} renderDeleteDialog={renderDeleteDialog} /> diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index be5fb4dbe..100f25199 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -1486,6 +1486,16 @@ export const useSceneMarkerUpdate = () => }, }); +export const useBulkSceneMarkerUpdate = () => + GQL.useBulkSceneMarkerUpdateMutation({ + update(cache, result) { + if (!result.data?.bulkSceneMarkerUpdate) return; + + evictTypeFields(cache, sceneMarkerMutationImpactedTypeFields); + evictQueries(cache, sceneMarkerMutationImpactedQueries); + }, + }); + export const useSceneMarkerDestroy = () => GQL.useSceneMarkerDestroyMutation({ update(cache, result, { variables }) { From 0a05a0b45b513754a9d75ec48801769642f06468 Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Mon, 17 Nov 2025 01:29:09 +0200 Subject: [PATCH 133/157] i18n: Change 'Has Chapters' to 'Chapters' (#6279) --- ui/v2.5/src/locales/en-GB.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index f0fe87f61..a780269f1 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1134,7 +1134,7 @@ "syncing": "Syncing with server", "uploading": "Uploading script" }, - "hasChapters": "Has Chapters", + "hasChapters": "Chapters", "hasMarkers": "Has Markers", "height": "Height", "height_cm": "Height (cm)", From a590caa3d3016e8ac3960ec9f1e5dfefd4720ee4 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Sun, 16 Nov 2025 16:20:38 -0800 Subject: [PATCH 134/157] FR: Performer Age Slider (#6267) - Add SidebarAgeFilter component with age presets (18-25, 25-35, 35-45, 45-60, 60+) --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- .../List/Filters/SidebarAgeFilter.tsx | 310 ++++++++++++++++++ ui/v2.5/src/components/List/styles.scss | 6 +- ui/v2.5/src/components/Scenes/SceneList.tsx | 13 +- ui/v2.5/src/components/Shared/styles.scss | 2 +- ui/v2.5/src/models/list-filter/scenes.ts | 5 +- 5 files changed, 331 insertions(+), 5 deletions(-) create mode 100644 ui/v2.5/src/components/List/Filters/SidebarAgeFilter.tsx diff --git a/ui/v2.5/src/components/List/Filters/SidebarAgeFilter.tsx b/ui/v2.5/src/components/List/Filters/SidebarAgeFilter.tsx new file mode 100644 index 000000000..3a6449ab6 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/SidebarAgeFilter.tsx @@ -0,0 +1,310 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { CriterionModifier } from "../../../core/generated-graphql"; +import { CriterionOption } from "../../../models/list-filter/criteria/criterion"; +import { NumberCriterion } from "src/models/list-filter/criteria/criterion"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { Option, SidebarListFilter } from "./SidebarListFilter"; +import { DoubleRangeInput } from "src/components/Shared/DoubleRangeInput"; +import { useDebounce } from "src/hooks/debounce"; + +interface ISidebarFilter { + title?: React.ReactNode; + option: CriterionOption; + filter: ListFilterModel; + setFilter: (f: ListFilterModel) => void; + sectionID?: string; +} + +// Age presets +const AGE_PRESETS = [ + { id: "18-25", label: "18-25", min: 18, max: 25 }, + { id: "25-35", label: "25-35", min: 25, max: 35 }, + { id: "35-45", label: "35-45", min: 35, max: 45 }, + { id: "45-60", label: "45-60", min: 45, max: 60 }, + { id: "60+", label: "60+", min: 60, max: null }, +]; + +const MAX_AGE = 60; // Maximum age for the slider +const MAX_LABEL = "60+"; // Display label for maximum age + +export const SidebarAgeFilter: React.FC = ({ + title, + option, + filter, + setFilter, + sectionID, +}) => { + const criteria = filter.criteriaFor(option.type) as NumberCriterion[]; + const criterion = criteria.length > 0 ? criteria[0] : null; + + // Get current values from criterion + const currentMin = criterion?.value?.value ?? 18; + const currentMax = criterion?.value?.value2 ?? MAX_AGE; + + const [sliderMin, setSliderMin] = useState(currentMin); + const [sliderMax, setSliderMax] = useState(currentMax); + const [minInput, setMinInput] = useState(currentMin.toString()); + const [maxInput, setMaxInput] = useState( + currentMax >= MAX_AGE ? MAX_LABEL : currentMax.toString() + ); + + // Reset slider when criterion is removed externally (via filter tag X) + useEffect(() => { + if (!criterion) { + setSliderMin(18); + setSliderMax(MAX_AGE); + setMinInput("18"); + setMaxInput(MAX_LABEL); + } + }, [criterion]); + + // Determine which preset is selected + const selectedPreset = useMemo(() => { + if (!criterion) return null; + + // Check if current values match any preset + for (const preset of AGE_PRESETS) { + if (preset.max === null) { + // For "60+" preset + if ( + criterion.modifier === CriterionModifier.GreaterThan && + criterion.value.value === preset.min + ) { + return preset.id; + } + } else { + // For range presets + if ( + criterion.modifier === CriterionModifier.Between && + criterion.value.value === preset.min && + criterion.value.value2 === preset.max + ) { + return preset.id; + } + } + } + + // Check if it's a custom range or custom GreaterThan + if ( + criterion.modifier === CriterionModifier.Between || + criterion.modifier === CriterionModifier.GreaterThan + ) { + return "custom"; + } + + return null; + }, [criterion]); + + const options: Option[] = useMemo(() => { + return AGE_PRESETS.map((preset) => ({ + id: preset.id, + label: preset.label, + className: "age-preset", + })); + }, []); + + const selected: Option[] = useMemo(() => { + if (!selectedPreset) return []; + if (selectedPreset === "custom") return []; + + const preset = AGE_PRESETS.find((p) => p.id === selectedPreset); + if (preset) { + return [ + { + id: preset.id, + label: preset.label, + className: "age-preset", + }, + ]; + } + return []; + }, [selectedPreset]); + + function onSelectPreset(item: Option) { + const preset = AGE_PRESETS.find((p) => p.id === item.id); + if (!preset) return; + + setSliderMin(preset.min); + setSliderMax(preset.max ?? MAX_AGE); + setMinInput(preset.min.toString()); + setMaxInput(preset.max === null ? MAX_LABEL : preset.max.toString()); + + const currentCriteria = filter.criteriaFor( + option.type + ) as NumberCriterion[]; + const currentCriterion = + currentCriteria.length > 0 ? currentCriteria[0] : null; + const newCriterion = currentCriterion + ? currentCriterion.clone() + : option.makeCriterion(); + + if (preset.max === null) { + // "60+" - use GreaterThan + newCriterion.modifier = CriterionModifier.GreaterThan; + newCriterion.value.value = preset.min; + newCriterion.value.value2 = undefined; + } else { + // Range preset - use Between + newCriterion.modifier = CriterionModifier.Between; + newCriterion.value.value = preset.min; + newCriterion.value.value2 = preset.max; + } + + setFilter(filter.replaceCriteria(option.type, [newCriterion])); + } + + function onUnselectPreset() { + setSliderMin(18); + setSliderMax(MAX_AGE); + setMinInput("18"); + setMaxInput(MAX_LABEL); + setFilter(filter.removeCriterion(option.type)); + } + + // Parse age input (supports formats like "25", "100+") + function parseAgeInput(input: string): number | null { + const trimmed = input.trim().toLowerCase(); + + if (trimmed === "max" || trimmed === MAX_LABEL.toLowerCase()) { + return MAX_AGE; + } + + const age = parseInt(trimmed); + if (isNaN(age) || age < 18 || age > MAX_AGE) { + return null; + } + + return age; + } + + // Filter update + function updateFilter(min: number, max: number) { + // If slider is at full range (18 to max), remove the filter entirely + if (min === 18 && max >= MAX_AGE) { + setFilter(filter.removeCriterion(option.type)); + return; + } + + const currentCriteria = filter.criteriaFor( + option.type + ) as NumberCriterion[]; + const currentCriterion = + currentCriteria.length > 0 ? currentCriteria[0] : null; + const newCriterion = currentCriterion + ? currentCriterion.clone() + : option.makeCriterion(); + + // If max is at MAX_AGE (but min > 18), use GreaterThan + if (max >= MAX_AGE) { + newCriterion.modifier = CriterionModifier.GreaterThan; + newCriterion.value.value = min; + newCriterion.value.value2 = undefined; + } else { + newCriterion.modifier = CriterionModifier.Between; + newCriterion.value.value = min; + newCriterion.value.value2 = max; + } + + setFilter(filter.replaceCriteria(option.type, [newCriterion])); + } + + const updateFilterDebounceMS = 300; + const debounceUpdateFilter = useDebounce( + updateFilter, + updateFilterDebounceMS + ); + + function handleSliderChange(min: number, max: number) { + setSliderMin(min); + setSliderMax(max); + setMinInput(min.toString()); + setMaxInput(max >= MAX_AGE ? MAX_LABEL : max.toString()); + + debounceUpdateFilter(min, max); + } + + function handleMinInputChange(value: string) { + setMinInput(value); + } + + function handleMaxInputChange(value: string) { + setMaxInput(value); + } + + function handleMinInputBlur() { + const parsed = parseAgeInput(minInput); + if (parsed !== null && parsed >= 18 && parsed < sliderMax) { + handleSliderChange(parsed, sliderMax); + } else { + // Reset to current value if invalid + setMinInput(sliderMin.toString()); + } + } + + function handleMaxInputBlur() { + const parsed = parseAgeInput(maxInput); + if (parsed !== null && parsed > sliderMin && parsed <= MAX_AGE) { + handleSliderChange(sliderMin, parsed); + } else { + // Reset to current value if invalid + setMaxInput(sliderMax >= MAX_AGE ? MAX_LABEL : sliderMax.toString()); + } + } + + const customSlider = ( +
+ handleSliderChange(min, max)} + minInput={ + handleMinInputChange(e.target.value)} + onBlur={handleMinInputBlur} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + } + }} + placeholder="18" + /> + } + maxInput={ + handleMaxInputChange(e.target.value)} + onBlur={handleMaxInputBlur} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + } + }} + placeholder={MAX_LABEL} + /> + } + /> +
+ ); + + return ( + + ); +}; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 1b5b4c6e1..e1397a48e 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -1402,12 +1402,14 @@ input[type="range"].zoom-slider { } // Duration slider styles -.duration-slider { +.duration-slider, +.age-slider-container { padding: 0.5rem 0 1rem; width: 100%; } -.duration-label-input { +.duration-label-input, +.age-label-input { background: transparent; border: 1px solid transparent; border-radius: 0.25rem; diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 14baf7188..729cea05a 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -55,7 +55,11 @@ import { RatingCriterionOption } from "src/models/list-filter/criteria/rating"; import { SidebarRatingFilter } from "../List/Filters/RatingFilter"; import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized"; import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; -import { DurationCriterionOption } from "src/models/list-filter/scenes"; +import { + DurationCriterionOption, + PerformerAgeCriterionOption, +} from "src/models/list-filter/scenes"; +import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter"; import { SidebarDurationFilter } from "../List/Filters/SidebarDurationFilter"; import { FilteredSidebarHeader, @@ -337,6 +341,13 @@ const SidebarContent: React.FC<{ setFilter={setFilter} sectionID="organized" /> + } + option={PerformerAgeCriterionOption} + filter={filter} + setFilter={setFilter} + sectionID="performer_age" + />
diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 8eaa3b90a..2da24774b 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -965,7 +965,7 @@ $sticky-header-height: calc(50px + 3.3rem); margin-bottom: 0.5rem; padding: 0 0.25rem; - .duration-label-input { + input[type="text"] { &:first-child { text-align: left; } diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index 09c60e483..b8dd6515a 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -79,6 +79,9 @@ const displayModeOptions = [ DisplayMode.Tagger, ]; +export const PerformerAgeCriterionOption = + createMandatoryNumberCriterionOption("performer_age"); + export const DurationCriterionOption = createDurationCriterionOption("duration"); @@ -113,7 +116,7 @@ const criterionOptions = [ PerformerTagsCriterionOption, PerformersCriterionOption, createMandatoryNumberCriterionOption("performer_count"), - createMandatoryNumberCriterionOption("performer_age"), + PerformerAgeCriterionOption, PerformerFavoriteCriterionOption, // StudioTagsCriterionOption, StudiosCriterionOption, From bb56b619f594ec493dd99f1ba442c0ceb7be9a22 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Sun, 16 Nov 2025 17:13:13 -0800 Subject: [PATCH 135/157] Add Markers Filter (#6270) --- ui/v2.5/src/components/Scenes/SceneList.tsx | 9 +++++++++ ui/v2.5/src/locales/en-GB.json | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 729cea05a..72f10bb47 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -54,6 +54,7 @@ import cx from "classnames"; import { RatingCriterionOption } from "src/models/list-filter/criteria/rating"; import { SidebarRatingFilter } from "../List/Filters/RatingFilter"; import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized"; +import { HasMarkersCriterionOption } from "src/models/list-filter/criteria/has-markers"; import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter"; import { DurationCriterionOption, @@ -333,6 +334,14 @@ const SidebarContent: React.FC<{ setFilter={setFilter} sectionID="duration" /> + } + data-type={HasMarkersCriterionOption.type} + option={HasMarkersCriterionOption} + filter={filter} + setFilter={setFilter} + sectionID="hasMarkers" + /> } data-type={OrganizedCriterionOption.type} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index a780269f1..08297727a 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1135,7 +1135,7 @@ "uploading": "Uploading script" }, "hasChapters": "Chapters", - "hasMarkers": "Has Markers", + "hasMarkers": "Markers", "height": "Height", "height_cm": "Height (cm)", "help": "Help", From 51999135be14687527750ba12a05abc46fef487a Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 18 Nov 2025 11:13:35 +1100 Subject: [PATCH 136/157] Add SFW content mode option (#6262) * Use more neutral language for content * Add sfw mode setting * Make configuration context mandatory * Add sfw class when sfw mode active * Hide nsfw performer fields in sfw mode * Hide nsfw sort options * Hide nsfw filter/sort options in sfw mode * Replace o-count with like counter in sfw mode * Use sfw label for o-counter filter in sfw mode * Use likes instead of o-count in sfw mode in other places * Rename sfw mode to sfw content mode * Use sfw image for default performers in sfw mode * Document SFW content mode * Add SFW mode setting to setup * Clarify README * Change wording of sfw mode description * Handle configuration loading error correctly * Hide age in performer cards --- README.md | 2 +- graphql/schema/types/config.graphql | 8 + internal/api/images.go | 6 +- internal/api/resolver_mutation_configure.go | 2 + internal/api/resolver_query_configuration.go | 1 + internal/api/routes_performer.go | 7 +- internal/api/server.go | 1 + internal/manager/config/config.go | 13 +- internal/manager/manager.go | 4 + internal/manager/models.go | 1 + internal/static/embed.go | 7 +- internal/static/performer_sfw/performer.svg | 7 + ui/v2.5/graphql/data/config.graphql | 1 + ui/v2.5/src/App.tsx | 54 ++++--- .../src/components/Dialogs/GenerateDialog.tsx | 4 +- ui/v2.5/src/components/FrontPage/Control.tsx | 8 +- .../src/components/FrontPage/FrontPage.tsx | 6 +- .../components/FrontPage/FrontPageConfig.tsx | 8 +- .../Galleries/DeleteGalleriesDialog.tsx | 4 +- .../Galleries/GalleryDetails/Gallery.tsx | 6 +- .../GalleryDetails/GalleryScrapeDialog.tsx | 8 + .../components/Galleries/GallerySelect.tsx | 4 +- ui/v2.5/src/components/Groups/GroupCard.tsx | 13 +- .../components/Groups/GroupDetails/Group.tsx | 4 +- .../Groups/GroupDetails/GroupScrapeDialog.tsx | 10 ++ ui/v2.5/src/components/Groups/GroupSelect.tsx | 4 +- .../components/Images/DeleteImagesDialog.tsx | 4 +- ui/v2.5/src/components/Images/ImageCard.tsx | 13 +- .../components/Images/ImageDetails/Image.tsx | 6 +- .../Images/ImageDetails/ImageScrapeDialog.tsx | 8 + ui/v2.5/src/components/Images/ImageList.tsx | 12 +- .../src/components/List/EditFilterDialog.tsx | 36 +++-- ui/v2.5/src/components/List/FilterTags.tsx | 6 +- .../components/List/Filters/PathFilter.tsx | 4 +- .../components/List/Filters/RatingFilter.tsx | 4 +- ui/v2.5/src/components/List/ItemList.tsx | 14 +- ui/v2.5/src/components/List/ListFilter.tsx | 18 ++- ui/v2.5/src/components/List/util.ts | 14 +- ui/v2.5/src/components/MainNavbar.tsx | 60 ++++---- .../Performers/EditPerformersDialog.tsx | 18 ++- .../components/Performers/PerformerCard.tsx | 17 +-- .../Performers/PerformerDetails/Performer.tsx | 13 +- .../PerformerDetails/PerformerEditPanel.tsx | 4 +- .../PerformerScrapeDialog.tsx | 23 +++ .../Performers/PerformerPopover.tsx | 4 +- .../components/Performers/PerformerSelect.tsx | 4 +- .../components/ScenePlayer/ScenePlayer.tsx | 5 +- .../components/Scenes/DeleteScenesDialog.tsx | 4 +- ui/v2.5/src/components/Scenes/SceneCard.tsx | 19 +-- .../Scenes/SceneDetails/OCounterButton.tsx | 13 +- .../components/Scenes/SceneDetails/Scene.tsx | 7 +- .../Scenes/SceneDetails/SceneEditPanel.tsx | 4 +- .../Scenes/SceneDetails/SceneHistoryPanel.tsx | 18 ++- .../Scenes/SceneDetails/SceneScrapeDialog.tsx | 11 ++ ui/v2.5/src/components/Scenes/SceneList.tsx | 10 +- .../src/components/Scenes/SceneMarkerCard.tsx | 6 +- .../Scenes/SceneMarkerWallPanel.tsx | 12 +- .../components/Scenes/SceneMergeDialog.tsx | 17 +++ ui/v2.5/src/components/Scenes/SceneSelect.tsx | 4 +- .../src/components/Scenes/SceneWallPanel.tsx | 12 +- .../SettingsInterfacePanel.tsx | 8 + .../Settings/Tasks/DataManagementTasks.tsx | 4 +- .../Tasks/DirectorySelectionDialog.tsx | 4 +- .../Settings/Tasks/LibraryTasks.tsx | 4 +- ui/v2.5/src/components/Setup/Setup.tsx | 30 +++- ui/v2.5/src/components/Shared/CountButton.tsx | 13 +- ui/v2.5/src/components/Shared/DetailItem.tsx | 4 +- .../Shared/GridCard/StudioOverlay.tsx | 4 +- .../components/Shared/PopoverCountButton.tsx | 4 +- .../components/Shared/Rating/RatingSystem.tsx | 5 +- .../src/components/Shared/RatingBanner.tsx | 6 +- .../Shared/ScrapeDialog/ScrapeDialog.tsx | 29 +++- .../Shared/ScrapeDialog/ScrapedObjectsRow.tsx | 23 ++- .../Shared/ScrapeDialog/scrapedTags.tsx | 1 + ui/v2.5/src/components/Shared/Select.tsx | 17 ++- ui/v2.5/src/components/Shared/StashID.tsx | 4 +- .../Studios/StudioDetails/Studio.tsx | 4 +- .../src/components/Studios/StudioSelect.tsx | 4 +- ui/v2.5/src/components/Tagger/config.ts | 6 +- ui/v2.5/src/components/Tagger/context.tsx | 4 +- .../components/Tagger/performers/Config.tsx | 4 +- .../Tagger/performers/PerformerTagger.tsx | 4 +- .../components/Tagger/scenes/SceneTagger.tsx | 4 +- .../components/Tagger/scenes/TaggerScene.tsx | 4 +- .../src/components/Tagger/studios/Config.tsx | 4 +- .../Tagger/studios/StudioTagger.tsx | 4 +- .../src/components/Tags/TagDetails/Tag.tsx | 4 +- ui/v2.5/src/components/Tags/TagPopover.tsx | 4 +- ui/v2.5/src/components/Tags/TagSelect.tsx | 4 +- ui/v2.5/src/components/Wall/WallItem.tsx | 4 +- ui/v2.5/src/docs/en/Manual/Interface.md | 9 ++ ui/v2.5/src/hooks/Config.tsx | 23 ++- ui/v2.5/src/hooks/Interactive/context.tsx | 4 +- ui/v2.5/src/hooks/Lightbox/Lightbox.tsx | 4 +- ui/v2.5/src/hooks/useTableColumns.ts | 5 +- ui/v2.5/src/index.scss | 1 + ui/v2.5/src/locales/en-GB.json | 21 ++- .../models/list-filter/criteria/criterion.ts | 44 ++++-- .../src/models/list-filter/filter-options.ts | 3 +- ui/v2.5/src/models/list-filter/groups.ts | 5 +- ui/v2.5/src/models/list-filter/images.ts | 5 +- ui/v2.5/src/models/list-filter/performers.ts | 11 +- ui/v2.5/src/models/list-filter/scenes.ts | 11 +- ui/v2.5/src/sfw-mode.scss | 91 +++++++++++ ui/v2.5/src/utils/form.tsx | 141 ++++++++++++++---- 105 files changed, 843 insertions(+), 370 deletions(-) create mode 100644 internal/static/performer_sfw/performer.svg create mode 100644 ui/v2.5/src/sfw-mode.scss diff --git a/README.md b/README.md index c54d94528..e47363395 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![GitHub release (latest by date)](https://img.shields.io/github/v/release/stashapp/stash?logo=github)](https://github.com/stashapp/stash/releases/latest) [![GitHub issues by-label](https://img.shields.io/github/issues-raw/stashapp/stash/bounty)](https://github.com/stashapp/stash/labels/bounty) -### **Stash is a self-hosted webapp written in Go which organizes and serves your porn.** +### **Stash is a self-hosted webapp written in Go which organizes and serves your diverse content collection, catering to both your SFW and NSFW needs.** ![demo image](docs/readme_assets/demo_image.png) * Stash gathers information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers and sites. diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 4d6d2080b..63ce3ea1c 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -2,6 +2,8 @@ input SetupInput { "Empty to indicate $HOME/.stash/config.yml default" configLocation: String! stashes: [StashConfigInput!]! + "True if SFW content mode is enabled" + sfwContentMode: Boolean "Empty to indicate default" databaseFile: String! "Empty to indicate default" @@ -341,6 +343,9 @@ type ConfigImageLightboxResult { } input ConfigInterfaceInput { + "True if SFW content mode is enabled" + sfwContentMode: Boolean + "Ordered list of items that should be shown in the menu" menuItems: [String!] @@ -407,6 +412,9 @@ type ConfigDisableDropdownCreate { } type ConfigInterfaceResult { + "True if SFW content mode is enabled" + sfwContentMode: Boolean! + "Ordered list of items that should be shown in the menu" menuItems: [String!] diff --git a/internal/api/images.go b/internal/api/images.go index 89a8e87b0..9e16fc0df 100644 --- a/internal/api/images.go +++ b/internal/api/images.go @@ -101,7 +101,7 @@ func initCustomPerformerImages(customPath string) { } } -func getDefaultPerformerImage(name string, gender *models.GenderEnum) []byte { +func getDefaultPerformerImage(name string, gender *models.GenderEnum, sfwMode bool) []byte { // try the custom box first if we have one if performerBoxCustom != nil { ret, err := performerBoxCustom.GetRandomImageByName(name) @@ -111,6 +111,10 @@ func getDefaultPerformerImage(name string, gender *models.GenderEnum) []byte { logger.Warnf("error loading custom default performer image: %v", err) } + if sfwMode { + return static.ReadAll(static.DefaultSFWPerformerImage) + } + var g models.GenderEnum if gender != nil { g = *gender diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index d9c71b09f..ba46a115a 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -445,6 +445,8 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigInterfaceInput) (*ConfigInterfaceResult, error) { c := config.GetInstance() + r.setConfigBool(config.SFWContentMode, input.SfwContentMode) + if input.MenuItems != nil { c.SetInterface(config.MenuItems, input.MenuItems) } diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index cfa22720b..5952dd41e 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -162,6 +162,7 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult { disableDropdownCreate := config.GetDisableDropdownCreate() return &ConfigInterfaceResult{ + SfwContentMode: config.GetSFWContentMode(), MenuItems: menuItems, SoundOnPreview: &soundOnPreview, WallShowTitle: &wallShowTitle, diff --git a/internal/api/routes_performer.go b/internal/api/routes_performer.go index b27fdbd6c..8d5463d63 100644 --- a/internal/api/routes_performer.go +++ b/internal/api/routes_performer.go @@ -18,9 +18,14 @@ type PerformerFinder interface { GetImage(ctx context.Context, performerID int) ([]byte, error) } +type sfwConfig interface { + GetSFWContentMode() bool +} + type performerRoutes struct { routes performerFinder PerformerFinder + sfwConfig sfwConfig } func (rs performerRoutes) Routes() chi.Router { @@ -54,7 +59,7 @@ func (rs performerRoutes) Image(w http.ResponseWriter, r *http.Request) { } if len(image) == 0 { - image = getDefaultPerformerImage(performer.Name, performer.Gender) + image = getDefaultPerformerImage(performer.Name, performer.Gender, rs.sfwConfig.GetSFWContentMode()) } utils.ServeImage(w, r, image) diff --git a/internal/api/server.go b/internal/api/server.go index 5059e9a2a..9290c6512 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -322,6 +322,7 @@ func (s *Server) getPerformerRoutes() chi.Router { return performerRoutes{ routes: routes{txnManager: repo.TxnManager}, performerFinder: repo.Performer, + sfwConfig: s.manager.Config, }.Routes() } diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 65e1bad51..a351cc872 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -43,6 +43,9 @@ const ( Password = "password" MaxSessionAge = "max_session_age" + // SFWContentMode mode config key + SFWContentMode = "sfw_content_mode" + FFMpegPath = "ffmpeg_path" FFProbePath = "ffprobe_path" @@ -628,7 +631,15 @@ func (i *Config) getStringMapString(key string) map[string]string { return ret } -// GetStathPaths returns the configured stash library paths. +// GetSFW returns true if SFW mode is enabled. +// Default performer images are changed to more agnostic images when enabled. +func (i *Config) GetSFWContentMode() bool { + i.RLock() + defer i.RUnlock() + return i.getBool(SFWContentMode) +} + +// GetStashPaths returns the configured stash library paths. // Works opposite to the usual case - it will return the override // value only if the main value is not set. func (i *Config) GetStashPaths() StashConfigs { diff --git a/internal/manager/manager.go b/internal/manager/manager.go index ca70b1c13..2d47fd907 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -262,6 +262,10 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error { cfg.SetString(config.Cache, input.CacheLocation) } + if input.SFWContentMode { + cfg.SetBool(config.SFWContentMode, true) + } + if input.StoreBlobsInDatabase { cfg.SetInterface(config.BlobsStorage, config.BlobStorageTypeDatabase) } else { diff --git a/internal/manager/models.go b/internal/manager/models.go index 3e96e6182..b7c7232c5 100644 --- a/internal/manager/models.go +++ b/internal/manager/models.go @@ -21,6 +21,7 @@ type SetupInput struct { // Empty to indicate $HOME/.stash/config.yml default ConfigLocation string `json:"configLocation"` Stashes []*config.StashConfigInput `json:"stashes"` + SFWContentMode bool `json:"sfwContentMode"` // Empty to indicate default DatabaseFile string `json:"databaseFile"` // Empty to indicate default diff --git a/internal/static/embed.go b/internal/static/embed.go index 91437a81f..665c5a892 100644 --- a/internal/static/embed.go +++ b/internal/static/embed.go @@ -8,12 +8,13 @@ import ( "io/fs" ) -//go:embed performer performer_male scene image gallery tag studio group +//go:embed performer performer_male performer_sfw scene image gallery tag studio group var data embed.FS const ( - Performer = "performer" - PerformerMale = "performer_male" + Performer = "performer" + PerformerMale = "performer_male" + DefaultSFWPerformerImage = "performer_sfw/performer.svg" Scene = "scene" DefaultSceneImage = "scene/scene.svg" diff --git a/internal/static/performer_sfw/performer.svg b/internal/static/performer_sfw/performer.svg new file mode 100644 index 000000000..24b444171 --- /dev/null +++ b/internal/static/performer_sfw/performer.svg @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/ui/v2.5/graphql/data/config.graphql b/ui/v2.5/graphql/data/config.graphql index c0bcda821..95d55864f 100644 --- a/ui/v2.5/graphql/data/config.graphql +++ b/ui/v2.5/graphql/data/config.graphql @@ -71,6 +71,7 @@ fragment ConfigGeneralData on ConfigGeneralResult { } fragment ConfigInterfaceData on ConfigInterfaceResult { + sfwContentMode menuItems soundOnPreview wallShowTitle diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index 005d101aa..a8b92ecc3 100644 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -31,7 +31,10 @@ import * as GQL from "./core/generated-graphql"; import { makeTitleProps } from "./hooks/title"; import { LoadingIndicator } from "./components/Shared/LoadingIndicator"; -import { ConfigurationProvider } from "./hooks/Config"; +import { + ConfigurationProvider, + useConfigurationContextOptional, +} from "./hooks/Config"; import { ManualProvider } from "./components/Help/context"; import { InteractiveProvider } from "./hooks/Interactive/context"; import { ReleaseNotesDialog } from "./components/Dialogs/ReleaseNotesDialog"; @@ -50,6 +53,7 @@ import { PatchFunction } from "./patch"; import moment from "moment/min/moment-with-locales"; import { ErrorMessage } from "./components/Shared/ErrorMessage"; +import cx from "classnames"; const Performers = lazyComponent( () => import("./components/Performers/Performers") @@ -104,8 +108,17 @@ const AppContainer: React.FC> = PatchFunction( ) as React.FC; const MainContainer: React.FC = ({ children }) => { + // use optional here because the configuration may have be loading or errored + const { configuration } = useConfigurationContextOptional() || {}; + const { sfwContentMode } = configuration?.interface || {}; + return ( -
+
{children}
); @@ -300,28 +313,36 @@ export const App: React.FC = () => { return null; } - if (config.error) { + function renderSimple(content: React.ReactNode) { return ( - - - } - error={config.error.message} - /> - + {content} ); } + if (config.loading) { + return renderSimple(); + } + + if (config.error) { + return renderSimple( + + } + error={config.error.message} + /> + ); + } + return ( { - + {maybeRenderReleaseNotes()} }> diff --git a/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx b/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx index 669bc8aa4..5afdb0b8e 100644 --- a/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx +++ b/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx @@ -6,7 +6,7 @@ import { Icon } from "src/components/Shared/Icon"; import { useToast } from "src/hooks/Toast"; import * as GQL from "src/core/generated-graphql"; import { FormattedMessage, useIntl } from "react-intl"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { Manual } from "../Help/Manual"; import { withoutTypename } from "src/utils/data"; import { GenerateOptions } from "../Settings/Tasks/GenerateOptions"; @@ -25,7 +25,7 @@ export const GenerateDialog: React.FC = ({ onClose, type, }) => { - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); function getDefaultOptions(): GQL.GenerateMetadataInput { return { diff --git a/ui/v2.5/src/components/FrontPage/Control.tsx b/ui/v2.5/src/components/FrontPage/Control.tsx index 7ff31bbac..72f84516f 100644 --- a/ui/v2.5/src/components/FrontPage/Control.tsx +++ b/ui/v2.5/src/components/FrontPage/Control.tsx @@ -1,9 +1,9 @@ -import React, { useContext, useMemo } from "react"; +import React, { useMemo } from "react"; import { useIntl } from "react-intl"; import { FrontPageContent, ICustomFilter } from "src/core/config"; import * as GQL from "src/core/generated-graphql"; import { useFindSavedFilter } from "src/core/StashService"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { ListFilterModel } from "src/models/list-filter/filter"; import { GalleryRecommendationRow } from "../Galleries/GalleryRecommendationRow"; import { ImageRecommendationRow } from "../Images/ImageRecommendationRow"; @@ -105,7 +105,7 @@ interface ISavedFilterResults { const SavedFilterResults: React.FC = ({ savedFilterID, }) => { - const { configuration: config } = useContext(ConfigurationContext); + const { configuration: config } = useConfigurationContext(); const { loading, data } = useFindSavedFilter(savedFilterID.toString()); const filter = useMemo(() => { @@ -136,7 +136,7 @@ interface ICustomFilterProps { const CustomFilterResults: React.FC = ({ customFilter, }) => { - const { configuration: config } = useContext(ConfigurationContext); + const { configuration: config } = useConfigurationContext(); const intl = useIntl(); const filter = useMemo(() => { diff --git a/ui/v2.5/src/components/FrontPage/FrontPage.tsx b/ui/v2.5/src/components/FrontPage/FrontPage.tsx index 89b4db468..12e56f6ab 100644 --- a/ui/v2.5/src/components/FrontPage/FrontPage.tsx +++ b/ui/v2.5/src/components/FrontPage/FrontPage.tsx @@ -6,7 +6,7 @@ import { Button } from "react-bootstrap"; import { FrontPageConfig } from "./FrontPageConfig"; import { useToast } from "src/hooks/Toast"; import { Control } from "./Control"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { FrontPageContent, generateDefaultFrontPageContent, @@ -24,7 +24,7 @@ const FrontPage: React.FC = PatchComponent("FrontPage", () => { const [saveUI] = useConfigureUI(); - const { configuration, loading } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); useScrollToTopOnMount(); @@ -51,7 +51,7 @@ const FrontPage: React.FC = PatchComponent("FrontPage", () => { setSaving(false); } - if (loading || saving) { + if (saving) { return ; } diff --git a/ui/v2.5/src/components/FrontPage/FrontPageConfig.tsx b/ui/v2.5/src/components/FrontPage/FrontPageConfig.tsx index 2f72d0740..33e6c066a 100644 --- a/ui/v2.5/src/components/FrontPage/FrontPageConfig.tsx +++ b/ui/v2.5/src/components/FrontPage/FrontPageConfig.tsx @@ -4,7 +4,7 @@ import { useFindSavedFilters } from "src/core/StashService"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { Button, Form, Modal } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { ISavedFilterRow, ICustomFilter, @@ -277,11 +277,11 @@ interface IFrontPageConfigProps { export const FrontPageConfig: React.FC = ({ onClose, }) => { - const { configuration, loading } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const ui = configuration?.ui; - const { data: allFilters, loading: loading2 } = useFindSavedFilters(); + const { data: allFilters, loading } = useFindSavedFilters(); const [isAdd, setIsAdd] = useState(false); const [currentContent, setCurrentContent] = useState([]); @@ -338,7 +338,7 @@ export const FrontPageConfig: React.FC = ({ setDragIndex(undefined); } - if (loading || loading2) { + if (loading) { return ; } diff --git a/ui/v2.5/src/components/Galleries/DeleteGalleriesDialog.tsx b/ui/v2.5/src/components/Galleries/DeleteGalleriesDialog.tsx index 2feaa0f1e..0e50c16b8 100644 --- a/ui/v2.5/src/components/Galleries/DeleteGalleriesDialog.tsx +++ b/ui/v2.5/src/components/Galleries/DeleteGalleriesDialog.tsx @@ -4,7 +4,7 @@ import { useGalleryDestroy } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "../Shared/Modal"; import { useToast } from "src/hooks/Toast"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { FormattedMessage, useIntl } from "react-intl"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; @@ -33,7 +33,7 @@ export const DeleteGalleriesDialog: React.FC = ( { count: props.selected.length, singularEntity, pluralEntity } ); - const { configuration: config } = React.useContext(ConfigurationContext); + const { configuration: config } = useConfigurationContext(); const [deleteFile, setDeleteFile] = useState( config?.defaults.deleteFile ?? false diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index 5d7cdeb51..9cee2d1e2 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -1,5 +1,5 @@ import { Button, Tab, Nav, Dropdown } from "react-bootstrap"; -import React, { useContext, useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { useHistory, Link, @@ -41,7 +41,7 @@ import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import cx from "classnames"; import { useRatingKeybinds } from "src/hooks/keybinds"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { goBackOrReplace } from "src/utils/history"; @@ -59,7 +59,7 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { const history = useHistory(); const Toast = useToast(); const intl = useIntl(); - const { configuration } = useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters); const [collapsed, setCollapsed] = useState(false); diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx index 5fe20b7b0..fbfde9f97 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx @@ -161,32 +161,38 @@ export const GalleryScrapeDialog: React.FC = ({ return ( <> setTitle(value)} /> setCode(value)} /> setURLs(value)} /> setDate(value)} /> setPhotographer(value)} /> setStudio(value)} @@ -194,6 +200,7 @@ export const GalleryScrapeDialog: React.FC = ({ onCreateNew={createNewStudio} /> setPerformers(value)} @@ -203,6 +210,7 @@ export const GalleryScrapeDialog: React.FC = ({ /> {scrapedTagsRow} setDetails(value)} diff --git a/ui/v2.5/src/components/Galleries/GallerySelect.tsx b/ui/v2.5/src/components/Galleries/GallerySelect.tsx index 4cd8825bb..c76266cf7 100644 --- a/ui/v2.5/src/components/Galleries/GallerySelect.tsx +++ b/ui/v2.5/src/components/Galleries/GallerySelect.tsx @@ -12,7 +12,7 @@ import { queryFindGalleriesForSelect, queryFindGalleriesByIDForSelect, } from "src/core/StashService"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { useIntl } from "react-intl"; import { defaultMaxOptionsShown } from "src/core/config"; import { ListFilterModel } from "src/models/list-filter/filter"; @@ -70,7 +70,7 @@ const gallerySelectSort = PatchFunction( const _GallerySelect: React.FC< IFilterProps & IFilterValueProps & ExtraGalleryProps > = (props) => { - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const intl = useIntl(); const maxOptionsShown = configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown; diff --git a/ui/v2.5/src/components/Groups/GroupCard.tsx b/ui/v2.5/src/components/Groups/GroupCard.tsx index 87a594446..7412c986a 100644 --- a/ui/v2.5/src/components/Groups/GroupCard.tsx +++ b/ui/v2.5/src/components/Groups/GroupCard.tsx @@ -10,7 +10,7 @@ import { FormattedMessage } from "react-intl"; import { RatingBanner } from "../Shared/RatingBanner"; import { faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons"; import { RelatedGroupPopoverButton } from "./RelatedGroupPopover"; -import { SweatDrops } from "../Shared/SweatDrops"; +import { OCounterButton } from "../Shared/CountButton"; const Description: React.FC<{ sceneNumber?: number; @@ -111,16 +111,7 @@ export const GroupCard: React.FC = ({ function maybeRenderOCounter() { if (!group.o_counter) return; - return ( -
- -
- ); + return ; } function maybeRenderPopoverButtonGroup() { diff --git a/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx b/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx index b48f3b98c..b2b3d8176 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx @@ -23,7 +23,7 @@ import { import { GroupEditPanel } from "./GroupEditPanel"; import { faRefresh, faTrashAlt } from "@fortawesome/free-solid-svg-icons"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { DetailImage } from "src/components/Shared/DetailImage"; import { useRatingKeybinds } from "src/hooks/keybinds"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; @@ -146,7 +146,7 @@ const GroupPage: React.FC = ({ group, tabKey }) => { const Toast = useToast(); // Configuration settings - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const uiConfig = configuration?.ui; const enableBackgroundImage = uiConfig?.enableMovieBackgroundImage ?? false; const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false; diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupScrapeDialog.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupScrapeDialog.tsx index bdb5d6ad5..d37210c43 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupScrapeDialog.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupScrapeDialog.tsx @@ -149,37 +149,44 @@ export const GroupScrapeDialog: React.FC = ({ return ( <> setName(value)} /> setAliases(value)} /> setDuration(value)} /> setDate(value)} /> setDirector(value)} /> setSynopsis(value)} /> setStudio(value)} @@ -187,18 +194,21 @@ export const GroupScrapeDialog: React.FC = ({ onCreateNew={createNewStudio} /> setURLs(value)} /> {scrapedTagsRow} setFrontImage(value)} /> = PatchComponent("GroupSelect", (props) => { const [createGroup] = useGroupCreate(); - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const intl = useIntl(); const maxOptionsShown = configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown; diff --git a/ui/v2.5/src/components/Images/DeleteImagesDialog.tsx b/ui/v2.5/src/components/Images/DeleteImagesDialog.tsx index 36a3ead3c..ec442a5ca 100644 --- a/ui/v2.5/src/components/Images/DeleteImagesDialog.tsx +++ b/ui/v2.5/src/components/Images/DeleteImagesDialog.tsx @@ -4,7 +4,7 @@ import { useImagesDestroy } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "src/components/Shared/Modal"; import { useToast } from "src/hooks/Toast"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { FormattedMessage, useIntl } from "react-intl"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; @@ -33,7 +33,7 @@ export const DeleteImagesDialog: React.FC = ( { count: props.selected.length, singularEntity, pluralEntity } ); - const { configuration: config } = React.useContext(ConfigurationContext); + const { configuration: config } = useConfigurationContext(); const [deleteFile, setDeleteFile] = useState( config?.defaults.deleteFile ?? false diff --git a/ui/v2.5/src/components/Images/ImageCard.tsx b/ui/v2.5/src/components/Images/ImageCard.tsx index 9a8c86a10..a22e48139 100644 --- a/ui/v2.5/src/components/Images/ImageCard.tsx +++ b/ui/v2.5/src/components/Images/ImageCard.tsx @@ -5,7 +5,6 @@ import * as GQL from "src/core/generated-graphql"; import { Icon } from "src/components/Shared/Icon"; import { GalleryLink, TagLink } from "src/components/Shared/TagLink"; import { HoverPopover } from "src/components/Shared/HoverPopover"; -import { SweatDrops } from "src/components/Shared/SweatDrops"; import { PerformerPopoverButton } from "src/components/Shared/PerformerPopoverButton"; import { GridCard } from "src/components/Shared/GridCard/GridCard"; import { RatingBanner } from "src/components/Shared/RatingBanner"; @@ -18,6 +17,7 @@ import { import { imageTitle } from "src/core/files"; import { TruncatedText } from "../Shared/TruncatedText"; import { StudioOverlay } from "../Shared/GridCard/StudioOverlay"; +import { OCounterButton } from "../Shared/CountButton"; interface IImageCardProps { image: GQL.SlimImageDataFragment; @@ -74,16 +74,7 @@ export const ImageCard: React.FC = ( function maybeRenderOCounter() { if (props.image.o_counter) { - return ( -
- -
- ); + return ; } } diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index 3b77d2eef..699d3d4e4 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -1,5 +1,5 @@ import { Tab, Nav, Dropdown } from "react-bootstrap"; -import React, { useContext, useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { FormattedDate, FormattedMessage, useIntl } from "react-intl"; import { useHistory, Link, RouteComponentProps } from "react-router-dom"; import { Helmet } from "react-helmet"; @@ -29,7 +29,7 @@ import { imagePath, imageTitle } from "src/core/files"; import { isVideo } from "src/utils/visualFile"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { useRatingKeybinds } from "src/hooks/keybinds"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import TextUtils from "src/utils/text"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import cx from "classnames"; @@ -48,7 +48,7 @@ const ImagePage: React.FC = ({ image }) => { const history = useHistory(); const Toast = useToast(); const intl = useIntl(); - const { configuration } = useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const [incrementO] = useImageIncrementO(image.id); const [decrementO] = useImageDecrementO(image.id); diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageScrapeDialog.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageScrapeDialog.tsx index cc7dffe66..44b112078 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageScrapeDialog.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageScrapeDialog.tsx @@ -163,32 +163,38 @@ export const ImageScrapeDialog: React.FC = ({ return ( <> setTitle(value)} /> setCode(value)} /> setURLs(value)} /> setDate(value)} /> setPhotographer(value)} /> setStudio(value)} @@ -196,6 +202,7 @@ export const ImageScrapeDialog: React.FC = ({ onCreateNew={createNewStudio} /> setPerformers(value)} @@ -204,6 +211,7 @@ export const ImageScrapeDialog: React.FC = ({ /> {scrapedTagsRow} setDetails(value)} diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index dc1f9b1e1..df10ff4b5 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -1,10 +1,4 @@ -import React, { - useCallback, - useState, - useMemo, - MouseEvent, - useContext, -} from "react"; +import React, { useCallback, useState, useMemo, MouseEvent } from "react"; import { FormattedNumber, useIntl } from "react-intl"; import cloneDeep from "lodash-es/cloneDeep"; import { useHistory } from "react-router-dom"; @@ -23,7 +17,7 @@ import "flexbin/flexbin.css"; import Gallery, { RenderImageProps } from "react-photo-gallery"; import { ExportDialog } from "../Shared/ExportDialog"; import { objectTitle } from "src/core/files"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { ImageGridCard } from "./ImageGridCard"; import { View } from "../List/views"; import { IItemListOperation } from "../List/FilteredListToolbar"; @@ -51,7 +45,7 @@ const ImageWall: React.FC = ({ zoomIndex, handleImageOpen, }) => { - const { configuration } = useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const uiConfig = configuration?.ui; const containerRef = React.useRef(null); diff --git a/ui/v2.5/src/components/List/EditFilterDialog.tsx b/ui/v2.5/src/components/List/EditFilterDialog.tsx index 5f6d43004..3f0f486b8 100644 --- a/ui/v2.5/src/components/List/EditFilterDialog.tsx +++ b/ui/v2.5/src/components/List/EditFilterDialog.tsx @@ -1,7 +1,6 @@ import cloneDeep from "lodash-es/cloneDeep"; import React, { useCallback, - useContext, useEffect, useMemo, useRef, @@ -14,7 +13,7 @@ import { CriterionOption, } from "src/models/list-filter/criteria/criterion"; import { FormattedMessage, useIntl } from "react-intl"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { ListFilterModel } from "src/models/list-filter/filter"; import { getFilterOptions } from "src/models/list-filter/factory"; import { FilterTags } from "./FilterTags"; @@ -65,6 +64,9 @@ const CriterionOptionList: React.FC = ({ onTogglePin, externallySelected = false, }) => { + const { configuration } = useConfigurationContext(); + const { sfwContentMode } = configuration.interface; + const prevCriterion = usePrevious(currentCriterion); const scrolled = useRef(false); @@ -148,7 +150,9 @@ const CriterionOptionList: React.FC = ({ className="collapse-icon fa-fw" icon={type === c.type ? faChevronDown : faChevronRight} /> - + {criteria.some((cc) => c.type === cc) && ( - - - ))} - - - - + + {menuItems.map(({ href, icon, message }) => ( + + + + + + ))} + + diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx index 71fcbedd9..677ac3aa1 100644 --- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -27,6 +27,8 @@ import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; import * as FormUtils from "src/utils/form"; import { CountrySelect } from "../Shared/CountrySelect"; +import { useConfigurationContext } from "src/hooks/Config"; +import cx from "classnames"; interface IListOperationProps { selected: GQL.SlimPerformerDataFragment[]; @@ -61,6 +63,10 @@ export const EditPerformersDialog: React.FC = ( ) => { const intl = useIntl(); const Toast = useToast(); + + const { configuration } = useConfigurationContext(); + const { sfwContentMode } = configuration.interface; + const [tagIds, setTagIds] = useState({ mode: GQL.BulkUpdateIdMode.Add, }); @@ -204,7 +210,7 @@ export const EditPerformersDialog: React.FC = ( setter: (newValue: string | undefined) => void ) { return ( - + @@ -218,9 +224,13 @@ export const EditPerformersDialog: React.FC = ( } function render() { + // sfw class needs to be set because it is outside body + return ( = ( }} isRunning={isUpdating} > - + {FormUtils.renderLabel({ title: intl.formatMessage({ id: "rating" }), })} @@ -322,7 +332,7 @@ export const EditPerformersDialog: React.FC = ( setPenisLength(v) )} - + diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index 02c304547..5f7a26d42 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -6,7 +6,6 @@ import NavUtils from "src/utils/navigation"; import TextUtils from "src/utils/text"; import { GridCard } from "../Shared/GridCard/GridCard"; import { CountryFlag } from "../Shared/CountryFlag"; -import { SweatDrops } from "../Shared/SweatDrops"; import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; import { TagLink } from "../Shared/TagLink"; @@ -25,7 +24,8 @@ import { ILabeledId } from "src/models/list-filter/types"; import { FavoriteIcon } from "../Shared/FavoriteIcon"; import { PatchComponent } from "src/patch"; import { ExternalLinksButton } from "../Shared/ExternalLinksButton"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; +import { OCounterButton } from "../Shared/CountButton"; export interface IPerformerCardExtraCriteria { scenes?: ModifierCriterion[]; @@ -103,16 +103,7 @@ const PerformerCardPopovers: React.FC = PatchComponent( function maybeRenderOCounter() { if (!performer.o_counter) return; - return ( -
- -
- ); + return ; } function maybeRenderTagPopoverButton() { @@ -179,7 +170,7 @@ const PerformerCardPopovers: React.FC = PatchComponent( const PerformerCardOverlays: React.FC = PatchComponent( "PerformerCard.Overlays", ({ performer }) => { - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const uiConfig = configuration?.ui; const [updatePerformer] = usePerformerUpdate(); diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index 0a1535068..dd72d0025 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -16,7 +16,7 @@ import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useToast } from "src/hooks/Toast"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { CompressedPerformerDetailsPanel, @@ -42,13 +42,13 @@ import { import { DetailTitle } from "src/components/Shared/DetailsPage/DetailTitle"; import { ExpandCollapseButton } from "src/components/Shared/CollapseButton"; import { FavoriteIcon } from "src/components/Shared/FavoriteIcon"; -import { SweatDrops } from "src/components/Shared/SweatDrops"; import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; import { LightboxLink } from "src/hooks/Lightbox/LightboxLink"; import { PatchComponent } from "src/patch"; import { ILightboxImage } from "src/hooks/Lightbox/types"; import { goBackOrReplace } from "src/utils/history"; +import { OCounterButton } from "src/components/Shared/CountButton"; interface IProps { performer: GQL.PerformerDataFragment; @@ -240,7 +240,7 @@ const PerformerPage: React.FC = PatchComponent( const intl = useIntl(); // Configuration settings - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const uiConfig = configuration?.ui; const abbreviateCounter = uiConfig?.abbreviateCounters ?? false; const enableBackgroundImage = @@ -432,12 +432,7 @@ const PerformerPage: React.FC = PatchComponent( withoutContext /> {!!performer.o_counter && ( - - - - - {performer.o_counter} - + )}
{!isEditing && ( diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index a3f128fee..597cbad1c 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -30,7 +30,7 @@ import { stringCircumMap, stringToCircumcised, } from "src/utils/circumcised"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { PerformerScrapeDialog } from "./PerformerScrapeDialog"; import PerformerScrapeModal from "./PerformerScrapeModal"; import PerformerStashBoxModal, { IStashBox } from "./PerformerStashBoxModal"; @@ -97,7 +97,7 @@ export const PerformerEditPanel: React.FC = ({ const [scrapedPerformer, setScrapedPerformer] = useState(); - const { configuration: stashConfig } = React.useContext(ConfigurationContext); + const { configuration: stashConfig } = useConfigurationContext(); const intl = useIntl(); diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index 0398f1eec..ad7e44d6d 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -63,6 +63,7 @@ function renderScrapedGenderRow( ) { return ( renderScrapedGender(result)} @@ -113,6 +114,7 @@ function renderScrapedCircumcisedRow( return ( renderScrapedCircumcised(result)} renderNewField={() => @@ -401,16 +403,19 @@ export const PerformerScrapeDialog: React.FC = ( return ( <> setName(value)} /> setDisambiguation(value)} /> setAliases(value)} @@ -421,46 +426,55 @@ export const PerformerScrapeDialog: React.FC = ( (value) => setGender(value) )} setBirthdate(value)} /> setDeathDate(value)} /> setEthnicity(value)} /> setCountry(value)} /> setHairColor(value)} /> setEyeColor(value)} /> setWeight(value)} /> setHeight(value)} /> setPenisLength(value)} @@ -471,42 +485,50 @@ export const PerformerScrapeDialog: React.FC = ( (value) => setCircumcised(value) )} setMeasurements(value)} /> setFakeTits(value)} /> setCareerLength(value)} /> setTattoos(value)} /> setPiercings(value)} /> setURLs(value)} /> setDetails(value)} /> {scrapedTagsRow} = ( onChange={(value) => setImage(value)} /> = ({ placement = "top", target, }) => { - const { configuration: config } = React.useContext(ConfigurationContext); + const { configuration: config } = useConfigurationContext(); const showPerformerCardOnHover = config?.ui.showTagCardOnHover ?? true; diff --git a/ui/v2.5/src/components/Performers/PerformerSelect.tsx b/ui/v2.5/src/components/Performers/PerformerSelect.tsx index ed7b7b303..f10519897 100644 --- a/ui/v2.5/src/components/Performers/PerformerSelect.tsx +++ b/ui/v2.5/src/components/Performers/PerformerSelect.tsx @@ -13,7 +13,7 @@ import { queryFindPerformersByIDForSelect, queryFindPerformersForSelect, } from "src/core/StashService"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { useIntl } from "react-intl"; import { defaultMaxOptionsShown } from "src/core/config"; import { ListFilterModel } from "src/models/list-filter/filter"; @@ -82,7 +82,7 @@ const _PerformerSelect: React.FC< > = (props) => { const [createPerformer] = usePerformerCreate(); - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const intl = useIntl(); const maxOptionsShown = configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown; diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 4440f80df..e07c0091d 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -1,7 +1,6 @@ import React, { KeyboardEvent, useCallback, - useContext, useEffect, useMemo, useRef, @@ -31,7 +30,7 @@ import { import * as GQL from "src/core/generated-graphql"; import { ScenePlayerScrubber } from "./ScenePlayerScrubber"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { ConnectionState, InteractiveContext, @@ -240,7 +239,7 @@ export const ScenePlayer: React.FC = PatchComponent( onNext, onPrevious, }) => { - const { configuration } = useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const interfaceConfig = configuration?.interface; const uiConfig = configuration?.ui; const videoRef = useRef(null); diff --git a/ui/v2.5/src/components/Scenes/DeleteScenesDialog.tsx b/ui/v2.5/src/components/Scenes/DeleteScenesDialog.tsx index 88f133a80..3cf9b7ecf 100644 --- a/ui/v2.5/src/components/Scenes/DeleteScenesDialog.tsx +++ b/ui/v2.5/src/components/Scenes/DeleteScenesDialog.tsx @@ -4,7 +4,7 @@ import { useScenesDestroy } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { ModalComponent } from "src/components/Shared/Modal"; import { useToast } from "src/hooks/Toast"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { FormattedMessage, useIntl } from "react-intl"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; import { objectPath } from "src/core/files"; @@ -34,7 +34,7 @@ export const DeleteScenesDialog: React.FC = ( { count: props.selected.length, singularEntity, pluralEntity } ); - const { configuration: config } = React.useContext(ConfigurationContext); + const { configuration: config } = useConfigurationContext(); const [deleteFile, setDeleteFile] = useState( config?.defaults.deleteFile ?? false diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index 99b910f67..2cb4a9af3 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -6,12 +6,11 @@ import * as GQL from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; import { GalleryLink, TagLink, SceneMarkerLink } from "../Shared/TagLink"; import { HoverPopover } from "../Shared/HoverPopover"; -import { SweatDrops } from "../Shared/SweatDrops"; import { TruncatedText } from "../Shared/TruncatedText"; import NavUtils from "src/utils/navigation"; import TextUtils from "src/utils/text"; import { SceneQueue } from "src/models/sceneQueue"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton"; import { GridCard } from "../Shared/GridCard/GridCard"; import { RatingBanner } from "../Shared/RatingBanner"; @@ -30,6 +29,7 @@ import { PatchComponent } from "src/patch"; import { StudioOverlay } from "../Shared/GridCard/StudioOverlay"; import { GroupTag } from "../Groups/GroupTag"; import { FileSize } from "../Shared/FileSize"; +import { OCounterButton } from "../Shared/CountButton"; interface IScenePreviewProps { isPortrait: boolean; @@ -218,16 +218,7 @@ const SceneCardPopovers = PatchComponent( function maybeRenderOCounter() { if (props.scene.o_counter) { - return ( -
- -
- ); + return ; } } @@ -353,7 +344,7 @@ const SceneCardImage = PatchComponent( "SceneCard.Image", (props: ISceneCardProps) => { const history = useHistory(); - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const cont = configuration?.interface.continuePlaylistDefault ?? false; const file = useMemo( @@ -437,7 +428,7 @@ const SceneCardImage = PatchComponent( export const SceneCard = PatchComponent( "SceneCard", (props: ISceneCardProps) => { - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const file = useMemo( () => (props.scene.files.length > 0 ? props.scene.files[0] : undefined), diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/OCounterButton.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/OCounterButton.tsx index 8fdb7dfd7..d8963df4d 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/OCounterButton.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/OCounterButton.tsx @@ -1,10 +1,11 @@ -import { faBan, faMinus } from "@fortawesome/free-solid-svg-icons"; +import { faBan, faMinus, faThumbsUp } from "@fortawesome/free-solid-svg-icons"; import React, { useState } from "react"; import { Button, ButtonGroup, Dropdown, DropdownButton } from "react-bootstrap"; import { useIntl } from "react-intl"; import { Icon } from "src/components/Shared/Icon"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { SweatDrops } from "src/components/Shared/SweatDrops"; +import { useConfigurationContext } from "src/hooks/Config"; export interface IOCounterButtonProps { value: number; @@ -17,6 +18,12 @@ export const OCounterButton: React.FC = ( props: IOCounterButtonProps ) => { const intl = useIntl(); + const { configuration } = useConfigurationContext(); + const { sfwContentMode } = configuration.interface; + + const icon = !sfwContentMode ? : ; + const messageID = !sfwContentMode ? "o_count" : "o_count_sfw"; + const [loading, setLoading] = useState(false); async function increment() { @@ -44,9 +51,9 @@ export const OCounterButton: React.FC = ( className="minimal pr-1" onClick={increment} variant="secondary" - title={intl.formatMessage({ id: "o_counter" })} + title={intl.formatMessage({ id: messageID })} > - + {icon} {props.value} ); diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index f7e844392..aee6ab344 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -3,7 +3,6 @@ import React, { useEffect, useState, useMemo, - useContext, useRef, useLayoutEffect, } from "react"; @@ -32,7 +31,7 @@ import SceneQueue, { QueuedScene } from "src/models/sceneQueue"; import { ListFilterModel } from "src/models/list-filter/filter"; import Mousetrap from "mousetrap"; import { OrganizedButton } from "./OrganizedButton"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { getPlayerPosition } from "src/components/ScenePlayer/util"; import { faEllipsisV, @@ -184,7 +183,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { const intl = useIntl(); const [updateScene] = useSceneUpdate(); const [generateScreenshot] = useSceneGenerateScreenshot(); - const { configuration } = useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const [showDraftModal, setShowDraftModal] = useState(false); const boxes = configuration?.general?.stashBoxes ?? []; @@ -689,7 +688,7 @@ const SceneLoader: React.FC> = ({ match, }) => { const { id } = match.params; - const { configuration } = useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const { data, loading, error } = useFindScene(id); const [scene, setScene] = useState(); diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 69b378787..e56ea265b 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -19,7 +19,7 @@ import ImageUtils from "src/utils/image"; import { getStashIDs } from "src/utils/stashIds"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { IGroupEntry, SceneGroupTable } from "./SceneGroupTable"; import { faSearch } from "@fortawesome/free-solid-svg-icons"; import { objectTitle } from "src/core/files"; @@ -103,7 +103,7 @@ export const SceneEditPanel: React.FC = ({ setStudio(scene.studio ?? null); }, [scene.studio]); - const { configuration: stashConfig } = React.useContext(ConfigurationContext); + const { configuration: stashConfig } = useConfigurationContext(); // Network state const [isLoading, setIsLoading] = useState(false); diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneHistoryPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneHistoryPanel.tsx index 1ac9dd5a2..2ba587a2b 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneHistoryPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneHistoryPanel.tsx @@ -21,6 +21,7 @@ import { useSceneResetActivity, } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; +import { useConfigurationContext } from "src/hooks/Config"; import { useToast } from "src/hooks/Toast"; import { TextField } from "src/utils/field"; import TextUtils from "src/utils/text"; @@ -172,6 +173,9 @@ export const SceneHistoryPanel: React.FC = ({ scene }) => { const intl = useIntl(); const Toast = useToast(); + const { configuration } = useConfigurationContext(); + const { sfwContentMode } = configuration.interface; + const [dialogs, setDialogs] = React.useState({ playHistory: false, oHistory: false, @@ -299,6 +303,9 @@ export const SceneHistoryPanel: React.FC = ({ scene }) => { } function maybeRenderDialogs() { + const clearHistoryMessageID = sfwContentMode + ? "dialogs.clear_o_history_confirm_sfw" + : "dialogs.clear_play_history_confirm"; return ( <> = ({ scene }) => { /> handleClearODates()} onCancel={() => setDialogPartial({ oHistory: false })} @@ -351,6 +358,11 @@ export const SceneHistoryPanel: React.FC = ({ scene }) => { ) as string[]; const oHistory = (scene.o_history ?? []).filter((h) => h != null) as string[]; + const oHistoryMessageID = sfwContentMode ? "o_history_sfw" : "o_history"; + const noneMessageID = sfwContentMode + ? "odate_recorded_no_sfw" + : "odate_recorded_no"; + return (
{maybeRenderDialogs()} @@ -401,7 +413,7 @@ export const SceneHistoryPanel: React.FC = ({ scene }) => {
- + @@ -427,7 +439,7 @@ export const SceneHistoryPanel: React.FC = ({ scene }) => {
handleDeleteODate(t)} /> diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx index 6a89caf85..7be291bd2 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx @@ -218,32 +218,38 @@ export const SceneScrapeDialog: React.FC = ({ return ( <> setTitle(value)} /> setCode(value)} /> setURLs(value)} /> setDate(value)} /> setDirector(value)} /> setStudio(value)} @@ -251,6 +257,7 @@ export const SceneScrapeDialog: React.FC = ({ onCreateNew={createNewStudio} /> setPerformers(value)} @@ -259,6 +266,7 @@ export const SceneScrapeDialog: React.FC = ({ ageFromDate={date.useNewValue ? date.newValue : date.originalValue} /> setGroups(value)} @@ -267,17 +275,20 @@ export const SceneScrapeDialog: React.FC = ({ /> {scrapedTagsRow} setDetails(value)} /> setStashID(value)} /> { }, }); - const { filter, setFilter, loading: filterLoading } = filterState; + const { filter, setFilter } = filterState; const { effectiveFilter, result, cachedResult, items, totalCount } = queryResult; @@ -709,7 +709,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => { ]; // render - if (filterLoading || sidebarStateLoading) return null; + if (sidebarStateLoading) return null; const operations = ( { }; const SceneMarkerCardImage = (props: ISceneMarkerCardProps) => { - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const file = useMemo( () => diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx index 9c507730b..0349fae0f 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx @@ -1,17 +1,11 @@ -import React, { - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import * as GQL from "src/core/generated-graphql"; import Gallery, { GalleryI, PhotoProps, RenderImageProps, } from "react-photo-gallery"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { objectTitle } from "src/core/files"; import { Link, useHistory } from "react-router-dom"; import { TruncatedText } from "../Shared/TruncatedText"; @@ -46,7 +40,7 @@ interface IExtraProps { export const MarkerWallItem: React.FC< RenderImageProps & IExtraProps > = (props: RenderImageProps & IExtraProps) => { - const { configuration } = useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const playSound = configuration?.interface.soundOnPreview ?? false; const showTitle = configuration?.interface.wallShowTitle ?? false; diff --git a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx index d5a18d4ac..511ca2351 100644 --- a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx @@ -372,27 +372,32 @@ const SceneMergeDetails: React.FC = ({ return ( <> setTitle(value)} /> setCode(value)} /> setURL(value)} /> setDate(value)} /> ( @@ -404,6 +409,7 @@ const SceneMergeDetails: React.FC = ({ onChange={(value) => setRating(value)} /> ( @@ -425,6 +431,7 @@ const SceneMergeDetails: React.FC = ({ onChange={(value) => setOCounter(value)} /> ( @@ -446,6 +453,7 @@ const SceneMergeDetails: React.FC = ({ onChange={(value) => setPlayCount(value)} /> ( @@ -469,6 +477,7 @@ const SceneMergeDetails: React.FC = ({ onChange={(value) => setPlayDuration(value)} /> ( @@ -492,32 +501,38 @@ const SceneMergeDetails: React.FC = ({ onChange={(value) => setGalleries(value)} /> setStudio(value)} /> setPerformers(value)} ageFromDate={date.useNewValue ? date.newValue : date.originalValue} /> setGroups(value)} /> setTags(value)} /> setDetails(value)} /> ( @@ -539,6 +554,7 @@ const SceneMergeDetails: React.FC = ({ onChange={(value) => setOrganized(value)} /> ( @@ -550,6 +566,7 @@ const SceneMergeDetails: React.FC = ({ onChange={(value) => setStashIDs(value)} /> & ExtraSceneProps > = (props) => { - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const intl = useIntl(); const maxOptionsShown = configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown; diff --git a/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx index 6f98cdaab..3f5020793 100644 --- a/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx @@ -1,10 +1,4 @@ -import React, { - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { SceneQueue } from "src/models/sceneQueue"; import Gallery, { @@ -12,7 +6,7 @@ import Gallery, { PhotoProps, RenderImageProps, } from "react-photo-gallery"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { objectTitle } from "src/core/files"; import { Link, useHistory } from "react-router-dom"; import { TruncatedText } from "../Shared/TruncatedText"; @@ -35,7 +29,7 @@ export const SceneWallItem: React.FC< > = (props: RenderImageProps & IExtraProps) => { const intl = useIntl(); - const { configuration } = useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const playSound = configuration?.interface.soundOnPreview ?? false; const showTitle = configuration?.interface.wallShowTitle ?? false; diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index e0c538cd0..ba93385b5 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -240,6 +240,14 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( + saveInterface({ sfwContentMode: v })} + /> +
diff --git a/ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx b/ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx index e093dc60a..c36e076f4 100644 --- a/ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/DataManagementTasks.tsx @@ -22,7 +22,7 @@ import { SettingSection } from "../SettingSection"; import { BooleanSetting, Setting } from "../Inputs"; import { ManualLink } from "src/components/Help/context"; import { Icon } from "src/components/Shared/Icon"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { FolderSelect } from "src/components/Shared/FolderSelect/FolderSelect"; import { faMinus, @@ -44,7 +44,7 @@ const CleanDialog: React.FC = ({ onClose, }) => { const intl = useIntl(); - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const libraryPaths = configuration?.general.stashes.map((s) => s.path); diff --git a/ui/v2.5/src/components/Settings/Tasks/DirectorySelectionDialog.tsx b/ui/v2.5/src/components/Settings/Tasks/DirectorySelectionDialog.tsx index 9fdaf09f4..87a58f292 100644 --- a/ui/v2.5/src/components/Settings/Tasks/DirectorySelectionDialog.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/DirectorySelectionDialog.tsx @@ -9,7 +9,7 @@ import { useIntl } from "react-intl"; import { Icon } from "src/components/Shared/Icon"; import { ModalComponent } from "src/components/Shared/Modal"; import { FolderSelect } from "src/components/Shared/FolderSelect/FolderSelect"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; interface IDirectorySelectionDialogProps { animation?: boolean; @@ -22,7 +22,7 @@ export const DirectorySelectionDialog: React.FC< IDirectorySelectionDialogProps > = ({ animation, allowEmpty = false, initialPaths = [], onClose }) => { const intl = useIntl(); - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const libraryPaths = configuration?.general.stashes.map((s) => s.path); diff --git a/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx b/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx index cb60891fd..605e37933 100644 --- a/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx +++ b/ui/v2.5/src/components/Settings/Tasks/LibraryTasks.tsx @@ -7,7 +7,7 @@ import { mutateMetadataGenerate, } from "src/core/StashService"; import { withoutTypename } from "src/utils/data"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { IdentifyDialog } from "../../Dialogs/IdentifyDialog/IdentifyDialog"; import * as GQL from "src/core/generated-graphql"; import { DirectorySelectionDialog } from "./DirectorySelectionDialog"; @@ -123,7 +123,7 @@ export const LibraryTasks: React.FC = () => { type DialogOpenState = typeof dialogOpen; - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const [configRead, setConfigRead] = useState(false); useEffect(() => { diff --git a/ui/v2.5/src/components/Setup/Setup.tsx b/ui/v2.5/src/components/Setup/Setup.tsx index ab5411fe1..27f9b4e58 100644 --- a/ui/v2.5/src/components/Setup/Setup.tsx +++ b/ui/v2.5/src/components/Setup/Setup.tsx @@ -1,4 +1,4 @@ -import React, { useState, useContext, useCallback } from "react"; +import React, { useState, useCallback } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { Alert, @@ -15,7 +15,7 @@ import { useSystemStatus, } from "src/core/StashService"; import { useHistory } from "react-router-dom"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import StashConfiguration from "../Settings/StashConfiguration"; import { Icon } from "../Shared/Icon"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; @@ -518,6 +518,10 @@ const SetPathsStep: React.FC = ({ goBack, next }) => { const [stashes, setStashes] = useState( setupState.stashes ?? [] ); + const [sfwContentMode, setSfwContentMode] = useState( + setupState.sfwContentMode ?? false + ); + const [databaseFile, setDatabaseFile] = useState( setupState.databaseFile ?? "" ); @@ -555,6 +559,7 @@ const SetPathsStep: React.FC = ({ goBack, next }) => { cacheLocation, blobsLocation: storeBlobsInDatabase ? "" : blobsLocation, storeBlobsInDatabase, + sfwContentMode, }; next(input); } @@ -594,6 +599,22 @@ const SetPathsStep: React.FC = ({ goBack, next }) => { /> + +

+ +

+

+ +

+ + } + onChange={() => setSfwContentMode(!sfwContentMode)} + /> + +
{overrideDatabase ? null : ( = ({ goBack }) => { export const Setup: React.FC = () => { const intl = useIntl(); - const { configuration, loading: configLoading } = - useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const [saveUI] = useConfigureUI(); @@ -1024,7 +1044,7 @@ export const Setup: React.FC = () => { } } - if (configLoading || statusLoading) { + if (statusLoading) { return ; } diff --git a/ui/v2.5/src/components/Shared/CountButton.tsx b/ui/v2.5/src/components/Shared/CountButton.tsx index 1519c104b..ad099c2f5 100644 --- a/ui/v2.5/src/components/Shared/CountButton.tsx +++ b/ui/v2.5/src/components/Shared/CountButton.tsx @@ -1,10 +1,11 @@ -import { faEye } from "@fortawesome/free-solid-svg-icons"; +import { faEye, faThumbsUp } from "@fortawesome/free-solid-svg-icons"; import React from "react"; import { Button, ButtonGroup } from "react-bootstrap"; import { Icon } from "src/components/Shared/Icon"; import { SweatDrops } from "./SweatDrops"; import cx from "classnames"; import { useIntl } from "react-intl"; +import { useConfigurationContext } from "src/hooks/Config"; interface ICountButtonProps { value: number; @@ -63,11 +64,17 @@ export const ViewCountButton: React.FC = (props) => { export const OCounterButton: React.FC = (props) => { const intl = useIntl(); + const { configuration } = useConfigurationContext(); + const { sfwContentMode } = configuration.interface; + + const icon = !sfwContentMode ? : ; + const messageID = !sfwContentMode ? "o_count" : "o_count_sfw"; + return ( } - title={intl.formatMessage({ id: "o_count" })} + icon={icon} + title={intl.formatMessage({ id: messageID })} countTitle={intl.formatMessage({ id: "actions.view_history" })} /> ); diff --git a/ui/v2.5/src/components/Shared/DetailItem.tsx b/ui/v2.5/src/components/Shared/DetailItem.tsx index a92f75868..76b595127 100644 --- a/ui/v2.5/src/components/Shared/DetailItem.tsx +++ b/ui/v2.5/src/components/Shared/DetailItem.tsx @@ -3,6 +3,7 @@ import { FormattedMessage } from "react-intl"; interface IDetailItem { id?: string | null; + className?: string; label?: React.ReactNode; value?: React.ReactNode; labelTitle?: string; @@ -13,6 +14,7 @@ interface IDetailItem { export const DetailItem: React.FC = ({ id, + className = "", label, value, labelTitle, @@ -30,7 +32,7 @@ export const DetailItem: React.FC = ({ const sanitisedID = id.replace(/_/g, "-"); return ( -
+
{message} {fullWidth ? ":" : ""} diff --git a/ui/v2.5/src/components/Shared/GridCard/StudioOverlay.tsx b/ui/v2.5/src/components/Shared/GridCard/StudioOverlay.tsx index 875b122d8..9bfd25071 100644 --- a/ui/v2.5/src/components/Shared/GridCard/StudioOverlay.tsx +++ b/ui/v2.5/src/components/Shared/GridCard/StudioOverlay.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from "react"; import { Link } from "react-router-dom"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; interface IStudio { id: string; @@ -11,7 +11,7 @@ interface IStudio { export const StudioOverlay: React.FC<{ studio: IStudio | null | undefined; }> = ({ studio }) => { - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const configValue = configuration?.interface.showStudioAsText; diff --git a/ui/v2.5/src/components/Shared/PopoverCountButton.tsx b/ui/v2.5/src/components/Shared/PopoverCountButton.tsx index 79a36bd9d..d652ff6ad 100644 --- a/ui/v2.5/src/components/Shared/PopoverCountButton.tsx +++ b/ui/v2.5/src/components/Shared/PopoverCountButton.tsx @@ -11,14 +11,14 @@ import React from "react"; import { Button, OverlayTrigger, Tooltip } from "react-bootstrap"; import { FormattedNumber, useIntl } from "react-intl"; import { Link } from "react-router-dom"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import TextUtils from "src/utils/text"; import { Icon } from "./Icon"; export const Count: React.FC<{ count: number; }> = ({ count }) => { - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const abbreviateCounter = configuration?.ui.abbreviateCounters ?? false; if (!abbreviateCounter) { diff --git a/ui/v2.5/src/components/Shared/Rating/RatingSystem.tsx b/ui/v2.5/src/components/Shared/Rating/RatingSystem.tsx index a0a11c363..11103acf8 100644 --- a/ui/v2.5/src/components/Shared/Rating/RatingSystem.tsx +++ b/ui/v2.5/src/components/Shared/Rating/RatingSystem.tsx @@ -1,5 +1,4 @@ -import React from "react"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { defaultRatingStarPrecision, defaultRatingSystemOptions, @@ -23,7 +22,7 @@ export interface IRatingSystemProps { export const RatingSystem = PatchComponent( "RatingSystem", (props: IRatingSystemProps) => { - const { configuration: config } = React.useContext(ConfigurationContext); + const { configuration: config } = useConfigurationContext(); const ratingSystemOptions = config?.ui.ratingSystemOptions ?? defaultRatingSystemOptions; diff --git a/ui/v2.5/src/components/Shared/RatingBanner.tsx b/ui/v2.5/src/components/Shared/RatingBanner.tsx index d152b8b52..d94b26433 100644 --- a/ui/v2.5/src/components/Shared/RatingBanner.tsx +++ b/ui/v2.5/src/components/Shared/RatingBanner.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from "react"; +import React from "react"; import { FormattedMessage } from "react-intl"; import { convertToRatingFormat, @@ -6,14 +6,14 @@ import { RatingStarPrecision, RatingSystemType, } from "src/utils/rating"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; interface IProps { rating?: number | null; } export const RatingBanner: React.FC = ({ rating }) => { - const { configuration: config } = useContext(ConfigurationContext); + const { configuration: config } = useConfigurationContext(); const ratingSystemOptions = config?.ui.ratingSystemOptions ?? defaultRatingSystemOptions; const isLegacy = diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx index 59d5f3985..b67c55f41 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx @@ -24,6 +24,7 @@ import { CountrySelect } from "../CountrySelect"; import { StringListInput } from "../StringListInput"; import { ImageSelector } from "../ImageSelector"; import { ScrapeResult } from "./scrapeResult"; +import { useConfigurationContext } from "src/hooks/Config"; interface IScrapedFieldProps { result: ScrapeResult; @@ -31,6 +32,7 @@ interface IScrapedFieldProps { interface IScrapedRowProps extends IScrapedFieldProps { className?: string; + field: string; title: string; renderOriginalField: (result: ScrapeResult) => JSX.Element | undefined; renderNewField: (result: ScrapeResult) => JSX.Element | undefined; @@ -105,7 +107,10 @@ export const ScrapeDialogRow = (props: IScrapedRowProps) => { } return ( - + {props.title} @@ -175,6 +180,8 @@ function getNameString(value: string) { interface IScrapedInputGroupRowProps { title: string; + field: string; + className?: string; placeholder?: string; result: ScrapeResult; locked?: boolean; @@ -187,6 +194,8 @@ export const ScrapedInputGroupRow: React.FC = ( return ( ( = (props) => { interface IScrapedStringListRowProps { title: string; + field: string; placeholder?: string; result: ScrapeResult; locked?: boolean; @@ -253,6 +263,7 @@ export const ScrapedStringListRow: React.FC = ( ( = ( return ( ( = (props) => { interface IScrapedImageRowProps { title: string; + field: string; className?: string; result: ScrapeResult; onChange: (value: ScrapeResult) => void; @@ -355,6 +368,7 @@ export const ScrapedImageRow: React.FC = (props) => { return ( ( = (props) => { interface IScrapedImagesRowProps { title: string; + field: string; className?: string; result: ScrapeResult; images: string[]; @@ -397,6 +412,7 @@ export const ScrapedImagesRow: React.FC = (props) => { return ( ( = ( props: IScrapeDialogProps ) => { const intl = useIntl(); + const { configuration } = useConfigurationContext(); + const { sfwContentMode } = configuration.interface; + return ( = ( text: intl.formatMessage({ id: "actions.cancel" }), variant: "secondary", }} - modalProps={{ size: "lg", dialogClassName: "scrape-dialog" }} + modalProps={{ + size: "lg", + dialogClassName: `scrape-dialog ${sfwContentMode ? "sfw-mode" : ""}`, + }} >
@@ -479,6 +501,7 @@ export const ScrapeDialog: React.FC = ( interface IScrapedCountryRowProps { title: string; + field: string; result: ScrapeResult; onChange: (value: ScrapeResult) => void; locked?: boolean; @@ -487,6 +510,7 @@ interface IScrapedCountryRowProps { export const ScrapedCountryRow: React.FC = ({ title, + field, result, onChange, locked, @@ -494,6 +518,7 @@ export const ScrapedCountryRow: React.FC = ({ }) => ( ( ; onChange: (value: ObjectScrapeResult) => void; newStudio?: GQL.ScrapedStudio; @@ -25,6 +26,7 @@ function getObjectName(value: T) { export const ScrapedStudioRow: React.FC = ({ title, + field, result, onChange, newStudio, @@ -73,6 +75,7 @@ export const ScrapedStudioRow: React.FC = ({ return ( renderScrapedStudio(result)} renderNewField={() => @@ -92,6 +95,7 @@ export const ScrapedStudioRow: React.FC = ({ interface IScrapedObjectsRow { title: string; + field: string; result: ScrapeResult; onChange: (value: ScrapeResult) => void; newObjects?: T[]; @@ -107,6 +111,7 @@ interface IScrapedObjectsRow { export const ScrapedObjectsRow = (props: IScrapedObjectsRow) => { const { title, + field, result, onChange, newObjects, @@ -118,6 +123,7 @@ export const ScrapedObjectsRow = (props: IScrapedObjectsRow) => { return ( renderObjects(result)} renderNewField={() => @@ -142,7 +148,15 @@ type IScrapedObjectRowImpl = Omit< export const ScrapedPerformersRow: React.FC< IScrapedObjectRowImpl & { ageFromDate?: string | null } -> = ({ title, result, onChange, newObjects, onCreateNew, ageFromDate }) => { +> = ({ + title, + field, + result, + onChange, + newObjects, + onCreateNew, + ageFromDate, +}) => { const performersCopy = useMemo(() => { return ( newObjects?.map((p) => { @@ -191,6 +205,7 @@ export const ScrapedPerformersRow: React.FC< return ( title={title} + field={field} result={result} renderObjects={renderScrapedPerformers} onChange={onChange} @@ -203,7 +218,7 @@ export const ScrapedPerformersRow: React.FC< export const ScrapedGroupsRow: React.FC< IScrapedObjectRowImpl -> = ({ title, result, onChange, newObjects, onCreateNew }) => { +> = ({ title, field, result, onChange, newObjects, onCreateNew }) => { const groupsCopy = useMemo(() => { return ( newObjects?.map((p) => { @@ -251,6 +266,7 @@ export const ScrapedGroupsRow: React.FC< return ( title={title} + field={field} result={result} renderObjects={renderScrapedGroups} onChange={onChange} @@ -263,7 +279,7 @@ export const ScrapedGroupsRow: React.FC< export const ScrapedTagsRow: React.FC< IScrapedObjectRowImpl -> = ({ title, result, onChange, newObjects, onCreateNew }) => { +> = ({ title, field, result, onChange, newObjects, onCreateNew }) => { function renderScrapedTags( scrapeResult: ScrapeResult, isNew?: boolean, @@ -297,6 +313,7 @@ export const ScrapedTagsRow: React.FC< return ( title={title} + field={field} result={result} renderObjects={renderScrapedTags} onChange={onChange} diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/scrapedTags.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/scrapedTags.tsx index ca3658391..f298a6eeb 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/scrapedTags.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/scrapedTags.tsx @@ -39,6 +39,7 @@ export function useScrapedTags( const scrapedTagsRow = ( setTags(value)} diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index 4ae547cfe..4eea52a38 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -15,7 +15,7 @@ import CreatableSelect from "react-select/creatable"; import * as GQL from "src/core/generated-graphql"; import { useMarkerStrings } from "src/core/StashService"; import { SelectComponents } from "react-select/dist/declarations/src/components"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { objectTitle } from "src/core/files"; import { defaultMaxOptionsShown } from "src/core/config"; import { useDebounce } from "src/hooks/debounce"; @@ -108,7 +108,7 @@ const getSelectedItems = (selectedItems: OnChangeValue) => { const LimitedSelectMenu = ( props: MenuListProps> ) => { - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const maxOptionsShown = configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown; @@ -496,6 +496,7 @@ export const ListSelect = (props: IListSelect) => { type DisableOption = Option & { isDisabled?: boolean; + className?: string; }; interface ICheckBoxSelectProps { @@ -510,7 +511,17 @@ export const CheckBoxSelect: React.FC = ({ onChange, }) => { const Option = (props: OptionProps) => ( - + , + HTMLDivElement + > + } + > ; linkType: LinkType; }> = ({ stashID, linkType }) => { - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const { endpoint, stash_id } = stashID; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index fc416320f..c26ed0c73 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -18,7 +18,7 @@ import { ModalComponent } from "src/components/Shared/Modal"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { useToast } from "src/hooks/Toast"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { StudioScenesPanel } from "./StudioScenesPanel"; import { StudioGalleriesPanel } from "./StudioGalleriesPanel"; import { StudioImagesPanel } from "./StudioImagesPanel"; @@ -264,7 +264,7 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { const intl = useIntl(); // Configuration settings - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const uiConfig = configuration?.ui; const abbreviateCounter = uiConfig?.abbreviateCounters ?? false; const enableBackgroundImage = uiConfig?.enableStudioBackgroundImage ?? false; diff --git a/ui/v2.5/src/components/Studios/StudioSelect.tsx b/ui/v2.5/src/components/Studios/StudioSelect.tsx index c62d25675..7305aa60d 100644 --- a/ui/v2.5/src/components/Studios/StudioSelect.tsx +++ b/ui/v2.5/src/components/Studios/StudioSelect.tsx @@ -13,7 +13,7 @@ import { queryFindStudiosByIDForSelect, queryFindStudiosForSelect, } from "src/core/StashService"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { useIntl } from "react-intl"; import { defaultMaxOptionsShown, IUIConfig } from "src/core/config"; import { ListFilterModel } from "src/models/list-filter/filter"; @@ -65,7 +65,7 @@ const _StudioSelect: React.FC< > = (props) => { const [createStudio] = useStudioCreate(); - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const intl = useIntl(); const maxOptionsShown = (configuration?.ui as IUIConfig).maxOptionsShown ?? defaultMaxOptionsShown; diff --git a/ui/v2.5/src/components/Tagger/config.ts b/ui/v2.5/src/components/Tagger/config.ts index 78515f550..c30db7da2 100644 --- a/ui/v2.5/src/components/Tagger/config.ts +++ b/ui/v2.5/src/components/Tagger/config.ts @@ -1,10 +1,10 @@ -import { useCallback, useContext } from "react"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useCallback } from "react"; +import { useConfigurationContext } from "src/hooks/Config"; import { initialConfig, ITaggerConfig } from "./constants"; import { useConfigureUISetting } from "src/core/StashService"; export function useTaggerConfig() { - const { configuration: stashConfig } = useContext(ConfigurationContext); + const { configuration: stashConfig } = useConfigurationContext(); const [saveUISetting] = useConfigureUISetting(); const config = stashConfig?.ui.taggerConfig ?? initialConfig; diff --git a/ui/v2.5/src/components/Tagger/context.tsx b/ui/v2.5/src/components/Tagger/context.tsx index 0db1fba1e..028e83ed0 100644 --- a/ui/v2.5/src/components/Tagger/context.tsx +++ b/ui/v2.5/src/components/Tagger/context.tsx @@ -17,7 +17,7 @@ import { useTagCreate, } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { ITaggerSource, SCRAPER_PREFIX, STASH_BOX_PREFIX } from "./constants"; import { errorToString } from "src/utils"; import { mergeStudioStashIDs } from "./utils"; @@ -117,7 +117,7 @@ export const TaggerContext: React.FC = ({ children }) => { const stopping = useRef(false); - const { configuration: stashConfig } = React.useContext(ConfigurationContext); + const { configuration: stashConfig } = useConfigurationContext(); const { config, setConfig } = useTaggerConfig(); const Scrapers = useListSceneScrapers(); diff --git a/ui/v2.5/src/components/Tagger/performers/Config.tsx b/ui/v2.5/src/components/Tagger/performers/Config.tsx index a839e1ae6..0d5316735 100644 --- a/ui/v2.5/src/components/Tagger/performers/Config.tsx +++ b/ui/v2.5/src/components/Tagger/performers/Config.tsx @@ -1,7 +1,7 @@ import React, { Dispatch, useState } from "react"; import { Badge, Button, Card, Collapse, Form } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { ITaggerConfig } from "../constants"; import PerformerFieldSelector from "../PerformerFieldSelector"; @@ -13,7 +13,7 @@ interface IConfigProps { } const Config: React.FC = ({ show, config, setConfig }) => { - const { configuration: stashConfig } = React.useContext(ConfigurationContext); + const { configuration: stashConfig } = useConfigurationContext(); const [showExclusionModal, setShowExclusionModal] = useState(false); const excludedFields = config.excludedPerformerFields ?? []; diff --git a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx index f0c87ff57..a6e2bcd1c 100755 --- a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx +++ b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx @@ -16,7 +16,7 @@ import { performerMutationImpactedQueries, } from "src/core/StashService"; import { Manual } from "src/components/Help/Manual"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import StashSearchResult from "./StashSearchResult"; import PerformerConfig from "./Config"; @@ -620,7 +620,7 @@ interface ITaggerProps { export const PerformerTagger: React.FC = ({ performers }) => { const jobsSubscribe = useJobsSubscribe(); const intl = useIntl(); - const { configuration: stashConfig } = React.useContext(ConfigurationContext); + const { configuration: stashConfig } = useConfigurationContext(); const { config, setConfig } = useTaggerConfig(); const [showConfig, setShowConfig] = useState(false); const [showManual, setShowManual] = useState(false); diff --git a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx index 922ecc473..695ed2817 100755 --- a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx @@ -12,7 +12,7 @@ import Config from "./Config"; import { TaggerScene } from "./TaggerScene"; import { SceneTaggerModals } from "./sceneTaggerModals"; import { SceneSearchResults } from "./StashSearchResult"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { faCog } from "@fortawesome/free-solid-svg-icons"; import { useLightbox } from "src/hooks/Lightbox/hooks"; @@ -26,7 +26,7 @@ const Scene: React.FC<{ const intl = useIntl(); const { currentSource, doSceneQuery, doSceneFragmentScrape, loading } = useContext(TaggerStateContext); - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const cont = configuration?.interface.continuePlaylistDefault ?? false; diff --git a/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx b/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx index db36bf404..4825ebcfd 100644 --- a/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx @@ -19,7 +19,7 @@ import { faImage, } from "@fortawesome/free-solid-svg-icons"; import { objectPath, objectTitle } from "src/core/files"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { SceneQueue } from "src/models/sceneQueue"; interface ITaggerSceneDetails { @@ -154,7 +154,7 @@ export const TaggerScene: React.FC> = ({ const history = useHistory(); - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const cont = configuration?.interface.continuePlaylistDefault ?? false; async function query() { diff --git a/ui/v2.5/src/components/Tagger/studios/Config.tsx b/ui/v2.5/src/components/Tagger/studios/Config.tsx index 9dd9f6856..ddfd17b1e 100644 --- a/ui/v2.5/src/components/Tagger/studios/Config.tsx +++ b/ui/v2.5/src/components/Tagger/studios/Config.tsx @@ -1,7 +1,7 @@ import React, { Dispatch, useState } from "react"; import { Badge, Button, Card, Collapse, Form } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { ITaggerConfig } from "../constants"; import StudioFieldSelector from "./StudioFieldSelector"; @@ -13,7 +13,7 @@ interface IConfigProps { } const Config: React.FC = ({ show, config, setConfig }) => { - const { configuration: stashConfig } = React.useContext(ConfigurationContext); + const { configuration: stashConfig } = useConfigurationContext(); const [showExclusionModal, setShowExclusionModal] = useState(false); const excludedFields = config.excludedStudioFields ?? []; diff --git a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx index b8fbefdb5..78553e518 100644 --- a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx +++ b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx @@ -17,7 +17,7 @@ import { evictQueries, } from "src/core/StashService"; import { Manual } from "src/components/Help/Manual"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import StashSearchResult from "./StashSearchResult"; import StudioConfig from "./Config"; @@ -669,7 +669,7 @@ interface ITaggerProps { export const StudioTagger: React.FC = ({ studios }) => { const jobsSubscribe = useJobsSubscribe(); const intl = useIntl(); - const { configuration: stashConfig } = React.useContext(ConfigurationContext); + const { configuration: stashConfig } = useConfigurationContext(); const { config, setConfig } = useTaggerConfig(); const [showConfig, setShowConfig] = useState(false); const [showManual, setShowManual] = useState(false); diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index 6d6a4a660..e0bc11e37 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -19,7 +19,7 @@ import { ModalComponent } from "src/components/Shared/Modal"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { Icon } from "src/components/Shared/Icon"; import { useToast } from "src/hooks/Toast"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { tagRelationHook } from "src/core/tags"; import { TagScenesPanel } from "./TagScenesPanel"; import { TagMarkersPanel } from "./TagMarkersPanel"; @@ -293,7 +293,7 @@ const TagPage: React.FC = ({ tag, tabKey }) => { const intl = useIntl(); // Configuration settings - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const uiConfig = configuration?.ui; const abbreviateCounter = uiConfig?.abbreviateCounters ?? false; const enableBackgroundImage = uiConfig?.enableTagBackgroundImage ?? false; diff --git a/ui/v2.5/src/components/Tags/TagPopover.tsx b/ui/v2.5/src/components/Tags/TagPopover.tsx index 9e3f0d80b..ef3aa950a 100644 --- a/ui/v2.5/src/components/Tags/TagPopover.tsx +++ b/ui/v2.5/src/components/Tags/TagPopover.tsx @@ -4,7 +4,7 @@ import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { HoverPopover } from "../Shared/HoverPopover"; import { useFindTag } from "../../core/StashService"; import { TagCard } from "./TagCard"; -import { ConfigurationContext } from "../../hooks/Config"; +import { useConfigurationContext } from "../../hooks/Config"; import { Placement } from "react-bootstrap/esm/Overlay"; interface ITagPopoverCardProps { @@ -47,7 +47,7 @@ export const TagPopover: React.FC = ({ placement = "top", target, }) => { - const { configuration: config } = React.useContext(ConfigurationContext); + const { configuration: config } = useConfigurationContext(); const showTagCardOnHover = config?.ui.showTagCardOnHover ?? true; diff --git a/ui/v2.5/src/components/Tags/TagSelect.tsx b/ui/v2.5/src/components/Tags/TagSelect.tsx index 9fdc57eaf..5b8da7a6d 100644 --- a/ui/v2.5/src/components/Tags/TagSelect.tsx +++ b/ui/v2.5/src/components/Tags/TagSelect.tsx @@ -13,7 +13,7 @@ import { queryFindTagsByIDForSelect, queryFindTagsForSelect, } from "src/core/StashService"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { useIntl } from "react-intl"; import { defaultMaxOptionsShown } from "src/core/config"; import { ListFilterModel } from "src/models/list-filter/filter"; @@ -67,7 +67,7 @@ export type TagSelectProps = IFilterProps & const _TagSelect: React.FC = (props) => { const [createTag] = useTagCreate(); - const { configuration } = React.useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const intl = useIntl(); const maxOptionsShown = configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown; diff --git a/ui/v2.5/src/components/Wall/WallItem.tsx b/ui/v2.5/src/components/Wall/WallItem.tsx index 5811b7543..959ac1617 100644 --- a/ui/v2.5/src/components/Wall/WallItem.tsx +++ b/ui/v2.5/src/components/Wall/WallItem.tsx @@ -12,7 +12,7 @@ import TextUtils from "src/utils/text"; import NavUtils from "src/utils/navigation"; import cx from "classnames"; import { SceneQueue } from "src/models/sceneQueue"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { markerTitle } from "src/core/markers"; import { objectTitle } from "src/core/files"; @@ -128,7 +128,7 @@ export const WallItem = ({ }: IWallItemProps) => { const [active, setActive] = useState(false); const itemEl = useRef(null); - const { configuration: config } = React.useContext(ConfigurationContext); + const { configuration: config } = useConfigurationContext(); const showTextContainer = config?.interface.wallShowTitle ?? true; diff --git a/ui/v2.5/src/docs/en/Manual/Interface.md b/ui/v2.5/src/docs/en/Manual/Interface.md index 31c7e25d4..cf5911405 100644 --- a/ui/v2.5/src/docs/en/Manual/Interface.md +++ b/ui/v2.5/src/docs/en/Manual/Interface.md @@ -4,6 +4,15 @@ Setting the language affects the formatting of numbers and dates. +## SFW Content Mode + +SFW Content Mode is used to indicate that the content being managed is _not_ adult content. + +When SFW Content Mode is enabled, the following changes are made to the UI: +- default performer images are changed to less adult-oriented images +- certain adult-specific metadata fields are hidden (e.g. performer genital fields) +- `O`-Counter is replaced with `Like`-counter + ## Scene/Marker Wall Preview Type The Scene Wall and Marker pages display scene preview videos (mp4) by default. This can be changed to animated image (webp) or static image. diff --git a/ui/v2.5/src/hooks/Config.tsx b/ui/v2.5/src/hooks/Config.tsx index 0b00d0dc5..65ad7122a 100644 --- a/ui/v2.5/src/hooks/Config.tsx +++ b/ui/v2.5/src/hooks/Config.tsx @@ -2,14 +2,28 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; export interface IContext { - configuration?: GQL.ConfigDataFragment; - loading?: boolean; + configuration: GQL.ConfigDataFragment; } -export const ConfigurationContext = React.createContext({}); +export const ConfigurationContext = React.createContext(null); + +export const useConfigurationContext = () => { + const context = React.useContext(ConfigurationContext); + + if (context === null) { + throw new Error( + "useConfigurationContext must be used within a ConfigurationProvider" + ); + } + + return context; +}; + +export const useConfigurationContextOptional = () => { + return React.useContext(ConfigurationContext); +}; export const ConfigurationProvider: React.FC = ({ - loading, configuration, children, }) => { @@ -17,7 +31,6 @@ export const ConfigurationProvider: React.FC = ({ {children} diff --git a/ui/v2.5/src/hooks/Interactive/context.tsx b/ui/v2.5/src/hooks/Interactive/context.tsx index 9e7194d6a..ccdc948b4 100644 --- a/ui/v2.5/src/hooks/Interactive/context.tsx +++ b/ui/v2.5/src/hooks/Interactive/context.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useContext, useEffect, useState } from "react"; -import { ConfigurationContext } from "../Config"; +import { useConfigurationContext } from "../Config"; import { useLocalForage } from "../LocalForage"; import { Interactive as InteractiveAPI } from "./interactive"; import InteractiveUtils, { @@ -86,7 +86,7 @@ export const InteractiveProvider: React.FC = ({ children }) => { { serverOffset: 0, lastSyncTime: 0 } ); - const { configuration: stashConfig } = React.useContext(ConfigurationContext); + const { configuration: stashConfig } = useConfigurationContext(); const [state, setState] = useState(ConnectionState.Missing); const [handyKey, setHandyKey] = useState(undefined); diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index 5619275ff..f0f057d86 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -19,7 +19,7 @@ import usePageVisibility from "../PageVisibility"; import { useToast } from "../Toast"; import { FormattedMessage, useIntl } from "react-intl"; import { LightboxImage } from "./LightboxImage"; -import { ConfigurationContext } from "../Config"; +import { useConfigurationContext } from "../Config"; import { Link } from "react-router-dom"; import { OCounterButton } from "src/components/Scenes/SceneDetails/OCounterButton"; import { @@ -154,7 +154,7 @@ export const LightboxComponent: React.FC = ({ const Toast = useToast(); const intl = useIntl(); - const { configuration: config } = React.useContext(ConfigurationContext); + const { configuration: config } = useConfigurationContext(); const [interfaceLocalForage, setInterfaceLocalForage] = useInterfaceLocalForage(); diff --git a/ui/v2.5/src/hooks/useTableColumns.ts b/ui/v2.5/src/hooks/useTableColumns.ts index 09d5357d2..ed6380bdb 100644 --- a/ui/v2.5/src/hooks/useTableColumns.ts +++ b/ui/v2.5/src/hooks/useTableColumns.ts @@ -1,6 +1,5 @@ -import { useContext } from "react"; import { useConfigureUI } from "src/core/StashService"; -import { ConfigurationContext } from "src/hooks/Config"; +import { useConfigurationContext } from "src/hooks/Config"; import { useToast } from "./Toast"; export const useTableColumns = ( @@ -9,7 +8,7 @@ export const useTableColumns = ( ) => { const Toast = useToast(); - const { configuration } = useContext(ConfigurationContext); + const { configuration } = useConfigurationContext(); const [saveUI] = useConfigureUI(); const ui = configuration?.ui; diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index eedc84c01..74599eb34 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -10,6 +10,7 @@ $sidebar-width: 250px; @import "styles/theme"; @import "styles/range"; @import "styles/scrollbars"; +@import "sfw-mode.scss"; @import "src/components/Changelog/styles.scss"; @import "src/components/Galleries/styles.scss"; @import "src/components/Help/styles.scss"; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 08297727a..1adcd7671 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -784,6 +784,10 @@ "description": "Number of times to attempt to scroll before moving to the next/previous item. Only applies for Pan Y scroll mode.", "heading": "Scroll attempts before transition" }, + "sfw_mode": { + "description": "Enable if using stash to store SFW content. Hides or changes some adult-content-related aspects of the UI.", + "heading": "SFW Content Mode" + }, "show_tag_card_on_hover": { "description": "Show tag card when hovering tag badges", "heading": "Tag card tooltips" @@ -897,6 +901,7 @@ "developmentVersion": "Development Version", "dialogs": { "clear_o_history_confirm": "Are you sure you want to clear the O history?", + "clear_o_history_confirm_sfw": "Are you sure you want to clear the like history?", "clear_play_history_confirm": "Are you sure you want to clear the play history?", "create_new_entity": "Create new {entity}", "delete_alert": "The following {count, plural, one {{singularEntity}} other {{pluralEntity}}} will be deleted permanently:", @@ -1158,6 +1163,7 @@ "interactive_speed": "Interactive Speed", "isMissing": "Is Missing", "last_o_at": "Last O At", + "last_o_at_sfw": "Last Like At", "last_played_at": "Last Played At", "library": "Library", "loading": { @@ -1198,9 +1204,11 @@ "new": "New", "none": "None", "o_count": "O Count", - "o_counter": "O-Counter", + "o_count_sfw": "Likes", "o_history": "O History", + "o_history_sfw": "Like History", "odate_recorded_no": "No O Date Recorded", + "odate_recorded_no_sfw": "No Like Date Recorded", "operations": "Operations", "organized": "Organised", "orientation": "Orientation", @@ -1377,25 +1385,28 @@ }, "paths": { "database_filename_empty_for_default": "database filename (empty for default)", - "description": "Next up, we need to determine where to find your porn collection, and where to store the Stash database, generated files and cache files. These settings can be changed later if needed.", + "description": "Next up, we need to determine where to find your content, and where to store the Stash database, generated files and cache files. These settings can be changed later if needed.", "path_to_blobs_directory_empty_for_default": "path to blobs directory (empty for default)", "path_to_cache_directory_empty_for_default": "path to cache directory (empty for default)", "path_to_generated_directory_empty_for_default": "path to generated directory (empty for default)", "set_up_your_paths": "Set up your paths", + "sfw_content_settings": "Using stash for SFW content?", + "sfw_content_settings_description": "stash can be used to manage SFW content such as photography, art, comics, and more. Enabling this option will adjust some UI behaviour to be more appropriate for SFW content.", "stash_alert": "No library paths have been selected. No media will be able to be scanned into Stash. Are you sure?", "store_blobs_in_database": "Store blobs in database", + "use_sfw_content_mode": "Use SFW content mode", "where_can_stash_store_blobs": "Where can Stash store database binary data?", "where_can_stash_store_blobs_description": "Stash can store binary data such as scene covers, performer, studio and tag images either in the database or in the filesystem. By default, it will store this data in the filesystem in the subdirectory blobs within the directory containing your config file. If you want to change this, please enter an absolute or relative (to the current working directory) path. Stash will create this directory if it does not already exist.", "where_can_stash_store_blobs_description_addendum": "Alternatively, you can store this data in the database. Note: This will increase the size of your database file, and will increase database migration times.", "where_can_stash_store_cache_files": "Where can Stash store cache files?", "where_can_stash_store_cache_files_description": "In order for some functionality like HLS/DASH live transcoding to function, Stash requires a cache directory for temporary files. By default, Stash will create a cache directory within the directory containing your config file. If you want to change this, please enter an absolute or relative (to the current working directory) path. Stash will create this directory if it does not already exist.", "where_can_stash_store_its_database": "Where can Stash store its database?", - "where_can_stash_store_its_database_description": "Stash uses an SQLite database to store your porn metadata. By default, this will be created as stash-go.sqlite in the directory containing your config file. If you want to change this, please enter an absolute or relative (to the current working directory) filename.", + "where_can_stash_store_its_database_description": "Stash uses an SQLite database to store your content metadata. By default, this will be created as stash-go.sqlite in the directory containing your config file. If you want to change this, please enter an absolute or relative (to the current working directory) filename.", "where_can_stash_store_its_database_warning": "WARNING: storing the database on a different system to where Stash is run from (e.g. storing the database on a NAS while running the Stash server on another computer) is unsupported! SQLite is not intended for use across a network, and attempting to do so can very easily cause your entire database to become corrupted.", "where_can_stash_store_its_generated_content": "Where can Stash store its generated content?", "where_can_stash_store_its_generated_content_description": "In order to provide thumbnails, previews and sprites, Stash generates images and videos. This also includes transcodes for unsupported file formats. By default, Stash will create a generated directory within the directory containing your config file. If you want to change where this generated media will be stored, please enter an absolute or relative (to the current working directory) path. Stash will create this directory if it does not already exist.", - "where_is_your_porn_located": "Where is your porn located?", - "where_is_your_porn_located_description": "Add directories containing your porn videos and images. Stash will use these directories to find videos and images during scanning." + "where_is_your_porn_located": "Where is your content located?", + "where_is_your_porn_located_description": "Add directories containing your videos and images. Stash will use these directories to find videos and images during scanning." }, "stash_setup_wizard": "Stash Setup Wizard", "success": { diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index a4d3a145c..8f30e5d17 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -78,7 +78,7 @@ export abstract class Criterion { protected cloneValues() {} - public abstract getLabel(intl: IntlShape): string; + public abstract getLabel(intl: IntlShape, sfwContentMode?: boolean): string; public getId(): string { return `${this.criterionOption.type}`; @@ -148,7 +148,7 @@ export abstract class ModifierCriterion< : ""; } - public getLabel(intl: IntlShape): string { + public getLabel(intl: IntlShape, sfwContentMode: boolean = false): string { const modifierString = ModifierCriterion.getModifierLabel( intl, this.modifier @@ -162,10 +162,14 @@ export abstract class ModifierCriterion< valueString = this.getLabelValue(intl); } + const messageID = !sfwContentMode + ? this.criterionOption.messageID + : this.criterionOption.sfwMessageID ?? this.criterionOption.messageID; + return intl.formatMessage( { id: "criterion_modifier.format_string" }, { - criterion: intl.formatMessage({ id: this.criterionOption.messageID }), + criterion: intl.formatMessage({ id: messageID }), modifierString, valueString, } @@ -257,12 +261,14 @@ interface ICriterionOptionParams { type: CriterionType; makeCriterion: MakeCriterionFn; hidden?: boolean; + sfwMessageID?: string; } export class CriterionOption { public readonly type: CriterionType; public readonly messageID: string; public readonly makeCriterionFn: MakeCriterionFn; + public readonly sfwMessageID?: string; // used for legacy criteria that are not shown in the UI public readonly hidden: boolean = false; @@ -272,6 +278,7 @@ export class CriterionOption { this.messageID = options.messageID; this.makeCriterionFn = options.makeCriterion; this.hidden = options.hidden ?? false; + this.sfwMessageID = options.sfwMessageID; } public makeCriterion(config?: ConfigDataFragment) { @@ -478,7 +485,7 @@ export class IHierarchicalLabeledIdCriterion extends ModifierCriterion ModifierCriterion + makeCriterion?: () => ModifierCriterion, + options?: { sfwMessageID?: string } ) { super({ messageID, @@ -773,15 +790,22 @@ export class MandatoryNumberCriterionOption extends ModifierCriterionOption { makeCriterion: makeCriterion ? makeCriterion : () => new NumberCriterion(this), + ...options, }); } } export function createMandatoryNumberCriterionOption( value: CriterionType, - messageID?: string + messageID?: string, + options?: { sfwMessageID?: string } ) { - return new MandatoryNumberCriterionOption(messageID ?? value, value); + return new MandatoryNumberCriterionOption( + messageID ?? value, + value, + undefined, + options + ); } export function encodeRangeValue( diff --git a/ui/v2.5/src/models/list-filter/filter-options.ts b/ui/v2.5/src/models/list-filter/filter-options.ts index 32b86e786..a63394f35 100644 --- a/ui/v2.5/src/models/list-filter/filter-options.ts +++ b/ui/v2.5/src/models/list-filter/filter-options.ts @@ -4,6 +4,7 @@ import { DisplayMode } from "./types"; export interface ISortByOption { messageID: string; value: string; + sfwMessageID?: string; } export const MediaSortByOptions = [ @@ -22,7 +23,7 @@ export class ListFilterOptions { public readonly displayModeOptions: DisplayMode[] = []; public readonly criterionOptions: CriterionOption[] = []; - public static createSortBy(value: string) { + public static createSortBy(value: string): ISortByOption { return { messageID: value, value, diff --git a/ui/v2.5/src/models/list-filter/groups.ts b/ui/v2.5/src/models/list-filter/groups.ts index 6aed48fdc..5a263b272 100644 --- a/ui/v2.5/src/models/list-filter/groups.ts +++ b/ui/v2.5/src/models/list-filter/groups.ts @@ -38,6 +38,7 @@ const sortByOptions = [ { messageID: "o_count", value: "o_counter", + sfwMessageID: "o_count_sfw", }, ]); const displayModeOptions = [DisplayMode.Grid]; @@ -53,7 +54,9 @@ const criterionOptions = [ RatingCriterionOption, PerformersCriterionOption, createDateCriterionOption("date"), - createMandatoryNumberCriterionOption("o_counter", "o_count"), + createMandatoryNumberCriterionOption("o_counter", "o_count", { + sfwMessageID: "o_count_sfw", + }), ContainingGroupsCriterionOption, SubGroupsCriterionOption, createMandatoryNumberCriterionOption("containing_group_count"), diff --git a/ui/v2.5/src/models/list-filter/images.ts b/ui/v2.5/src/models/list-filter/images.ts index d8619112d..4d5630b1c 100644 --- a/ui/v2.5/src/models/list-filter/images.ts +++ b/ui/v2.5/src/models/list-filter/images.ts @@ -31,6 +31,7 @@ const sortByOptions = ["filesize", "file_count", "date", ...MediaSortByOptions] { messageID: "o_count", value: "o_counter", + sfwMessageID: "o_count_sfw", }, ]); const displayModeOptions = [DisplayMode.Grid, DisplayMode.Wall]; @@ -43,7 +44,9 @@ const criterionOptions = [ PathCriterionOption, GalleriesCriterionOption, OrganizedCriterionOption, - createMandatoryNumberCriterionOption("o_counter", "o_count"), + createMandatoryNumberCriterionOption("o_counter", "o_count", { + sfwMessageID: "o_count_sfw", + }), ResolutionCriterionOption, OrientationCriterionOption, ImageIsMissingCriterionOption, diff --git a/ui/v2.5/src/models/list-filter/performers.ts b/ui/v2.5/src/models/list-filter/performers.ts index 2cb3ef216..fcc152d01 100644 --- a/ui/v2.5/src/models/list-filter/performers.ts +++ b/ui/v2.5/src/models/list-filter/performers.ts @@ -31,7 +31,6 @@ const sortByOptions = [ "penis_length", "play_count", "last_played_at", - "last_o_at", "career_length", "weight", "measurements", @@ -54,6 +53,12 @@ const sortByOptions = [ { messageID: "o_count", value: "o_counter", + sfwMessageID: "o_count_sfw", + }, + { + messageID: "last_o_at", + value: "last_o_at", + sfwMessageID: "last_o_at_sfw", }, ]); @@ -102,7 +107,9 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("image_count"), createMandatoryNumberCriterionOption("gallery_count"), createMandatoryNumberCriterionOption("play_count"), - createMandatoryNumberCriterionOption("o_counter", "o_count"), + createMandatoryNumberCriterionOption("o_counter", "o_count", { + sfwMessageID: "o_count_sfw", + }), createBooleanCriterionOption("ignore_auto_tag"), CountryCriterionOption, createNumberCriterionOption("height_cm", "height"), diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index b8dd6515a..cf2791567 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -46,7 +46,6 @@ const sortByOptions = [ "framerate", "bitrate", "last_played_at", - "last_o_at", "resume_time", "play_duration", "play_count", @@ -62,6 +61,12 @@ const sortByOptions = [ { messageID: "o_count", value: "o_counter", + sfwMessageID: "o_count_sfw", + }, + { + messageID: "last_o_at", + value: "last_o_at", + sfwMessageID: "last_o_at_sfw", }, { messageID: "group_scene_number", @@ -97,7 +102,9 @@ const criterionOptions = [ DuplicatedCriterionOption, OrganizedCriterionOption, RatingCriterionOption, - createMandatoryNumberCriterionOption("o_counter", "o_count"), + createMandatoryNumberCriterionOption("o_counter", "o_count", { + sfwMessageID: "o_count_sfw", + }), ResolutionCriterionOption, OrientationCriterionOption, createMandatoryNumberCriterionOption("framerate"), diff --git a/ui/v2.5/src/sfw-mode.scss b/ui/v2.5/src/sfw-mode.scss new file mode 100644 index 000000000..9883afb4b --- /dev/null +++ b/ui/v2.5/src/sfw-mode.scss @@ -0,0 +1,91 @@ +// hide nsfw elements when in sfw-content mode +// stylelint-disable selector-class-pattern +.sfw-content-mode { + // hide adult-oriented performer fields in sort by select + .sort-by-select, + .performer-table { + [data-value="ethnicity"], + [data-value="hair_color"], + [data-value="eye_color"], + [data-value="measurements"], + [data-value="weight"], + [data-value="weight_kg"], + [data-value="penis_length"], + [data-value="penis_length_cm"], + [data-value="circumcised"], + [data-value="fake_tits"] { + display: none; + } + } + + .performer-table { + td, + th { + &.ethnicity, + &.hair_color, + &.eye_color, + &.height, + &.measurements, + &.weight_kg, + &.penis_length_cm, + &.circumcised, + &.fake_tits { + &-head, + &-data { + display: none; + } + } + } + } + + #performer-edit, + &.scrape-dialog { + [data-field="ethnicity"], + [data-field="hair_color"], + [data-field="eye_color"], + [data-field="measurements"], + [data-field="weight"], + [data-field="penis_length"], + [data-field="circumcised"], + [data-field="fake_tits"], + [data-field="tattoos"], + [data-field="piercings"] { + display: none; + } + } + + &.edit-filter-dialog { + [data-type="ethnicity"], + [data-type="hair_color"], + [data-type="eye_color"], + [data-type="measurements"], + [data-type="weight"], + [data-type="penis_length"], + [data-type="circumcised"], + [data-type="fake_tits"], + [data-type="tattoos"], + [data-type="piercings"] { + display: none; + } + } + + #performer-page { + .detail-item.ethnicity, + .detail-item.hair_color, + .detail-item.eye_color, + .detail-item.measurements, + .detail-item.weight, + .detail-item.penis_length, + .detail-item.circumcised, + .detail-item.fake_tits, + .detail-item.tattoos, + .detail-item.piercings { + display: none; + } + } + + // hide performer age on performer cards + .performer-card__age { + display: none; + } +} diff --git a/ui/v2.5/src/utils/form.tsx b/ui/v2.5/src/utils/form.tsx index f518d5700..e11d885f8 100644 --- a/ui/v2.5/src/utils/form.tsx +++ b/ui/v2.5/src/utils/form.tsx @@ -103,10 +103,14 @@ export function formikUtils( }, }: IProps = {} ) { - type Field = keyof V & string; + type FieldName = keyof V & string; type ErrorMessage = string | undefined; - function renderFormControl(field: Field, type: string, placeholder: string) { + function renderFormControl( + field: FieldName, + type: string, + placeholder: string + ) { const formikProps = formik.getFieldProps({ name: field, type: type }); const error = formik.errors[field] as ErrorMessage; @@ -168,38 +172,90 @@ export function formikUtils( ); } - function renderField( - field: Field, - title: string, - control: React.ReactNode, - props?: IProps - ) { + const FieldGroup: React.FC<{ + field: FieldName; + title: string; + control: React.ReactNode; + props?: IProps; + className?: string; + }> = ({ field, title, control, props, className }) => { return ( - + {title} {control} ); + }; + + function renderField( + field: FieldName, + title: string, + control: React.ReactNode, + props?: IProps, + className?: string + ) { + return ( + + ); } - function renderInputField( - field: Field, - type: string = "text", - messageID: string = field, - props?: IProps - ) { + const InputFieldGroup: React.FC<{ + field: FieldName; + type?: string; + messageID?: string; + props?: IProps; + className?: string; + }> = ({ field, type = "text", messageID = field, props, className }) => { const title = intl.formatMessage({ id: messageID }); const control = renderFormControl(field, type, title); - return renderField(field, title, control, props); + return ( + + ); + }; + + function renderInputField( + field: FieldName, + type: string = "text", + messageID: string = field, + props?: IProps, + className?: string + ) { + return ( + + ); } - function renderSelectField( - field: Field, - entries: Map, - messageID: string = field, - props?: IProps - ) { + const SelectFieldGroup: React.FC<{ + field: FieldName; + className?: string; + entries: Map; + messageID?: string; + props?: IProps; + }> = ({ field, className, entries, messageID = field, props }) => { const formikProps = formik.getFieldProps(field); let { value } = formikProps; @@ -224,11 +280,35 @@ export function formikUtils( ); - return renderField(field, title, control, props); + return ( + + ); + }; + + function renderSelectField( + field: FieldName, + entries: Map, + messageID: string = field, + props?: IProps + ) { + return ( + + ); } function renderDateField( - field: Field, + field: FieldName, messageID: string = field, props?: IProps ) { @@ -248,7 +328,7 @@ export function formikUtils( } function renderDurationField( - field: Field, + field: FieldName, messageID: string = field, props?: IProps ) { @@ -268,7 +348,7 @@ export function formikUtils( } function renderRatingField( - field: Field, + field: FieldName, messageID: string = field, props?: IProps ) { @@ -309,7 +389,7 @@ export function formikUtils( } function renderStringListField( - field: Field, + field: FieldName, messageID: string = field, props?: IProps ) { @@ -332,7 +412,7 @@ export function formikUtils( } function renderURLListField( - field: Field, + field: FieldName, onScrapeClick?: (url: string) => void, urlScrapable?: (url: string) => boolean, messageID: string = field, @@ -359,7 +439,7 @@ export function formikUtils( } function renderStashIDsField( - field: Field, + field: FieldName, linkType: LinkType, messageID: string = field, props?: IProps @@ -405,8 +485,11 @@ export function formikUtils( return { renderFormControl, renderField, + FieldGroup, renderInputField, + InputFieldGroup, renderSelectField, + SelectFieldGroup, renderDateField, renderDurationField, renderRatingField, From 2f65a1da3e67fa8fa860348ca1972961cd31f060 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 18 Nov 2025 13:45:37 +1100 Subject: [PATCH 137/157] Revert form changes from #6262 Removes the components inside the formikUtils function, which was causing incorrect re-renders. Adds data-field to renderField instead, which is a far more simple change. --- ui/v2.5/src/utils/form.tsx | 141 ++++++++----------------------------- 1 file changed, 29 insertions(+), 112 deletions(-) diff --git a/ui/v2.5/src/utils/form.tsx b/ui/v2.5/src/utils/form.tsx index e11d885f8..1fef74cf2 100644 --- a/ui/v2.5/src/utils/form.tsx +++ b/ui/v2.5/src/utils/form.tsx @@ -103,14 +103,10 @@ export function formikUtils( }, }: IProps = {} ) { - type FieldName = keyof V & string; + type Field = keyof V & string; type ErrorMessage = string | undefined; - function renderFormControl( - field: FieldName, - type: string, - placeholder: string - ) { + function renderFormControl(field: Field, type: string, placeholder: string) { const formikProps = formik.getFieldProps({ name: field, type: type }); const error = formik.errors[field] as ErrorMessage; @@ -172,90 +168,38 @@ export function formikUtils( ); } - const FieldGroup: React.FC<{ - field: FieldName; - title: string; - control: React.ReactNode; - props?: IProps; - className?: string; - }> = ({ field, title, control, props, className }) => { + function renderField( + field: Field, + title: string, + control: React.ReactNode, + props?: IProps + ) { return ( - + {title} {control} ); - }; - - function renderField( - field: FieldName, - title: string, - control: React.ReactNode, - props?: IProps, - className?: string - ) { - return ( - - ); } - const InputFieldGroup: React.FC<{ - field: FieldName; - type?: string; - messageID?: string; - props?: IProps; - className?: string; - }> = ({ field, type = "text", messageID = field, props, className }) => { + function renderInputField( + field: Field, + type: string = "text", + messageID: string = field, + props?: IProps + ) { const title = intl.formatMessage({ id: messageID }); const control = renderFormControl(field, type, title); - return ( - - ); - }; - - function renderInputField( - field: FieldName, - type: string = "text", - messageID: string = field, - props?: IProps, - className?: string - ) { - return ( - - ); + return renderField(field, title, control, props); } - const SelectFieldGroup: React.FC<{ - field: FieldName; - className?: string; - entries: Map; - messageID?: string; - props?: IProps; - }> = ({ field, className, entries, messageID = field, props }) => { + function renderSelectField( + field: Field, + entries: Map, + messageID: string = field, + props?: IProps + ) { const formikProps = formik.getFieldProps(field); let { value } = formikProps; @@ -280,35 +224,11 @@ export function formikUtils( ); - return ( - - ); - }; - - function renderSelectField( - field: FieldName, - entries: Map, - messageID: string = field, - props?: IProps - ) { - return ( - - ); + return renderField(field, title, control, props); } function renderDateField( - field: FieldName, + field: Field, messageID: string = field, props?: IProps ) { @@ -328,7 +248,7 @@ export function formikUtils( } function renderDurationField( - field: FieldName, + field: Field, messageID: string = field, props?: IProps ) { @@ -348,7 +268,7 @@ export function formikUtils( } function renderRatingField( - field: FieldName, + field: Field, messageID: string = field, props?: IProps ) { @@ -389,7 +309,7 @@ export function formikUtils( } function renderStringListField( - field: FieldName, + field: Field, messageID: string = field, props?: IProps ) { @@ -412,7 +332,7 @@ export function formikUtils( } function renderURLListField( - field: FieldName, + field: Field, onScrapeClick?: (url: string) => void, urlScrapable?: (url: string) => boolean, messageID: string = field, @@ -439,7 +359,7 @@ export function formikUtils( } function renderStashIDsField( - field: FieldName, + field: Field, linkType: LinkType, messageID: string = field, props?: IProps @@ -485,11 +405,8 @@ export function formikUtils( return { renderFormControl, renderField, - FieldGroup, renderInputField, - InputFieldGroup, renderSelectField, - SelectFieldGroup, renderDateField, renderDurationField, renderRatingField, From 78aeb06f20f719f73e6ce905a99908accc0f98b8 Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Mon, 17 Nov 2025 22:04:22 -0500 Subject: [PATCH 138/157] add lumberjack log rotation (#5696) * [logging] add UI and graphql for maximum log size * [logger] set default size to 0MB and don't rotate --- cmd/stash/main.go | 4 +-- go.mod | 1 + go.sum | 2 ++ graphql/schema/types/config.graphql | 4 +++ internal/api/resolver_mutation_configure.go | 4 +++ internal/api/resolver_query_configuration.go | 1 + internal/log/logger.go | 30 ++++++++++++------- internal/manager/config/config.go | 26 +++++++++++----- ui/v2.5/graphql/data/config.graphql | 1 + .../Settings/SettingsSystemPanel.tsx | 8 +++++ ui/v2.5/src/locales/en-GB.json | 2 ++ 11 files changed, 64 insertions(+), 19 deletions(-) diff --git a/cmd/stash/main.go b/cmd/stash/main.go index 86edd6276..e3a54f020 100644 --- a/cmd/stash/main.go +++ b/cmd/stash/main.go @@ -110,7 +110,7 @@ func main() { // Logs only error level message to stderr. func initLogTemp() *log.Logger { l := log.NewLogger() - l.Init("", true, "Error") + l.Init("", true, "Error", 0) logger.Logger = l return l @@ -118,7 +118,7 @@ func initLogTemp() *log.Logger { func initLog(cfg *config.Config) *log.Logger { l := log.NewLogger() - l.Init(cfg.GetLogFile(), cfg.GetLogOut(), cfg.GetLogLevel()) + l.Init(cfg.GetLogFile(), cfg.GetLogOut(), cfg.GetLogLevel(), cfg.GetLogFileMaxSize()) logger.Logger = l return l diff --git a/go.mod b/go.mod index bf2eb0f6e..4d6b78dc6 100644 --- a/go.mod +++ b/go.mod @@ -63,6 +63,7 @@ require ( golang.org/x/text v0.25.0 golang.org/x/time v0.10.0 gopkg.in/guregu/null.v4 v4.0.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v2 v2.4.0 ) diff --git a/go.sum b/go.sum index dced0768f..bc84b1f23 100644 --- a/go.sum +++ b/go.sum @@ -1122,6 +1122,8 @@ gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.66.3/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 63ce3ea1c..6a1ac72be 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -155,6 +155,8 @@ input ConfigGeneralInput { logLevel: String "Whether to log http access" logAccess: Boolean + "Maximum log size" + logFileMaxSize: Int "True if galleries should be created from folders with images" createGalleriesFromFolders: Boolean "Regex used to identify images as gallery covers" @@ -279,6 +281,8 @@ type ConfigGeneralResult { logLevel: String! "Whether to log http access" logAccess: Boolean! + "Maximum log size" + logFileMaxSize: Int! "Array of video file extensions" videoExtensions: [String!]! "Array of image file extensions" diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index ba46a115a..3299c01a8 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -334,6 +334,10 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen logger.SetLogLevel(*input.LogLevel) } + if input.LogFileMaxSize != nil && *input.LogFileMaxSize != c.GetLogFileMaxSize() { + c.SetInt(config.LogFileMaxSize, *input.LogFileMaxSize) + } + if input.Excludes != nil { for _, r := range input.Excludes { _, err := regexp.Compile(r) diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index 5952dd41e..7213f8447 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -115,6 +115,7 @@ func makeConfigGeneralResult() *ConfigGeneralResult { LogOut: config.GetLogOut(), LogLevel: config.GetLogLevel(), LogAccess: config.GetLogAccess(), + LogFileMaxSize: config.GetLogFileMaxSize(), VideoExtensions: config.GetVideoExtensions(), ImageExtensions: config.GetImageExtensions(), GalleryExtensions: config.GetGalleryExtensions(), diff --git a/internal/log/logger.go b/internal/log/logger.go index 5f686d32d..cb07121a5 100644 --- a/internal/log/logger.go +++ b/internal/log/logger.go @@ -3,12 +3,14 @@ package log import ( "fmt" + "io" "os" "strings" "sync" "time" "github.com/sirupsen/logrus" + lumberjack "gopkg.in/natefinch/lumberjack.v2" ) type LogItem struct { @@ -41,8 +43,8 @@ func NewLogger() *Logger { } // Init initialises the logger based on a logging configuration -func (log *Logger) Init(logFile string, logOut bool, logLevel string) { - var file *os.File +func (log *Logger) Init(logFile string, logOut bool, logLevel string, logFileMaxSize int) { + var logger io.WriteCloser customFormatter := new(logrus.TextFormatter) customFormatter.TimestampFormat = "2006-01-02 15:04:05" customFormatter.ForceColors = true @@ -57,30 +59,38 @@ func (log *Logger) Init(logFile string, logOut bool, logLevel string) { // the access log colouring not being applied _, _ = customFormatter.Format(logrus.NewEntry(log.logger)) + // if size is 0, disable rotation if logFile != "" { - var err error - file, err = os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) - - if err != nil { - fmt.Printf("Could not open '%s' for log output due to error: %s\n", logFile, err.Error()) + if logFileMaxSize == 0 { + var err error + logger, err = os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + fmt.Fprintf(os.Stderr, "unable to open log file %s: %v\n", logFile, err) + } + } else { + logger = &lumberjack.Logger{ + Filename: logFile, + MaxSize: logFileMaxSize, // Megabytes + Compress: true, + } } } - if file != nil { + if logger != nil { if logOut { // log to file separately disabling colours fileFormatter := new(logrus.TextFormatter) fileFormatter.TimestampFormat = customFormatter.TimestampFormat fileFormatter.FullTimestamp = customFormatter.FullTimestamp log.logger.AddHook(&fileLogHook{ - Writer: file, + Writer: logger, Formatter: fileFormatter, }) } else { // logging to file only // turn off the colouring for the file customFormatter.ForceColors = false - log.logger.Out = file + log.logger.Out = logger } } diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index a351cc872..eda863663 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -252,13 +252,15 @@ const ( DLNAPortDefault = 1338 // Logging options - LogFile = "logfile" - LogOut = "logout" - defaultLogOut = true - LogLevel = "loglevel" - defaultLogLevel = "Info" - LogAccess = "logaccess" - defaultLogAccess = true + LogFile = "logfile" + LogOut = "logout" + defaultLogOut = true + LogLevel = "loglevel" + defaultLogLevel = "Info" + LogAccess = "logaccess" + defaultLogAccess = true + LogFileMaxSize = "logfile_max_size" + defaultLogFileMaxSize = 0 // megabytes, default disabled // Default settings DefaultScanSettings = "defaults.scan_task" @@ -1636,6 +1638,16 @@ func (i *Config) GetLogAccess() bool { return i.getBoolDefault(LogAccess, defaultLogAccess) } +// GetLogFileMaxSize returns the maximum size of the log file in megabytes for lumberjack to rotate +func (i *Config) GetLogFileMaxSize() int { + value := i.getInt(LogFileMaxSize) + if value < 0 { + value = defaultLogFileMaxSize + } + + return value +} + // Max allowed graphql upload size in megabytes func (i *Config) GetMaxUploadSize() int64 { i.RLock() diff --git a/ui/v2.5/graphql/data/config.graphql b/ui/v2.5/graphql/data/config.graphql index 95d55864f..192fb8053 100644 --- a/ui/v2.5/graphql/data/config.graphql +++ b/ui/v2.5/graphql/data/config.graphql @@ -37,6 +37,7 @@ fragment ConfigGeneralData on ConfigGeneralResult { logOut logLevel logAccess + logFileMaxSize createGalleriesFromFolders galleryCoverRegex videoExtensions diff --git a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx index a3ab150db..3baeca1e2 100644 --- a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx @@ -465,6 +465,14 @@ export const SettingsConfigurationPanel: React.FC = () => { checked={general.logAccess ?? false} onChange={(v) => saveGeneral({ logAccess: v })} /> + + saveGeneral({ logFileMaxSize: v })} + /> ); diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 1adcd7671..6a230736a 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -301,6 +301,8 @@ "log_http_desc": "Logs http access to the terminal. Requires restart.", "log_to_terminal": "Log to terminal", "log_to_terminal_desc": "Logs to the terminal in addition to a file. Always true if file logging is disabled. Requires restart.", + "log_file_max_size": "Maximum log size", + "log_file_max_size_desc": "Maximum size in megabytes of the log file before it is compressed. 0MB is disabled. Requires restart.", "maximum_session_age": "Maximum Session Age", "maximum_session_age_desc": "Maximum idle time before a login session is expired, in seconds. Requires restart.", "password": "Password", From a31df336f874c1d340487f11457f992862db6fb8 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Mon, 17 Nov 2025 20:05:55 -0800 Subject: [PATCH 139/157] Remove style for Studio URLs (#6291) --- ui/v2.5/src/components/Studios/styles.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/v2.5/src/components/Studios/styles.scss b/ui/v2.5/src/components/Studios/styles.scss index eaab21d10..772f42db1 100644 --- a/ui/v2.5/src/components/Studios/styles.scss +++ b/ui/v2.5/src/components/Studios/styles.scss @@ -49,5 +49,9 @@ display: none; } } + + .detail-item.urls ul { + list-style-type: none; + } /* stylelint-enable selector-class-pattern */ } From 367b96df0f003e1e75db44e4f859bee7541d7007 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Mon, 17 Nov 2025 20:06:25 -0800 Subject: [PATCH 140/157] Bug Fix: Update Macos Version Check (#6289) --- internal/api/check_version.go | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/internal/api/check_version.go b/internal/api/check_version.go index 6279997d7..f4c2950f1 100644 --- a/internal/api/check_version.go +++ b/internal/api/check_version.go @@ -7,8 +7,10 @@ import ( "fmt" "io" "net/http" + "os" "regexp" "runtime" + "strings" "time" "golang.org/x/sys/cpu" @@ -36,6 +38,24 @@ var stashReleases = func() map[string]string { } } +// isMacOSBundle checks if the application is running from within a macOS .app bundle +func isMacOSBundle() bool { + exec, err := os.Executable() + return err == nil && strings.Contains(exec, "Stash.app/") +} + +// getWantedRelease determines which release variant to download based on platform and bundle type +func getWantedRelease(platform string) string { + release := stashReleases()[platform] + + // On macOS, check if running from .app bundle + if runtime.GOOS == "darwin" && isMacOSBundle() { + return "Stash.app.zip" + } + + return release +} + type githubReleasesResponse struct { Url string Assets_url string @@ -168,7 +188,7 @@ func GetLatestRelease(ctx context.Context) (*LatestRelease, error) { } platform := fmt.Sprintf("%s/%s", runtime.GOOS, arch) - wantedRelease := stashReleases()[platform] + wantedRelease := getWantedRelease(platform) url := apiReleases if build.IsDevelop() { From 33b59e02afcad97727acae5219cfabffa40244af Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Mon, 17 Nov 2025 23:07:08 -0500 Subject: [PATCH 141/157] [markers] ignore generating markers past end (#6290) --- internal/manager/task_generate_markers.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/manager/task_generate_markers.go b/internal/manager/task_generate_markers.go index f37c7aed1..cfe17926c 100644 --- a/internal/manager/task_generate_markers.go +++ b/internal/manager/task_generate_markers.go @@ -107,6 +107,12 @@ func (t *GenerateMarkersTask) generateMarker(videoFile *models.VideoFile, scene sceneHash := scene.GetHash(t.fileNamingAlgorithm) seconds := float64(sceneMarker.Seconds) + // check if marker past duration + if seconds > float64(videoFile.Duration) { + logger.Warnf("[generator] scene marker at %.2f seconds exceeds video duration of %.2f seconds, skipping", seconds, float64(videoFile.Duration)) + return + } + g := t.generator if err := g.MarkerPreviewVideo(context.TODO(), videoFile.Path, sceneHash, seconds, sceneMarker.EndSeconds, instance.Config.GetPreviewAudio()); err != nil { From 2332401dbfbcdbb943dac09e89e8f9ed87856a02 Mon Sep 17 00:00:00 2001 From: NodudeWasTaken <75137537+NodudeWasTaken@users.noreply.github.com> Date: Tue, 18 Nov 2025 23:10:00 +0100 Subject: [PATCH 142/157] Fix missing saved filter overwrite translation (#6294) This translation was renamed from _confirm to _warning. --- ui/v2.5/src/components/List/SavedFilterList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/List/SavedFilterList.tsx b/ui/v2.5/src/components/List/SavedFilterList.tsx index 83c6d8a65..df1d6136a 100644 --- a/ui/v2.5/src/components/List/SavedFilterList.tsx +++ b/ui/v2.5/src/components/List/SavedFilterList.tsx @@ -222,7 +222,7 @@ const OverwriteAlert: React.FC<{ Date: Tue, 18 Nov 2025 21:28:20 -0500 Subject: [PATCH 143/157] Sanitise intent URL (#6297) --- .../src/components/Scenes/SceneDetails/ExternalPlayerButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/ExternalPlayerButton.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/ExternalPlayerButton.tsx index b17dfb6bb..3701f4138 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/ExternalPlayerButton.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/ExternalPlayerButton.tsx @@ -29,7 +29,7 @@ export const ExternalPlayerButton: React.FC = ({ const streamURL = new URL(stream); if (isAndroid) { const scheme = streamURL.protocol.slice(0, -1); - streamURL.hash = `Intent;action=android.intent.action.VIEW;scheme=${scheme};type=video/mp4;S.title=${encodeURI( + streamURL.hash = `Intent;action=android.intent.action.VIEW;scheme=${scheme};type=video/mp4;S.title=${encodeURIComponent( title )};end`; From 58b68333800cda4e7a7e7678ba2d9e9a6ab62d27 Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Tue, 18 Nov 2025 21:29:15 -0500 Subject: [PATCH 144/157] make airplay follow chromecast enable (#6296) --- ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index e07c0091d..cab5f22a8 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -369,7 +369,9 @@ export const ScenePlayer: React.FC = PatchComponent( }, }, plugins: { - airPlay: {}, + airPlay: { + addButtonToControlBar: uiConfig?.enableChromecast ?? false, + }, chromecast: {}, vttThumbnails: { showTimestamp: true, @@ -428,7 +430,7 @@ export const ScenePlayer: React.FC = PatchComponent( }; // empty deps - only init once // showAbLoopControls is necessary to re-init the player when the config changes - }, [uiConfig?.showAbLoopControls]); + }, [uiConfig?.showAbLoopControls, uiConfig?.enableChromecast]); useEffect(() => { const player = getPlayer(); From 2cac7d5b20b5729d76ad6381ec4e066a8a59ccea Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Mon, 24 Nov 2025 13:17:51 -0800 Subject: [PATCH 145/157] Bugfix: Add extra date formats. (#6305) --- pkg/utils/date.go | 12 +++++++ pkg/utils/date_test.go | 80 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 pkg/utils/date_test.go diff --git a/pkg/utils/date.go b/pkg/utils/date.go index de5566e4d..511cf8a4f 100644 --- a/pkg/utils/date.go +++ b/pkg/utils/date.go @@ -23,5 +23,17 @@ func ParseDateStringAsTime(dateString string) (time.Time, error) { return t, nil } + // Support partial dates: year-month format + t, e = time.Parse("2006-01", dateString) + if e == nil { + return t, nil + } + + // Support partial dates: year only format + t, e = time.Parse("2006", dateString) + if e == nil { + return t, nil + } + return time.Time{}, fmt.Errorf("ParseDateStringAsTime failed: dateString <%s>", dateString) } diff --git a/pkg/utils/date_test.go b/pkg/utils/date_test.go new file mode 100644 index 000000000..f3622ca40 --- /dev/null +++ b/pkg/utils/date_test.go @@ -0,0 +1,80 @@ +package utils + +import ( + "testing" + "time" +) + +func TestParseDateStringAsTime(t *testing.T) { + tests := []struct { + name string + input string + expectError bool + }{ + // Full date formats (existing support) + {"RFC3339", "2014-01-02T15:04:05Z", false}, + {"Date only", "2014-01-02", false}, + {"Date with time", "2014-01-02 15:04:05", false}, + + // Partial date formats (new support) + {"Year-Month", "2006-08", false}, + {"Year only", "2014", false}, + + // Invalid formats + {"Invalid format", "not-a-date", true}, + {"Empty string", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseDateStringAsTime(tt.input) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error for input %q, but got none", tt.input) + } + } else { + if err != nil { + t.Errorf("Unexpected error for input %q: %v", tt.input, err) + } + if result.IsZero() { + t.Errorf("Expected non-zero time for input %q", tt.input) + } + } + }) + } +} + +func TestParseDateStringAsTime_YearOnly(t *testing.T) { + result, err := ParseDateStringAsTime("2014") + if err != nil { + t.Fatalf("Failed to parse year-only date: %v", err) + } + + if result.Year() != 2014 { + t.Errorf("Expected year 2014, got %d", result.Year()) + } + if result.Month() != time.January { + t.Errorf("Expected month January, got %s", result.Month()) + } + if result.Day() != 1 { + t.Errorf("Expected day 1, got %d", result.Day()) + } +} + +func TestParseDateStringAsTime_YearMonth(t *testing.T) { + result, err := ParseDateStringAsTime("2006-08") + if err != nil { + t.Fatalf("Failed to parse year-month date: %v", err) + } + + if result.Year() != 2006 { + t.Errorf("Expected year 2006, got %d", result.Year()) + } + if result.Month() != time.August { + t.Errorf("Expected month August, got %s", result.Month()) + } + if result.Day() != 1 { + t.Errorf("Expected day 1, got %d", result.Day()) + } +} From e176cf5f7114083333e1f019a0bd18928956eab6 Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Mon, 24 Nov 2025 23:35:05 +0200 Subject: [PATCH 146/157] Document "# requires" in the plugin config (#6306) * Document "# requires" in the plugin config * Add missing line breaks in UIPluginApi documentation --- ui/v2.5/src/docs/en/Manual/Plugins.md | 9 +++++++-- ui/v2.5/src/docs/en/Manual/UIPluginApi.md | 2 ++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/docs/en/Manual/Plugins.md b/ui/v2.5/src/docs/en/Manual/Plugins.md index f7517aa2c..cd24e0d4a 100644 --- a/ui/v2.5/src/docs/en/Manual/Plugins.md +++ b/ui/v2.5/src/docs/en/Manual/Plugins.md @@ -65,8 +65,11 @@ Plugins provide tasks which can be run from the Tasks page. The basic structure of a plugin configuration file is as follows: -``` -name: +```yaml +name: +# optional list of dependencies to be included +# "#" is is part of the config - do not remove +# requires: description: version: url: @@ -121,6 +124,8 @@ tasks: The `name`, `description`, `version` and `url` fields are displayed on the plugins page. +`# requires` will make the plugin manager select plugins matching the specified IDs to be automatically installed as dependencies. Only works with plugins within the same index. + The `exec`, `interface`, `errLog` and `tasks` fields are used only for plugins with tasks. The `settings` field is used to display plugin settings on the plugins page. Plugin settings can also be set using the graphql mutation `configurePlugin` - the settings set this way do _not_ need to be specified in the `settings` field unless they are to be displayed in the stock plugin settings UI. diff --git a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md index 23cd3fd64..67d837ed7 100644 --- a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md +++ b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md @@ -23,6 +23,7 @@ This namespace contains the generated graphql client interface. This is a low-le ### `libraries` `libraries` provides access to the following UI libraries: + - `ReactRouterDOM` - `Bootstrap` - `Apollo` @@ -149,6 +150,7 @@ InteractiveUtils.interactiveClientProvider = ( ### `hooks` This namespace provides access to the following core utility hooks: + - `useGalleryLightbox` - `useLightbox` - `useSpriteInfo` From 5d02f916c21a41be74141eed3ed9d36dc94f740c Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Mon, 24 Nov 2025 13:58:57 -0800 Subject: [PATCH 147/157] Check for dupe IDs against boxes (#6309) --- pkg/models/stash_ids.go | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/pkg/models/stash_ids.go b/pkg/models/stash_ids.go index 7751c2ef0..d761e959f 100644 --- a/pkg/models/stash_ids.go +++ b/pkg/models/stash_ids.go @@ -79,10 +79,23 @@ func (s StashIDInputs) ToStashIDs() StashIDs { return nil } - ret := make(StashIDs, len(s)) - for i, v := range s { - ret[i] = v.ToStashID() + // #2800 - deduplicate StashIDs based on endpoint and stash_id + ret := make(StashIDs, 0, len(s)) + seen := make(map[string]map[string]bool) + + for _, v := range s { + stashID := v.ToStashID() + + if seen[stashID.Endpoint] == nil { + seen[stashID.Endpoint] = make(map[string]bool) + } + + if !seen[stashID.Endpoint][stashID.StashID] { + seen[stashID.Endpoint][stashID.StashID] = true + ret = append(ret, stashID) + } } + return ret } From ca8ee6bc2a8dc4ca46785b4c16baec1746d93ac0 Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Mon, 24 Nov 2025 17:12:23 -0500 Subject: [PATCH 148/157] add MediaSession plugin (#6298) --- .../components/ScenePlayer/ScenePlayer.tsx | 17 +++++ .../components/ScenePlayer/media-session.ts | 71 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 ui/v2.5/src/components/ScenePlayer/media-session.ts diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index cab5f22a8..c566eb1b3 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -22,6 +22,7 @@ import "./vtt-thumbnails"; import "./big-buttons"; import "./track-activity"; import "./vrmode"; +import "./media-session"; import cx from "classnames"; import { useSceneSaveActivity, @@ -397,6 +398,7 @@ export const ScenePlayer: React.FC = PatchComponent( pauseBeforeLooping: false, createButtons: uiConfig?.showAbLoopControls ?? false, }, + mediaSession: {}, }, }; @@ -874,6 +876,21 @@ export const ScenePlayer: React.FC = PatchComponent( return () => player.off("ended"); }, [getPlayer, onComplete]); + // set up mediaSession plugin + useEffect(() => { + const player = getPlayer(); + if (!player) return; + + // set up mediasession plugin + player + .mediaSession() + .setMetadata( + scene?.title ?? "Stash", + scene?.studio?.name ?? "Stash", + scene.paths.screenshot || "" + ); + }, [getPlayer, scene]); + function onScrubberScroll() { if (started.current) { getPlayer()?.pause(); diff --git a/ui/v2.5/src/components/ScenePlayer/media-session.ts b/ui/v2.5/src/components/ScenePlayer/media-session.ts new file mode 100644 index 000000000..7be1d0d4e --- /dev/null +++ b/ui/v2.5/src/components/ScenePlayer/media-session.ts @@ -0,0 +1,71 @@ +import videojs, { VideoJsPlayer } from "video.js"; + +class MediaSessionPlugin extends videojs.getPlugin("plugin") { + constructor(player: VideoJsPlayer) { + super(player); + + player.ready(() => { + player.addClass("vjs-media-session"); + this.setActionHandlers(); + }); + + player.on("play", () => { + this.updatePlaybackState(); + }); + + player.on("pause", () => { + this.updatePlaybackState(); + }); + this.updatePlaybackState(); + } + + // manually set poster since it's only set on useEffect + public setMetadata(title: string, studioName: string, poster: string): void { + if ("mediaSession" in navigator) { + navigator.mediaSession.metadata = new MediaMetadata({ + title, + artist: studioName, + artwork: [ + { + src: poster || this.player.poster() || "", + type: "image/jpeg", + }, + ], + }); + } + } + + private updatePlaybackState(): void { + if ("mediaSession" in navigator) { + const playbackState = this.player.paused() ? "paused" : "playing"; + navigator.mediaSession.playbackState = playbackState; + } + } + + private setActionHandlers(): void { + // method initialization + navigator.mediaSession.setActionHandler("play", () => { + this.player.play(); + }); + navigator.mediaSession.setActionHandler("pause", () => { + this.player.pause(); + }); + navigator.mediaSession.setActionHandler("nexttrack", () => { + this.player.skipButtons()?.handleForward(); + }); + navigator.mediaSession.setActionHandler("previoustrack", () => { + this.player.skipButtons()?.handleBackward(); + }); + } +} + +videojs.registerPlugin("mediaSession", MediaSessionPlugin); + +/* eslint-disable @typescript-eslint/naming-convention */ +declare module "video.js" { + interface VideoJsPlayer { + mediaSession: () => MediaSessionPlugin; + } +} + +export default MediaSessionPlugin; From ecd9c6ec5bdce26aeece0b80f159dcf4cc11e58f Mon Sep 17 00:00:00 2001 From: Slick Daddy <129640104+slick-daddy@users.noreply.github.com> Date: Tue, 25 Nov 2025 02:06:36 +0300 Subject: [PATCH 149/157] Show O Counter in Studio card (#5982) Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/schema/types/studio.graphql | 1 + internal/api/resolver_model_studio.go | 18 ++++++++++++++++ pkg/models/mocks/ImageReaderWriter.go | 21 +++++++++++++++++++ pkg/models/mocks/SceneReaderWriter.go | 21 +++++++++++++++++++ pkg/models/repository_image.go | 1 + pkg/models/repository_scene.go | 1 + pkg/sqlite/image.go | 14 +++++++++++++ pkg/sqlite/scene.go | 17 +++++++++++++++ ui/v2.5/graphql/data/studio-slim.graphql | 1 + ui/v2.5/graphql/data/studio.graphql | 1 + ui/v2.5/src/components/Studios/StudioCard.tsx | 9 ++++++++ .../Studios/StudioDetails/Studio.tsx | 18 ++++++++++------ ui/v2.5/src/components/Studios/styles.scss | 21 +++++++++++++++++++ 13 files changed, 138 insertions(+), 6 deletions(-) diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index 097f04eb3..4c5778c5b 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -25,6 +25,7 @@ type Studio { updated_at: Time! groups: [Group!]! movies: [Movie!]! @deprecated(reason: "use groups instead") + o_counter: Int } input StudioCreateInput { diff --git a/internal/api/resolver_model_studio.go b/internal/api/resolver_model_studio.go index 850d42b54..fabcf38bd 100644 --- a/internal/api/resolver_model_studio.go +++ b/internal/api/resolver_model_studio.go @@ -143,6 +143,24 @@ func (r *studioResolver) MovieCount(ctx context.Context, obj *models.Studio, dep return r.GroupCount(ctx, obj, depth) } +func (r *studioResolver) OCounter(ctx context.Context, obj *models.Studio) (ret *int, err error) { + var res_scene int + var res_image int + var res int + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + res_scene, err = r.repository.Scene.OCountByStudioID(ctx, obj.ID) + if err != nil { + return err + } + res_image, err = r.repository.Image.OCountByStudioID(ctx, obj.ID) + return err + }); err != nil { + return nil, err + } + res = res_scene + res_image + return &res, nil +} + func (r *studioResolver) ParentStudio(ctx context.Context, obj *models.Studio) (ret *models.Studio, err error) { if obj.ParentID == nil { return nil, nil diff --git a/pkg/models/mocks/ImageReaderWriter.go b/pkg/models/mocks/ImageReaderWriter.go index 2bbf4ceeb..afc5efdb7 100644 --- a/pkg/models/mocks/ImageReaderWriter.go +++ b/pkg/models/mocks/ImageReaderWriter.go @@ -594,6 +594,27 @@ func (_m *ImageReaderWriter) OCountByPerformerID(ctx context.Context, performerI return r0, r1 } +// OCountByStudioID provides a mock function with given fields: ctx, studioID +func (_m *ImageReaderWriter) OCountByStudioID(ctx context.Context, studioID int) (int, error) { + ret := _m.Called(ctx, studioID) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, studioID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, studioID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Query provides a mock function with given fields: ctx, options func (_m *ImageReaderWriter) Query(ctx context.Context, options models.ImageQueryOptions) (*models.ImageQueryResult, error) { ret := _m.Called(ctx, options) diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index bec31b6f2..ef10c890d 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -1183,6 +1183,27 @@ func (_m *SceneReaderWriter) OCountByPerformerID(ctx context.Context, performerI return r0, r1 } +// OCountByStudioID provides a mock function with given fields: ctx, studioID +func (_m *SceneReaderWriter) OCountByStudioID(ctx context.Context, studioID int) (int, error) { + ret := _m.Called(ctx, studioID) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, studioID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, studioID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // PlayDuration provides a mock function with given fields: ctx func (_m *SceneReaderWriter) PlayDuration(ctx context.Context) (float64, error) { ret := _m.Called(ctx) diff --git a/pkg/models/repository_image.go b/pkg/models/repository_image.go index 1455d7762..672ecd063 100644 --- a/pkg/models/repository_image.go +++ b/pkg/models/repository_image.go @@ -38,6 +38,7 @@ type ImageCounter interface { CountByGalleryID(ctx context.Context, galleryID int) (int, error) OCount(ctx context.Context) (int, error) OCountByPerformerID(ctx context.Context, performerID int) (int, error) + OCountByStudioID(ctx context.Context, studioID int) (int, error) } // ImageCreator provides methods to create images. diff --git a/pkg/models/repository_scene.go b/pkg/models/repository_scene.go index fe0f473fb..8c2833470 100644 --- a/pkg/models/repository_scene.go +++ b/pkg/models/repository_scene.go @@ -45,6 +45,7 @@ type SceneCounter interface { CountMissingOSHash(ctx context.Context) (int, error) OCountByPerformerID(ctx context.Context, performerID int) (int, error) OCountByGroupID(ctx context.Context, groupID int) (int, error) + OCountByStudioID(ctx context.Context, studioID int) (int, error) } // SceneCreator provides methods to create scenes. diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 6575ebb91..1588fa415 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -682,6 +682,20 @@ func (qb *ImageStore) OCountByPerformerID(ctx context.Context, performerID int) return ret, nil } +func (qb *ImageStore) OCountByStudioID(ctx context.Context, studioID int) (int, error) { + table := qb.table() + q := dialect.Select(goqu.COALESCE(goqu.SUM("o_counter"), 0)).From(table).Where( + table.Col(studioIDColumn).Eq(studioID), + ) + + var ret int + if err := querySimple(ctx, q, &ret); err != nil { + return 0, err + } + + return ret, nil +} + func (qb *ImageStore) OCount(ctx context.Context) (int, error) { table := qb.table() diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 23f5ef482..40feb5847 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -818,6 +818,23 @@ func (qb *SceneStore) OCountByGroupID(ctx context.Context, groupID int) (int, er return ret, nil } +func (qb *SceneStore) OCountByStudioID(ctx context.Context, studioID int) (int, error) { + table := qb.table() + oHistoryTable := goqu.T(scenesODatesTable) + + q := dialect.Select(goqu.COUNT("*")).From(table).InnerJoin( + oHistoryTable, + goqu.On(table.Col(idColumn).Eq(oHistoryTable.Col(sceneIDColumn))), + ).Where(table.Col(studioIDColumn).Eq(studioID)) + + var ret int + if err := querySimple(ctx, q, &ret); err != nil { + return 0, err + } + + return ret, nil +} + func (qb *SceneStore) FindByGroupID(ctx context.Context, groupID int) ([]*models.Scene, error) { sq := dialect.From(scenesGroupsJoinTable).Select(scenesGroupsJoinTable.Col(sceneIDColumn)).Where( scenesGroupsJoinTable.Col(groupIDColumn).Eq(groupID), diff --git a/ui/v2.5/graphql/data/studio-slim.graphql b/ui/v2.5/graphql/data/studio-slim.graphql index cf101bd04..c48f7d93e 100644 --- a/ui/v2.5/graphql/data/studio-slim.graphql +++ b/ui/v2.5/graphql/data/studio-slim.graphql @@ -17,4 +17,5 @@ fragment SlimStudioData on Studio { id name } + o_counter } diff --git a/ui/v2.5/graphql/data/studio.graphql b/ui/v2.5/graphql/data/studio.graphql index d4ba79887..aabec7a9b 100644 --- a/ui/v2.5/graphql/data/studio.graphql +++ b/ui/v2.5/graphql/data/studio.graphql @@ -39,6 +39,7 @@ fragment StudioData on Studio { tags { ...SlimTagData } + o_counter } fragment SelectStudioData on Studio { diff --git a/ui/v2.5/src/components/Studios/StudioCard.tsx b/ui/v2.5/src/components/Studios/StudioCard.tsx index 5cd1cc209..01b2b5c5a 100644 --- a/ui/v2.5/src/components/Studios/StudioCard.tsx +++ b/ui/v2.5/src/components/Studios/StudioCard.tsx @@ -13,6 +13,7 @@ import { RatingBanner } from "../Shared/RatingBanner"; import { FavoriteIcon } from "../Shared/FavoriteIcon"; import { useStudioUpdate } from "src/core/StashService"; import { faTag } from "@fortawesome/free-solid-svg-icons"; +import { OCounterButton } from "../Shared/CountButton"; interface IProps { studio: GQL.StudioDataFragment; @@ -175,6 +176,12 @@ export const StudioCard: React.FC = ({ ); } + function maybeRenderOCounter() { + if (!studio.o_counter) return; + + return ; + } + function maybeRenderPopoverButtonGroup() { if ( studio.scene_count || @@ -182,6 +189,7 @@ export const StudioCard: React.FC = ({ studio.gallery_count || studio.group_count || studio.performer_count || + studio.o_counter || studio.tags.length > 0 ) { return ( @@ -194,6 +202,7 @@ export const StudioCard: React.FC = ({ {maybeRenderGalleriesPopoverButton()} {maybeRenderPerformersPopoverButton()} {maybeRenderTagPopoverButton()} + {maybeRenderOCounter()} ); diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index c26ed0c73..2edc53fe1 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -48,6 +48,7 @@ import { ExternalLinkButtons } from "src/components/Shared/ExternalLinksButton"; import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; import { goBackOrReplace } from "src/utils/history"; +import { OCounterButton } from "src/components/Shared/CountButton"; interface IProps { studio: GQL.StudioDataFragment; @@ -471,12 +472,17 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { - setRating(value)} - clickToRate - withoutContext - /> +
+ setRating(value)} + clickToRate + withoutContext + /> + {!!studio.o_counter && ( + + )} +
{!isEditing && ( Date: Tue, 25 Nov 2025 10:11:39 +1100 Subject: [PATCH 150/157] Ignore empty studio alias in ScrapedStudio (#6313) --- pkg/models/model_scraped_item.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 131f08be1..417564db5 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -62,7 +62,7 @@ func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *Stu ret.Details = *s.Details } - if s.Aliases != nil && !excluded["aliases"] { + if s.Aliases != nil && *s.Aliases != "" && !excluded["aliases"] { ret.Aliases = NewRelatedStrings(stringslice.FromString(*s.Aliases, ",")) } From 50ad3c0778a5027340c7f9049f47b6ad851bca93 Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Mon, 24 Nov 2025 22:41:01 -0500 Subject: [PATCH 151/157] [MediaSession] fall back to performers if studio not available (#6315) --- ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx | 4 +++- ui/v2.5/src/components/ScenePlayer/media-session.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index c566eb1b3..77c0d2b19 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -882,11 +882,13 @@ export const ScenePlayer: React.FC = PatchComponent( if (!player) return; // set up mediasession plugin + // get performer names as array + const performers = scene?.performers.map((p) => p.name).join(", "); player .mediaSession() .setMetadata( scene?.title ?? "Stash", - scene?.studio?.name ?? "Stash", + scene?.studio?.name ?? performers ?? "Stash", scene.paths.screenshot || "" ); }, [getPlayer, scene]); diff --git a/ui/v2.5/src/components/ScenePlayer/media-session.ts b/ui/v2.5/src/components/ScenePlayer/media-session.ts index 7be1d0d4e..b3ce2d0ea 100644 --- a/ui/v2.5/src/components/ScenePlayer/media-session.ts +++ b/ui/v2.5/src/components/ScenePlayer/media-session.ts @@ -20,11 +20,11 @@ class MediaSessionPlugin extends videojs.getPlugin("plugin") { } // manually set poster since it's only set on useEffect - public setMetadata(title: string, studioName: string, poster: string): void { + public setMetadata(title: string, artist: string, poster: string): void { if ("mediaSession" in navigator) { navigator.mediaSession.metadata = new MediaMetadata({ title, - artist: studioName, + artist, artwork: [ { src: poster || this.player.poster() || "", From d6a29533719459d05addd0f5b2dc04775edaa76b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 25 Nov 2025 17:36:13 +1100 Subject: [PATCH 152/157] Refactor filtered list toolbar (#6317) * Refactor list operation buttons into a single button group * Refactor ListFilter into FilteredListToolbar and restyle * Move zoom keybinds out of zoom control * Use button group for display mode select * Hide zoom slider on xs devices --- .../src/components/Galleries/GalleryList.tsx | 1 - ui/v2.5/src/components/Groups/GroupList.tsx | 1 - ui/v2.5/src/components/Images/ImageList.tsx | 1 - .../components/List/FilteredListToolbar.tsx | 69 ++++++---- ui/v2.5/src/components/List/ItemList.tsx | 13 +- ui/v2.5/src/components/List/ListFilter.tsx | 112 +--------------- .../components/List/ListOperationButtons.tsx | 126 +++++++++--------- .../src/components/List/ListViewOptions.tsx | 74 ++++++++-- ui/v2.5/src/components/List/ZoomSlider.tsx | 29 ++-- ui/v2.5/src/components/List/styles.scss | 15 ++- .../components/Performers/PerformerList.tsx | 1 - ui/v2.5/src/components/Scenes/SceneList.tsx | 5 + .../src/components/Scenes/SceneMarkerList.tsx | 1 - ui/v2.5/src/components/Studios/StudioList.tsx | 1 - ui/v2.5/src/components/Tags/TagList.tsx | 1 - 15 files changed, 208 insertions(+), 242 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index a0930b927..105557175 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -195,7 +195,6 @@ export const GalleryList: React.FC = ({ selectable > = ({ selectable={selectable} > = ({ selectable > { text: string; @@ -63,34 +65,47 @@ export const FilteredListToolbar: React.FC = ({ return ( + + - {showEditFilter && ( - showEditFilter()} - view={view} - /> - )} - 0} - onEdit={onEdit} - onDelete={onDelete} + - - - + showEditFilter()} count={filter.count()} /> - + + setFilter(filter.setSortBy(e ?? undefined))} + onChangeSortDirection={() => setFilter(filter.toggleSortDirection())} + onReshuffleRandomSort={() => setFilter(filter.reshuffleRandomSort())} + /> + + setFilter(filter.setPageSize(size))} + /> + + 0} + onEdit={onEdit} + onDelete={onDelete} + /> + + ); }; diff --git a/ui/v2.5/src/components/List/ItemList.tsx b/ui/v2.5/src/components/List/ItemList.tsx index f1d811ff2..b68077b55 100644 --- a/ui/v2.5/src/components/List/ItemList.tsx +++ b/ui/v2.5/src/components/List/ItemList.tsx @@ -43,6 +43,8 @@ import { } from "./FilteredListToolbar"; import { PagedList } from "./PagedList"; import { useConfigurationContext } from "src/hooks/Config"; +import { useZoomKeybinds } from "./ZoomSlider"; +import { DisplayMode } from "src/models/list-filter/types"; interface IFilteredItemList { filterStateProps: IFilterStateHook; @@ -112,7 +114,6 @@ export function useFilteredItemList< interface IItemListProps { view?: View; - zoomable?: boolean; otherOperations?: IItemListOperation[]; renderContent: ( result: T, @@ -144,7 +145,6 @@ export const ItemList = ( ) => { const { view, - zoomable, otherOperations, renderContent, renderEditDialog, @@ -216,6 +216,15 @@ export const ItemList = ( showEditFilter, }); + const zoomable = + filter.displayMode === DisplayMode.Grid || + filter.displayMode === DisplayMode.Wall; + + useZoomKeybinds({ + zoomIndex: zoomable ? filter.zoomIndex : undefined, + onChangeZoom: (zoom) => updateFilter(filter.setZoom(zoom)), + }); + useEffect(() => { if (addKeybinds) { const unbindExtras = addKeybinds(result, effectiveFilter, selectedIds); diff --git a/ui/v2.5/src/components/List/ListFilter.tsx b/ui/v2.5/src/components/List/ListFilter.tsx index b40951081..ff3be0360 100644 --- a/ui/v2.5/src/components/List/ListFilter.tsx +++ b/ui/v2.5/src/components/List/ListFilter.tsx @@ -1,4 +1,3 @@ -import cloneDeep from "lodash-es/cloneDeep"; import React, { useCallback, useEffect, @@ -23,17 +22,14 @@ import { import { Icon } from "../Shared/Icon"; import { ListFilterModel } from "src/models/list-filter/filter"; import useFocus from "src/utils/focus"; -import { FormattedMessage, useIntl } from "react-intl"; -import { SavedFilterDropdown } from "./SavedFilterList"; +import { useIntl } from "react-intl"; import { faCaretDown, faCaretUp, faCheck, faRandom, } from "@fortawesome/free-solid-svg-icons"; -import { FilterButton } from "./Filters/FilterButton"; import { useDebounce } from "src/hooks/debounce"; -import { View } from "./views"; import { ClearableInput } from "../Shared/ClearableInput"; import { useStopWheelScroll } from "src/utils/form"; import { ISortByOption } from "src/models/list-filter/filter-options"; @@ -330,109 +326,3 @@ export const SortBySelect: React.FC<{ ); }; - -interface IListFilterProps { - onFilterUpdate: (newFilter: ListFilterModel) => void; - filter: ListFilterModel; - view?: View; - openFilterDialog: () => void; -} - -export const ListFilter: React.FC = ({ - onFilterUpdate, - filter, - openFilterDialog, - view, -}) => { - const filterOptions = filter.options; - - useEffect(() => { - Mousetrap.bind("r", () => onReshuffleRandomSort()); - - return () => { - Mousetrap.unbind("r"); - }; - }); - - function onChangePageSize(pp: number) { - const newFilter = cloneDeep(filter); - newFilter.itemsPerPage = pp; - newFilter.currentPage = 1; - onFilterUpdate(newFilter); - } - - function onChangeSortDirection() { - const newFilter = cloneDeep(filter); - if (filter.sortDirection === SortDirectionEnum.Asc) { - newFilter.sortDirection = SortDirectionEnum.Desc; - } else { - newFilter.sortDirection = SortDirectionEnum.Asc; - } - - onFilterUpdate(newFilter); - } - - function onChangeSortBy(eventKey: string | null) { - const newFilter = cloneDeep(filter); - newFilter.sortBy = eventKey ?? undefined; - newFilter.currentPage = 1; - onFilterUpdate(newFilter); - } - - function onReshuffleRandomSort() { - const newFilter = cloneDeep(filter); - newFilter.currentPage = 1; - newFilter.randomSeed = -1; - onFilterUpdate(newFilter); - } - - function render() { - return ( - <> -
- -
- - - { - onFilterUpdate(f); - }} - view={view} - /> - - - - } - > - openFilterDialog()} - count={filter.count()} - /> - - - - - - - - ); - } - - return render(); -}; diff --git a/ui/v2.5/src/components/List/ListOperationButtons.tsx b/ui/v2.5/src/components/List/ListOperationButtons.tsx index bdb87fa3f..2d8e83039 100644 --- a/ui/v2.5/src/components/List/ListOperationButtons.tsx +++ b/ui/v2.5/src/components/List/ListOperationButtons.tsx @@ -1,11 +1,5 @@ -import React, { PropsWithChildren, useEffect } from "react"; -import { - Button, - ButtonGroup, - Dropdown, - OverlayTrigger, - Tooltip, -} from "react-bootstrap"; +import React, { PropsWithChildren, useEffect, useMemo } from "react"; +import { Button, ButtonGroup, Dropdown } from "react-bootstrap"; import Mousetrap from "mousetrap"; import { FormattedMessage, useIntl } from "react-intl"; import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; @@ -108,8 +102,8 @@ export const ListOperationButtons: React.FC = ({ }; }); - function maybeRenderButtons() { - const buttons = (otherOperations ?? []).filter((o) => { + const buttons = useMemo(() => { + const ret = (otherOperations ?? []).filter((o) => { if (!o.icon) { return false; } @@ -120,16 +114,17 @@ export const ListOperationButtons: React.FC = ({ return o.isDisplayed(); }); + if (itemsSelected) { if (onEdit) { - buttons.push({ + ret.push({ icon: faPencilAlt, text: intl.formatMessage({ id: "actions.edit" }), onClick: onEdit, }); } if (onDelete) { - buttons.push({ + ret.push({ icon: faTrash, text: intl.formatMessage({ id: "actions.delete" }), onClick: onDelete, @@ -138,58 +133,57 @@ export const ListOperationButtons: React.FC = ({ } } - if (buttons.length > 0) { - return ( - - {buttons.map((button) => { - return ( - {button.text}} - key={button.text} - > - - - ); - })} - - ); - } - } + return ret; + }, [otherOperations, itemsSelected, onEdit, onDelete, intl]); - function renderSelectAll() { - if (onSelectAll) { - return ( - onSelectAll?.()} - > - - - ); - } - } + const operationButtons = useMemo(() => { + return ( + <> + {buttons.map((button) => { + return ( + + ); + })} + + ); + }, [buttons]); - function renderSelectNone() { - if (onSelectNone) { - return ( - onSelectNone?.()} - > - - - ); + const moreDropdown = useMemo(() => { + function renderSelectAll() { + if (onSelectAll) { + return ( + onSelectAll?.()} + > + + + ); + } + } + + function renderSelectNone() { + if (onSelectNone) { + return ( + onSelectNone?.()} + > + + + ); + } } - } - function renderMore() { const options = [renderSelectAll(), renderSelectNone()].filter((o) => o); if (otherOperations) { @@ -224,13 +218,19 @@ export const ListOperationButtons: React.FC = ({ {options.length > 0 ? options : undefined} ); + }, [otherOperations, onSelectAll, onSelectNone]); + + // don't render anything if there are no buttons or operations + if (buttons.length === 0 && !moreDropdown) { + return null; } return ( <> - {maybeRenderButtons()} - - {renderMore()} + + {operationButtons} + {moreDropdown} + ); }; diff --git a/ui/v2.5/src/components/List/ListViewOptions.tsx b/ui/v2.5/src/components/List/ListViewOptions.tsx index 04adcaa74..b681e086d 100644 --- a/ui/v2.5/src/components/List/ListViewOptions.tsx +++ b/ui/v2.5/src/components/List/ListViewOptions.tsx @@ -1,8 +1,16 @@ import React, { useEffect, useRef, useState } from "react"; import Mousetrap from "mousetrap"; -import { Button, Dropdown, Overlay, Popover } from "react-bootstrap"; +import { + Button, + ButtonGroup, + Dropdown, + Overlay, + OverlayTrigger, + Popover, + Tooltip, +} from "react-bootstrap"; import { DisplayMode } from "src/models/list-filter/types"; -import { useIntl } from "react-intl"; +import { IntlShape, useIntl } from "react-intl"; import { Icon } from "../Shared/Icon"; import { faChevronDown, @@ -53,6 +61,10 @@ function getLabelId(option: DisplayMode) { return `display_mode.${displayModeId}`; } +function getLabel(intl: IntlShape, option: DisplayMode) { + return intl.formatMessage({ id: getLabelId(option) }); +} + export const ListViewOptions: React.FC = ({ zoomIndex, onSetZoom, @@ -60,9 +72,6 @@ export const ListViewOptions: React.FC = ({ onSetDisplayMode, displayModeOptions, }) => { - const minZoom = 0; - const maxZoom = 3; - const intl = useIntl(); const overlayTarget = useRef(null); @@ -98,10 +107,6 @@ export const ListViewOptions: React.FC = ({ }; }); - function getLabel(option: DisplayMode) { - return intl.formatMessage({ id: getLabelId(option) }); - } - function onChangeZoom(v: number) { if (onSetZoom) { onSetZoom(v); @@ -116,7 +121,7 @@ export const ListViewOptions: React.FC = ({ variant="secondary" title={intl.formatMessage( { id: "display_mode.label_current" }, - { current: getLabel(displayMode) } + { current: getLabel(intl, displayMode) } )} onClick={() => setShowOptions(!showOptions)} > @@ -140,8 +145,6 @@ export const ListViewOptions: React.FC = ({ displayMode === DisplayMode.Wall) ? (
@@ -156,7 +159,7 @@ export const ListViewOptions: React.FC = ({ onSetDisplayMode(option); }} > - {getLabel(option)} + {getLabel(intl, option)} ))}
@@ -167,3 +170,48 @@ export const ListViewOptions: React.FC = ({ ); }; + +export const ListViewButtonGroup: React.FC = ({ + zoomIndex, + onSetZoom, + displayMode, + onSetDisplayMode, + displayModeOptions, +}) => { + const intl = useIntl(); + + return ( + <> + {displayModeOptions.length > 1 && ( + + {displayModeOptions.map((option) => ( + + {getLabel(intl, option)} + + } + > + + + ))} + + )} +
+ {onSetZoom && + zoomIndex !== undefined && + (displayMode === DisplayMode.Grid || + displayMode === DisplayMode.Wall) ? ( + + ) : null} +
+ + ); +}; diff --git a/ui/v2.5/src/components/List/ZoomSlider.tsx b/ui/v2.5/src/components/List/ZoomSlider.tsx index dff8e4f57..093b5ec7a 100644 --- a/ui/v2.5/src/components/List/ZoomSlider.tsx +++ b/ui/v2.5/src/components/List/ZoomSlider.tsx @@ -2,19 +2,14 @@ import React, { useEffect } from "react"; import Mousetrap from "mousetrap"; import { Form } from "react-bootstrap"; -export interface IZoomSelectProps { - minZoom: number; - maxZoom: number; - zoomIndex: number; - onChangeZoom: (v: number) => void; -} +const minZoom = 0; +const maxZoom = 3; -export const ZoomSelect: React.FC = ({ - minZoom, - maxZoom, - zoomIndex, - onChangeZoom, -}) => { +export function useZoomKeybinds(props: { + zoomIndex: number | undefined; + onChangeZoom: (v: number) => void; +}) { + const { zoomIndex, onChangeZoom } = props; useEffect(() => { Mousetrap.bind("+", () => { if (zoomIndex !== undefined && zoomIndex < maxZoom) { @@ -32,7 +27,17 @@ export const ZoomSelect: React.FC = ({ Mousetrap.unbind("-"); }; }); +} +export interface IZoomSelectProps { + zoomIndex: number; + onChangeZoom: (v: number) => void; +} + +export const ZoomSelect: React.FC = ({ + zoomIndex, + onChangeZoom, +}) => { return ( = ({ selectable > { Mousetrap.unbind("d d"); }; }); + useZoomKeybinds({ + zoomIndex: filter.zoomIndex, + onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)), + }); const onCloseEditDelete = useCloseEditDelete({ closeModal, diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index dbe6e2e23..3ae595b7c 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -148,7 +148,6 @@ export const SceneMarkerList: React.FC = ({ selectable > = ({ selectable > = ({ filterHook, alterQuery }) => { > Date: Tue, 25 Nov 2025 17:28:12 +1100 Subject: [PATCH 153/157] Update changelog --- ui/v2.5/src/docs/en/Changelog/v0300.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/docs/en/Changelog/v0300.md b/ui/v2.5/src/docs/en/Changelog/v0300.md index aaf07c234..128be4cf7 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0300.md +++ b/ui/v2.5/src/docs/en/Changelog/v0300.md @@ -1,15 +1,23 @@ ### ✨ New Features +* Added SFW content mode option to settings and setup wizard. ([#6262](https://github.com/stashapp/stash/pull/6262)) * Added stash-ids to Tags. ([#6255](https://github.com/stashapp/stash/pull/6255)) +* Logs can now be compressed after reaching a configurable size. ([#5696](https://github.com/stashapp/stash/pull/5696)) +* Added ability to edit multiple studios at once. ([#6238](https://github.com/stashapp/stash/pull/6238)) +* Added ability to edit multiple scene markers at once. ([#6239](https://github.com/stashapp/stash/pull/6239)) * Added support for multiple Studio URLs. ([#6223](https://github.com/stashapp/stash/pull/6223)) * Added option to add markers to front page. ([#6065](https://github.com/stashapp/stash/pull/6065)) * Added duration filter to scene list sidebar. ([#6264](https://github.com/stashapp/stash/pull/6264)) * Added experimental support for JPEG XL images. ([#6184](https://github.com/stashapp/stash/pull/6184)) ### 🎨 Improvements +* Added performer age slider to scene filter sidebar. ([#6267](https://github.com/stashapp/stash/pull/6267)) +* Added markers option to scene filter sidebar. ([#6270](https://github.com/stashapp/stash/pull/6270)) * Selected stash-box is now remembered in the scene tagger view. ([#6192](https://github.com/stashapp/stash/pull/6192)) * Added hardware encoding support for Rockchip RKMPP devices. ([#6182](https://github.com/stashapp/stash/pull/6182)) +* stash now uses the Media Session API when playing scenes. ([#6298](https://github.com/stashapp/stash/pull/6298)) * Added `inputURL` and `inputHostname` fields to scraper specs. ([#6250](https://github.com/stashapp/stash/pull/6250)) * Added extra studio fields to scraper specs. ([#6249](https://github.com/stashapp/stash/pull/6249)) +* Added o-count to studio cards and details page. ([#5982](https://github.com/stashapp/stash/pull/5982)) * Added o-count to group cards. ([#6122](https://github.com/stashapp/stash/pull/6122)) * Added options to filter and sort groups by o-count. ([#6122](https://github.com/stashapp/stash/pull/6122)) * Added o-count to performer details page. ([#6171](https://github.com/stashapp/stash/pull/6171)) @@ -20,4 +28,8 @@ * Added keyboard shortcuts to generate scene screenshot at current time (`c c`) and to regenerate default screenshot (`c d`). ([#5984](https://github.com/stashapp/stash/pull/5984)) ### 🐛 Bug fixes -* stash-ids are now set when creating new objects from the scrape dialog. ([#6269](https://github.com/stashapp/stash/pull/6269)) \ No newline at end of file +* stash-ids are now set when creating new objects from the scrape dialog. ([#6269](https://github.com/stashapp/stash/pull/6269)) +* partial dates are now correctly handled when scraping scenes. ([#6305](https://github.com/stashapp/stash/pull/6305)) +* Fixed external player not loading on Android when a scene title has special characters. ([#6297](https://github.com/stashapp/stash/pull/6297)) +* Fixed Macos version check pointing to incorrect location. ([#6289](https://github.com/stashapp/stash/pull/6289)) +* stash will no longer try to generate marker previews where a marker start is set after the end of a scene's duration. ([#6290](https://github.com/stashapp/stash/pull/6290)) \ No newline at end of file From ca357b9eb364bc6cde6d3b8ec6c0bf0272fa0c69 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 25 Nov 2025 17:46:23 +1100 Subject: [PATCH 154/157] Codeberg weblate (#6318) * Translated using Weblate (Russian) Currently translated at 100.0% (1219 of 1219 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ru/ * Translated using Weblate (Korean) Currently translated at 100.0% (1222 of 1222 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/ * Translated using Weblate (Korean) Currently translated at 100.0% (1222 of 1222 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/ * Translated using Weblate (German) Currently translated at 100.0% (1222 of 1222 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/ * Translated using Weblate (French) Currently translated at 100.0% (1222 of 1222 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/ * Translated using Weblate (Japanese) Currently translated at 83.9% (1026 of 1222 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ja/ * Translated using Weblate (Korean) Currently translated at 100.0% (1222 of 1222 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/ * Translated using Weblate (Estonian) Currently translated at 100.0% (1222 of 1222 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1222 of 1222 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/ * Translated using Weblate (Spanish) Currently translated at 96.7% (1182 of 1222 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/ * Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (1222 of 1222 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hant/ * Translated using Weblate (French) Currently translated at 100.0% (1222 of 1222 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/ * Translated using Weblate (Korean) Currently translated at 100.0% (1222 of 1222 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/ * Translated using Weblate (Swedish) Currently translated at 100.0% (1222 of 1222 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/sv/ * Translated using Weblate (Ukrainian) Currently translated at 87.4% (1069 of 1222 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/ * Update translation files Updated by "Cleanup translation files" add-on in Weblate. Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ * Translated using Weblate (Estonian) Currently translated at 98.7% (1217 of 1233 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/ * Translated using Weblate (French) Currently translated at 100.0% (1233 of 1233 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/ * Translated using Weblate (Czech) Currently translated at 98.4% (1214 of 1233 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/cs/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 99.6% (1229 of 1233 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/ * Translated using Weblate (Czech) Currently translated at 100.0% (1233 of 1233 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/cs/ * Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 99.0% (1221 of 1233 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hant/ * Translated using Weblate (Spanish) Currently translated at 96.8% (1194 of 1233 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/ * Translated using Weblate (Japanese) Currently translated at 82.7% (1020 of 1233 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ja/ * Translated using Weblate (German) Currently translated at 99.3% (1225 of 1233 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/ * Translated using Weblate (Spanish) Currently translated at 97.2% (1199 of 1233 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/ * Translated using Weblate (Dutch) Currently translated at 79.1% (976 of 1233 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/nl/ * Translated using Weblate (Bulgarian) Currently translated at 25.1% (310 of 1233 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/bg/ * Translated using Weblate (Spanish) Currently translated at 100.0% (1233 of 1233 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/ * Translated using Weblate (Estonian) Currently translated at 100.0% (1233 of 1233 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/ --------- Co-authored-by: direnyx Co-authored-by: lugged9922 Co-authored-by: yec Co-authored-by: Marly21 Co-authored-by: doodoo Co-authored-by: tobakumap Co-authored-by: Zesty6249 Co-authored-by: wql219 Co-authored-by: donlothario Co-authored-by: danny60718 Co-authored-by: AlpacaSerious Co-authored-by: ves10023 Co-authored-by: Codeberg Translate Co-authored-by: NymeriaCZ Co-authored-by: 2307777 <2307777@noreply.codeberg.org> Co-authored-by: hirokazuk Co-authored-by: PhilipWaldman Co-authored-by: Gundir --- ui/v2.5/src/locales/bg-BG.json | 11 +-- ui/v2.5/src/locales/cs-CZ.json | 44 +++++++++--- ui/v2.5/src/locales/da-DK.json | 1 - ui/v2.5/src/locales/de-DE.json | 30 ++++++-- ui/v2.5/src/locales/es-ES.json | 127 +++++++++++++++++++++++++-------- ui/v2.5/src/locales/et-EE.json | 44 +++++++++--- ui/v2.5/src/locales/fi-FI.json | 1 - ui/v2.5/src/locales/fr-FR.json | 46 ++++++++---- ui/v2.5/src/locales/hu-HU.json | 1 - ui/v2.5/src/locales/id-ID.json | 1 - ui/v2.5/src/locales/it-IT.json | 1 - ui/v2.5/src/locales/ja-JP.json | 9 ++- ui/v2.5/src/locales/ko-KR.json | 62 +++++++++------- ui/v2.5/src/locales/nb-NO.json | 1 - ui/v2.5/src/locales/nl-NL.json | 84 ++++++++++++++++++---- ui/v2.5/src/locales/nn-NO.json | 1 - ui/v2.5/src/locales/pl-PL.json | 1 - ui/v2.5/src/locales/pt-BR.json | 1 - ui/v2.5/src/locales/ro-RO.json | 1 - ui/v2.5/src/locales/ru-RU.json | 8 ++- ui/v2.5/src/locales/sv-SE.json | 23 ++++-- ui/v2.5/src/locales/th-TH.json | 1 - ui/v2.5/src/locales/tr-TR.json | 1 - ui/v2.5/src/locales/uk-UA.json | 55 +++++++++++--- ui/v2.5/src/locales/zh-CN.json | 44 +++++++++--- ui/v2.5/src/locales/zh-TW.json | 31 +++++--- 26 files changed, 459 insertions(+), 171 deletions(-) diff --git a/ui/v2.5/src/locales/bg-BG.json b/ui/v2.5/src/locales/bg-BG.json index a7fb455a0..65f57dee2 100644 --- a/ui/v2.5/src/locales/bg-BG.json +++ b/ui/v2.5/src/locales/bg-BG.json @@ -52,7 +52,7 @@ "export": "Експортиране", "export_all": "Експортирай всичко…", "reshuffle": "Пренареди", - "assign_stashid_to_parent_studio": "Задай Stash ID към съществуващо родителско студио и опресни метаданните", + "assign_stashid_to_parent_studio": "Присвояване на Stash ID към съществуващо родителско студио и актуализиране на метаданните", "find": "Намери", "finish": "Приключи", "from_file": "От файл…", @@ -148,7 +148,7 @@ }, "temp_disable": "Спри временно…", "temp_enable": "Включи временно…", - "unset": "", + "unset": "Премахни", "use_default": "Използвай на стойностите по подразбиране", "view_history": "Виж история", "view_random": "Виж Случайно" @@ -305,14 +305,17 @@ "password_desc": "Парола за достъп до Stash. Остави празно за да изключи достъп чрез потребител", "stash-box_integration": "Stash-box интеграция", "username": "Потребителско име", - "username_desc": "Потребителско име за достъп до Stash. Остави празно за да изключи достъп чрез потребител" + "username_desc": "Потребителско име за достъп до Stash. Остави празно за да изключи достъп чрез потребител", + "log_file_max_size": "Максимален размер на файла", + "log_file_max_size_desc": "Максимален размер в мегабайти на лог файла преди компресиране. 0 MB означава деактивирано. Изисква рестартиране." }, "backup_directory_path": { "description": "Местоположение на папка за резрвни SQLite бази данни", "heading": "Път към Папка за Резервни Данни" }, "blobs_path": { - "description": "Къде във файловата система да се пазят бинарни данни. Позва се само ако се ползва Файлова система за блоб пазене. ВНИМАНИЕ: промяната ще изисква ръчно местене на съществуващи данни." + "description": "Къде във файловата система да се пазят бинарни данни. Позва се само ако се ползва Файлова система за блоб пазене. ВНИМАНИЕ: промяната ще изисква ръчно местене на съществуващи данни.", + "heading": "Път до файловата система за двоични данни" } }, "ui": { diff --git a/ui/v2.5/src/locales/cs-CZ.json b/ui/v2.5/src/locales/cs-CZ.json index 951c06e26..a518e1d4d 100644 --- a/ui/v2.5/src/locales/cs-CZ.json +++ b/ui/v2.5/src/locales/cs-CZ.json @@ -293,7 +293,9 @@ "password_desc": "Heslo pro přístup do aplikace. Ponechte prázdné pro vypnutí autentizace", "stash-box_integration": "Stash-box integrace", "username": "Přihlašovací jméno", - "username_desc": "Přihlašovací jméno pro přístup do aplikace. Ponechte prázdné pro vypnutí autentizace" + "username_desc": "Přihlašovací jméno pro přístup do aplikace. Ponechte prázdné pro vypnutí autentizace", + "log_file_max_size": "Maximální velikost logu", + "log_file_max_size_desc": "Maximální velikost logu v megabytech před kompresí. 0MB pro deaktivaci. Vyžaduje restart." }, "backup_directory_path": { "description": "Adresář umístění záloh databáze SQLite", @@ -800,6 +802,18 @@ "use_stash_hosted_funscript": { "description": "Je-li povoleno, budou funscripty poskytovány přímo ze Stash do vašeho zařízení Handy bez použití serveru Handy třetí strany. Vyžaduje, aby byl Stash dostupný z vašeho zařízení Handy a vygenerovaný API klíč, pokud má stash nakonfigurované údaje.", "heading": "Funscripty podávejte přímo" + }, + "sfw_mode": { + "description": "Zapněte zda použáváte stash k ukládání SFW obsahu. Schová nebo změní některé aspekty uživatelského rozhraní související s obsahem pro dospělé.", + "heading": "Režim obsahu SFW" + }, + "performer_list": { + "heading": "List účinkujicích", + "options": { + "show_links_on_grid_card": { + "heading": "Zobrazit odkazy na mřížce karet účinkujicích" + } + } } }, "advanced_mode": "Pokročilý mód" @@ -961,7 +975,8 @@ "delete_entity_simple_desc": "{count, plural, one {Opravdu chcete smazat tuto {singularEntity}?} other {Opravdu chcete smazat tyto {pluralEntity}?}}", "performers_found": "{count} nalezených účinkujících", "overwrite_filter_warning": "Uložený filtr \"{entityName}\" bude přepsán.", - "set_default_filter_confirm": "Chcete doopravdy nastavit tento filtr jako výchozí?" + "set_default_filter_confirm": "Chcete doopravdy nastavit tento filtr jako výchozí?", + "clear_o_history_confirm_sfw": "Opravdu chcete vymazat historii lajků?" }, "chapters": "Kapitoly", "circumcised": "Obřezán", @@ -1148,7 +1163,7 @@ "setup": { "paths": { "where_can_stash_store_its_generated_content_description": "Aby Stash mohl poskytovat miniatury, náhledy a sprity generuje Stash obrázky a videa. To zahrnuje také transkódování pro nepodporované formáty souborů. Ve výchozím nastavení Stash vytvoří generated adresář v adresáři obsahujícím váš konfigurační soubor. Pokud chcete změnit, kam se budou tato generovaná media ukládat, zadejte prosím absolutní nebo relativní (k aktuálnímu pracovnímu adresáři) cestu. Stash vytvoří tento adresář, pokud ještě neexistuje.", - "where_is_your_porn_located_description": "Přidejte adresáře obsahující vaše porno videa a obrázky. Stash použije tyto adresáře k vyhledání videí a obrázků během skenování.", + "where_is_your_porn_located_description": "Přidejte adresáře obsahující váše videa a obrázky. Stash použije tyto adresáře k vyhledání videí a obrázků během skenování.", "where_can_stash_store_blobs_description": "Kam může Stash ukládat binární data jako jsou obaly scén, účinkující, studia a obrázky tagů, buď v databázi nebo v souborovém systému. Ve výchozím nastavení bude tato data ukládat do souborového systému v podadresáři blobs v adresáři obsahujícím váš konfigurační soubor. Pokud to chcete změnit, zadejte prosím absolutní nebo relativní (k aktuálnímu pracovnímu adresáři) cestu. Stash vytvoří tento adresář, pokud ještě neexistuje.", "path_to_cache_directory_empty_for_default": "cesta k adresáři mezipaměti (ve výchozím nastavení prázdné)", "path_to_generated_directory_empty_for_default": "cesta k adresáři vygenerovaných souborů (ve výchozím nastavení prázdné)", @@ -1157,16 +1172,19 @@ "where_can_stash_store_its_generated_content": "Kam může Stash ukládat svůj vygenerovaný obsah?", "where_can_stash_store_its_database_warning": "VAROVÁNÍ: Uložení databáze na jiný systém, než ze kterého se spouští Stash (např. uložení databáze na NAS při spuštění serveru Stash na jiném počítači), nepodporováno! SQLite není určen pro použití v síti a pokus o to může velmi snadno způsobit poškození celé vaší databáze.", "database_filename_empty_for_default": "název souboru databáze (ve výchozím nastavení prázdné)", - "description": "Dále musíme určit, kde najdeme vaši sbírku porna a kam uložit databázi Stash, vygenerované soubory a soubory mezipaměti. Tato nastavení lze v případě potřeby později změnit.", + "description": "Dále musíme určit, kde najdeme vaši sbírku obsahu a kam uložit databázi Stash, vygenerované soubory a soubory mezipaměti. Tato nastavení lze v případě potřeby později změnit.", "path_to_blobs_directory_empty_for_default": "cesta k adresáři blobů (ve výchozím nastavení prázdné)", "stash_alert": "Nebyla vybrána žádná cesta knihovny. Žádné médium nebude možné naskenovat do Stash. Jste si jisti?", - "where_is_your_porn_located": "Kde se nachází vaše porno?", + "where_is_your_porn_located": "Kde se nachází váš obsah?", "where_can_stash_store_cache_files_description": "Aby některé funkce, jako je živé transkódování HLS/DASH, fungovaly, vyžaduje Stash adresář mezipaměti pro dočasné soubory. Ve výchozím nastavení Stash vytvoří adresář mezipaměť v adresáři obsahujícím váš konfigurační soubor. Pokud to chcete změnit, zadejte prosím absolutní nebo relativní (k aktuálnímu pracovnímu adresáři) cestu. Stash vytvoří tento adresář, pokud ještě neexistuje.", "where_can_stash_store_its_database": "Kde může Stash uložit svou databázi?", "where_can_stash_store_blobs": "Kde může Stash ukládat binární data databáze?", "where_can_stash_store_blobs_description_addendum": "Případně můžete tato data uložit do databáze. Poznámka: Tím se zvětší velikost souboru databáze a prodlouží se doba migrace databáze.", - "where_can_stash_store_its_database_description": "Stash používá databázi SQLite k ukládání metadat porna. Ve výchozím nastavení bude tento soubor vytvořen jako stash-go.sqlite v adresáři obsahujícím váš konfigurační soubor. Pokud to chcete změnit, zadejte prosím absolutní nebo relativní (k aktuálnímu pracovnímu adresáři) název souboru.", - "store_blobs_in_database": "Uložte bloby do databáze" + "where_can_stash_store_its_database_description": "Stash používá databázi SQLite k ukládání metadat obsahu. Ve výchozím nastavení bude tento soubor vytvořen jako stash-go.sqlite v adresáři obsahujícím váš konfigurační soubor. Pokud to chcete změnit, zadejte prosím absolutní nebo relativní (k aktuálnímu pracovnímu adresáři) název souboru.", + "store_blobs_in_database": "Uložte bloby do databáze", + "sfw_content_settings": "Používáte stash k SFW obsahu?", + "sfw_content_settings_description": "Stash lze použít ke správě SFW obsahu, jako jsou fotografie, umění, komiksy a další. Povolení této možnosti upraví některé vlastnosti uživatelského rozhraní tak, aby byly vhodnější pro SFW obsah.", + "use_sfw_content_mode": "Použít režim obsahu SFW" }, "stash_setup_wizard": "Průvodce nastavením Stash", "success": { @@ -1277,7 +1295,7 @@ "syncing": "Probíhá synchronizace se serverem", "uploading": "Nahrávání skriptu" }, - "hasMarkers": "Má značky", + "hasMarkers": "Značky", "height_cm": "Výška (cm)", "include_sub_studios": "Zahrnout dceřiná studia", "interactive": "Interaktivní", @@ -1310,7 +1328,6 @@ "o_count": "Počet O" }, "megabits_per_second": "{value} mbps", - "o_counter": "O-Počítadlo", "none": "Žádný", "pagination": { "last": "Poslední", @@ -1354,7 +1371,7 @@ }, "hair_color": "Barva vlasů", "help": "Pomoc", - "hasChapters": "Má kapitoly", + "hasChapters": "Kapitoly", "height": "Výška", "ignore_auto_tag": "Ignoruj automatické tagování", "instagram": "Instragram", @@ -1547,5 +1564,10 @@ "login": "Přihlášení", "username": "Uživatelské jméno", "internal_error": "Neočekávaná interní chyba. Podívej se do logu pro více detailů" - } + }, + "last_o_at_sfw": "Poslední lajk", + "o_count_sfw": "Lajky", + "o_history_sfw": "Historie lajků", + "odate_recorded_no_sfw": "Žádný datum lajku nebyl zaznamenán", + "scenes_duration": "Trvání scény" } diff --git a/ui/v2.5/src/locales/da-DK.json b/ui/v2.5/src/locales/da-DK.json index bdb60fe67..54cc89938 100644 --- a/ui/v2.5/src/locales/da-DK.json +++ b/ui/v2.5/src/locales/da-DK.json @@ -1032,7 +1032,6 @@ "name": "Navn", "new": "Ny", "none": "Ingen", - "o_counter": "O-tæller", "operations": "Operationer", "organized": "Organiseret", "pagination": { diff --git a/ui/v2.5/src/locales/de-DE.json b/ui/v2.5/src/locales/de-DE.json index 045cd46e3..3354d4085 100644 --- a/ui/v2.5/src/locales/de-DE.json +++ b/ui/v2.5/src/locales/de-DE.json @@ -304,7 +304,9 @@ "password_desc": "Passwort für den Zugriff auf Stash. Feld leer lassen, um Benutzerauthentifizierung zu deaktivieren", "stash-box_integration": "Stash-box Einbindung", "username": "Benutzername", - "username_desc": "Benutzername für den Zugriff auf Stash. Feld leer lassen, um Benutzerauthentifizierung zu deaktivieren" + "username_desc": "Benutzername für den Zugriff auf Stash. Feld leer lassen, um Benutzerauthentifizierung zu deaktivieren", + "log_file_max_size": "Maximale Log Größe", + "log_file_max_size_desc": "Maximale Größe, in Megabytes, von den Log Files bevor es komprimiert wird. 0MB ist deaktiviert. Benötigt Neustart." }, "backup_directory_path": { "description": "Verzeichnisspeicherort für SQLite-Datenbankdateisicherungen", @@ -811,6 +813,18 @@ "use_stash_hosted_funscript": { "description": "Wenn aktiviert, werden Funscripts direkt von Stash an dein Handy-Gerät gesendet, ohne Handy-Server von Drittanbietern zu verwenden. Erfordert, dass Stash von deinem Handy-Gerät aus zugänglich ist und ein API-Schlüssel generiert wurde, falls Stash mit Zugangsdaten konfiguriert ist.", "heading": "Funscripts direkt bereitstellen" + }, + "performer_list": { + "options": { + "show_links_on_grid_card": { + "heading": "Zeige den Link auf der Darsteller Gitterkarte" + } + }, + "heading": "Darsteller Liste" + }, + "sfw_mode": { + "description": "Aktivieren wenn man Stash für das speichern von SFW content benutzt. Versteckt oder Ändernt ein paar NFSW eigenschaften des UI.", + "heading": "SFW Content Modus" } }, "advanced_mode": "Fortgeschrittener Modus" @@ -974,7 +988,8 @@ "performers_found": "{count} Darsteller:innen gefunden", "clear_o_history_confirm": "Möchten Sie wirklich den O-Verlauf löschen?", "overwrite_filter_warning": "Der gespeicherte Filter \"{entityName}\" wird überschrieben.", - "set_default_filter_confirm": "Sind Sie sicher, dass Sie diesen Filter als Standard festlegen möchten?" + "set_default_filter_confirm": "Sind Sie sicher, dass Sie diesen Filter als Standard festlegen möchten?", + "clear_o_history_confirm_sfw": "Bist du dir sicher das du den Verlauf löschen willst?" }, "dimensions": "Maße", "director": "Regisseur", @@ -1146,7 +1161,6 @@ "name": "Name", "new": "Neu", "none": "Keiner", - "o_counter": "O-Zähler", "operations": "Operationen", "organized": "Organisiert", "pagination": { @@ -1302,7 +1316,8 @@ "where_is_your_porn_located": "Wo finden wir deine Porno-Kollektion?", "where_is_your_porn_located_description": "Füge Ordner hinzu in denen sich deine Porno-Videos und -Bilder befinden. Stash wird diese Ordner nutzen, um Videos und Bilder in das System einzupflegen.", "path_to_blobs_directory_empty_for_default": "Pfad zum Verzeichnis der blobs (standardmäßig leer)", - "store_blobs_in_database": "blobs in der Datenbank speichern" + "store_blobs_in_database": "blobs in der Datenbank speichern", + "sfw_content_settings": "Benutzt du stash auch für SFW Inhalte?" }, "stash_setup_wizard": "Einrichtungshelfer für Stash", "success": { @@ -1547,5 +1562,10 @@ "username": "Benutzername" }, "age_on_date": "bei Produktion", - "sort_name": "Namen sortieren" + "sort_name": "Namen sortieren", + "scenes_duration": "Szenen Dauer", + "last_o_at_sfw": "Letztes mal ein Gefällt mir gegeben am", + "o_count_sfw": "Gefällt mir", + "o_history_sfw": "Gefällt mir Verlauf", + "odate_recorded_no_sfw": "Kein Gefällt mir Datum vermerkt" } diff --git a/ui/v2.5/src/locales/es-ES.json b/ui/v2.5/src/locales/es-ES.json index 3fd9ef089..ce3a38b0a 100644 --- a/ui/v2.5/src/locales/es-ES.json +++ b/ui/v2.5/src/locales/es-ES.json @@ -141,8 +141,17 @@ "reset_play_duration": "Reiniciar la duración de la reproducción", "load": "Cargar", "load_filter": "Cargar el filtro", - "play": "Jugar", - "reset_resume_time": "Restablecer el tiempo de reanudación" + "play": "Reproducir", + "reset_resume_time": "Restablecer el tiempo de reanudación", + "reset_cover": "Restaurar portada por defecto", + "set_cover": "Establecer como portada", + "show_results": "Mostrar resultados", + "show_count_results": "Mostrar {count} resultados", + "sidebar": { + "close": "Cerrar barra lateral", + "open": "Abrir barra lateral", + "toggle": "Alternar barra lateral" + } }, "actions_name": "Acciones", "age": "Edad", @@ -186,7 +195,10 @@ "show_male_label": "Mostrar actores", "source": "Fuente", "mark_organized_desc": "Marcar la escena como organizada tras pulsar el botón de Guardar.", - "mark_organized_label": "Marcar como organizado al guardar" + "mark_organized_label": "Marcar como organizado al guardar", + "errors": { + "blacklist_duplicate": "Elemento duplicado en la lista negra" + } }, "noun_query": "Consulta", "results": { @@ -286,7 +298,9 @@ "password_desc": "Contraseña para acceder a Stash. Dejar en blanco para deshabilitar la exigencia de identificación para acceder a la aplicación", "stash-box_integration": "Integración Stash-box", "username": "Usuario", - "username_desc": "Usuario para acceder a Stash. Dejar en blanco para deshabilitar la exigencia de identificación para acceder a la aplicación" + "username_desc": "Usuario para acceder a Stash. Dejar en blanco para deshabilitar la exigencia de identificación para acceder a la aplicación", + "log_file_max_size": "Tamaño máximo del registro", + "log_file_max_size_desc": "Tamaño máximo en megabytes del archivo de registro antes de comprimirlo. 0 MB está desactivado. Requiere reiniciar." }, "backup_directory_path": { "description": "Ubicación del directorio para copias de seguridad de archivos de bases de datos SQLite", @@ -438,7 +452,9 @@ "endpoint": "Terminal de red", "graphql_endpoint": "Terminal de red GraphQL", "name": "Nombre", - "title": "Terminal de red Stash-box" + "title": "Terminal de red Stash-box", + "max_requests_per_minute": "Máximas peticiones por minuto", + "max_requests_per_minute_description": "Utiliza el valor predeterminado {defaultValue} si se establece en 0" }, "system": { "transcoding": "Transcodificación" @@ -490,7 +506,7 @@ "heading": "Identificar", "identifying_from_paths": "Identificación de las escenas que se encuentren en las siguientes rutas", "identifying_scenes": "Identificando {num} {scene}", - "include_male_performers": "Incluir actores (varones)", + "include_male_performers": "Incluir actores varones", "set_cover_images": "Selección automática de carátula de escena", "set_organized": "Marcar escena como \"clasificada\"", "source": "Fuente", @@ -564,7 +580,9 @@ "whitespace_chars": "Espacios en blanco", "whitespace_chars_desc": "Estos caracteres se reemplazarán en el título por espacios en blanco" }, - "scene_tools": "Herramientas de escenas" + "scene_tools": "Herramientas de escenas", + "graphql_playground": "Entorno de pruebas de GraphQL", + "heading": "Herramientas" }, "ui": { "abbreviate_counters": { @@ -729,7 +747,8 @@ "heading": "Etiqueta de VR" }, "enable_chromecast": "Habilitar Chromecast", - "show_ab_loop_controls": "Mostrar controles del plugin de bucle AB" + "show_ab_loop_controls": "Mostrar controles del plugin de bucle AB", + "show_range_markers": "Mostrar marcadores de rango" } }, "scene_wall": { @@ -788,6 +807,18 @@ "heading": "Mostrar contenido de subetiquetas" } } + }, + "sfw_mode": { + "heading": "Modo de contenido SFW", + "description": "Actívelo si utiliza Stash para almacenar contenido SFW. Oculta o modifica algunos aspectos de la interfaz de usuario relacionados con contenido para adultos." + }, + "performer_list": { + "heading": "Lista de actores", + "options": { + "show_links_on_grid_card": { + "heading": "Mostrar enlaces en las tarjetas de la cuadrícula de actores" + } + } } }, "advanced_mode": "Modo avanzado" @@ -887,7 +918,7 @@ "marker_image_previews": "Vistas previas de marcadores en formato de imagen animada", "marker_image_previews_tooltip": "Generar también vistas previas animadas (webp), solo requeridas cuando el tipo de vista previa de la pared de escenas/marcadores está configurado como Imagen Animada. Al navegar, consumen menos CPU que las vistas previas de video, pero se generan además de ellas y son archivos más grandes.", "marker_screenshots": "Capturas de pantalla de marcadores", - "marker_screenshots_tooltip": "Imágenes estáticas JPG de marcadores, solo requeridas si el tipo de vista previa seleccionada por defecto es \"Imagen estática\".", + "marker_screenshots_tooltip": "Imágenes estáticas JPG de marcadores", "markers": "Vistas previas de marcadores", "markers_tooltip": "Vídeos de 20 segundos de duración que comienzan a partir del tiempo seleccionado.", "override_preview_generation_options": "Sobrescribir opciones para la generación de vistas previas", @@ -946,7 +977,10 @@ "destination": "Reasignar a" }, "delete_entity_simple_desc": "{count, plural, one {¿Estás seguro de que quieres eliminar esta {singularEntity}?} other {¿Estás seguro de que quieres eliminar estas {pluralEntity}?}}", - "reassign_entity_title": "{count, plural, one {Reasignar {singularEntity}} other {Reasignar {pluralEntity}}}" + "reassign_entity_title": "{count, plural, one {Reasignar {singularEntity}} other {Reasignar {pluralEntity}}}", + "clear_o_history_confirm_sfw": "¿Estás seguro de que quieres borrar el historial de «Me gusta»?", + "overwrite_filter_warning": "El filtro guardado \"{entityName}\" se sobrescribirá.", + "set_default_filter_confirm": "¿Estás seguro de que deseas establecer este filtro como predeterminado?" }, "dimensions": "Dimensiones", "director": "Director", @@ -955,7 +989,8 @@ "list": "Lista", "tagger": "Etiquetadora", "unknown": "Desconocido/a", - "wall": "Muro" + "wall": "Muro", + "label_current": "Modo de visualización: {current}" }, "donate": "Donar", "dupe_check": { @@ -1043,7 +1078,7 @@ "uploading": "Subiendo script", "error": "Error al conectar con Handy" }, - "hasMarkers": "Tiene marcadores", + "hasMarkers": "Marcadores", "height": "Estatura", "help": "Ayuda", "ignore_auto_tag": "Ignorar Etiquetado Automático", @@ -1073,21 +1108,20 @@ "interactive_speed": "Velocidad interactiva", "performer_card": { "age": "{age} {years_old}", - "age_context": "{age} {years_old} en esta escena" + "age_context": "{age} {years_old} durante la producción" }, "phash": "Función de hash perceptual", "stream": "Transmisión", "video_codec": "Códec de vídeo", "play_count": "Contador de reproducciones", "play_duration": "Tiempo de reproducción", - "o_count": "Contador de pajas" + "o_count": "Contador de orgasmos" }, "megabits_per_second": "{value} megabits por segundo (mbps)", "metadata": "Metadatos", "name": "Nombre", "new": "Añadir", "none": "Ninguno/a", - "o_counter": "Contador “P”", "operations": "Acciones", "organized": "Clasificadas", "pagination": { @@ -1162,7 +1196,9 @@ "name": "Filtro", "saved_filters": "Filtros guardados", "update_filter": "Actualizar filtro", - "edit_filter": "Editar filtro" + "edit_filter": "Editar filtro", + "search_term": "Término de búsqueda", + "more_filter_criteria": "+{count} más" }, "seconds": "Segundos", "settings": "Preferencias", @@ -1184,7 +1220,9 @@ "errors": { "something_went_wrong": "¡OH NO! ¡Algo ha ido mal!", "something_went_wrong_description": "Si sospechas que puede haber un error con los datos aportados, por favor, haz clic en volver para arreglarlos. De lo contrario, abre una incidencia en {githubLink} o busca ayuda en {discordLink}.", - "something_went_wrong_while_setting_up_your_system": "Algo ha salido mal mientras configurábamos tu entorno. Éste es el mensaje de error recibido: {error}" + "something_went_wrong_while_setting_up_your_system": "Algo ha salido mal mientras configurábamos tu entorno. Éste es el mensaje de error recibido: {error}", + "unable_to_retrieve_system_status": "No se puede recuperar el estado del sistema: {error}", + "unexpected_error": "Se ha producido un error inesperado: {error}" }, "folder": { "file_path": "Ruta relativa del fichero", @@ -1206,7 +1244,7 @@ }, "paths": { "database_filename_empty_for_default": "nombre para el archivo de la base de datos (en blanco para usar opción por defecto)", - "description": "A continuación necesitamos saber dónde se encuentra tu colección de porno, y dónde guardar la base de datos de Stash y los ficheros multimedia de soporte generados. Estos ajustes se pueden modificar posteriormente.", + "description": "A continuación necesitamos saber dónde se encuentra tu contenido, y dónde guardar la base de datos de Stash y los ficheros multimedia de soporte generados. Estos ajustes se pueden modificar posteriormente.", "path_to_generated_directory_empty_for_default": "ruta al directorio de ficheros multimedia generados (dejar en blanco para usar opción por defecto)", "set_up_your_paths": "Selecciona tus rutas", "stash_alert": "No se han seleccionado rutas para tu biblioteca. Ningún fichero multimedia podrá ser seleccionado para su inclusión en Stash. ¿Estás seguro?", @@ -1214,8 +1252,8 @@ "where_can_stash_store_its_database_description": "Stash emplea una base de datos SQLite para almacenar los metadatos de tu colección. Por defecto será creada como stash-go.sqlite en el directorio en el que se encuentra tu archivo de configuración. Si quieres cambiar esto, por favor, introduce un nombre de archivo con ruta absoluta o relativa al directorio de trabajo actual.", "where_can_stash_store_its_generated_content": "¿Dónde guarda Stash los ficheros multimedia de soporte generados?", "where_can_stash_store_its_generated_content_description": "Para poder ofrecerte miniaturas, vistas previas y conjuntos de imágenes animadas, Stash genera imágenes y vídeos. Esto incluye también transcodificaciones para formatos de vídeo no soportados. Por defecto, Stash creará el directorio generated en el directorio que contiene tu archivo de configuración. Si quieres cambiar dónde se almacenarán estos archivos generados, por favor, introduce una ruta absoluta o relativa (al directorio de trabajo actual). Stash creará este directorio si no existe.", - "where_is_your_porn_located": "¿Dónde guardas el porno?", - "where_is_your_porn_located_description": "Añade los directorios que contienen tus imágenes y vídeos porno. Stash usará estos directorios para buscar imágenes y vídeos durante el escaneo.", + "where_is_your_porn_located": "¿Dónde guardas tu contenido?", + "where_is_your_porn_located_description": "Añade los directorios que contienen tus imágenes y vídeos. Stash usará estos directorios para buscar imágenes y vídeos durante el escaneo.", "path_to_cache_directory_empty_for_default": "ruta al directorio de la caché (dejar en blanco para usar el directorio por defecto)", "store_blobs_in_database": "Almacenar blobs en la base de datos", "path_to_blobs_directory_empty_for_default": "ruta al directorio con los blobs (dejar en blanco para el valor por defecto)", @@ -1224,7 +1262,10 @@ "where_can_stash_store_its_database_warning": "ADVERTENCIA: ¡almacenar la base de datos en un sistema diferente al que Stash se ejecuta (por ejemplo, almacenar la base de datos en un NAS mientras se ejecuta el servidor Stash en otro equipo) no es compatible! SQLite no está diseñado para su uso a través de una red e intentar hacerlo puede corromper fácilmente toda tu base de datos.", "where_can_stash_store_blobs": "¿Dónde puede Stash guardar los datos binarios de la base de datos?", "where_can_stash_store_blobs_description": "Stash puede almacenar datos binarios como portadas de escenas, imágenes de intérpretes, estudios y etiquetas ya sea en la base de datos o en el sistema de archivos. Por defecto, almacenará estos datos en el sistema de archivos en el subdirectorio blobs dentro del directorio que contiene tu archivo de configuración. Si deseas cambiar esto, por favor ingresa una ruta absoluta o relativa (respecto al directorio de trabajo actual). Stash creará este directorio si aún no existe.", - "where_can_stash_store_cache_files_description": "Para que algunas funciones como la transcodificación en vivo de HLS/DASH funcionen, Stash requiere un directorio de caché para archivos temporales. Por defecto, Stash creará un directorio cache dentro del directorio que contiene tu archivo de configuración. Si deseas cambiar esto, por favor ingresa una ruta absoluta o relativa (respecto al directorio de trabajo actual). Stash creará este directorio si aún no existe." + "where_can_stash_store_cache_files_description": "Para que algunas funciones como la transcodificación en vivo de HLS/DASH funcionen, Stash requiere un directorio de caché para archivos temporales. Por defecto, Stash creará un directorio cache dentro del directorio que contiene tu archivo de configuración. Si deseas cambiar esto, por favor ingresa una ruta absoluta o relativa (respecto al directorio de trabajo actual). Stash creará este directorio si aún no existe.", + "sfw_content_settings": "¿Usar Stash para contenido SFW?", + "sfw_content_settings_description": "Stash se puede utilizar para gestionar contenido SFW, como fotografía, arte, cómics y mucho más. Al habilitar esta opción, se ajustará el comportamiento de la interfaz de usuario para que sea más adecuado para el contenido SFW.", + "use_sfw_content_mode": "Utilizar el modo de contenido SFW" }, "stash_setup_wizard": "Asistente de configuración de Stash", "success": { @@ -1274,7 +1315,7 @@ "scenes_duration": "Duración de las escenas", "scenes_size": "Tamaño de las escenas", "total_play_count": "Contador total de reproducciones", - "total_o_count": "Total de pajas", + "total_o_count": "Total de orgasmos", "total_play_duration": "Tiempo total de reproducciones", "scenes_played": "Escenas reproducidas" }, @@ -1345,7 +1386,7 @@ } }, "file_count": "Conteo de archivos", - "hasChapters": "Tiene capítulos", + "hasChapters": "Capítulos", "index_of_total": "{index} de {total}", "last_played_at": "Última reproducción el", "package_manager": { @@ -1385,8 +1426,8 @@ "date_format": "YYYY-MM-DD", "datetime_format": "YYYY-MM-DD HH:MM", "disambiguation": "Disambiguación", - "o_count": "Contador de pajas", - "o_history": "Historial de pajas", + "o_count": "Contador de orgasmos", + "o_history": "Historial de orgasmos", "orientation": "Orientación", "penis_length_cm": "Longitud del pene (cm)", "play_count": "Contador de reproducciones", @@ -1437,7 +1478,7 @@ "saved_filter": "Filtro guardado" } }, - "last_o_at": "Última paja a las", + "last_o_at": "Último orgasmo a las", "parent_studio": "Estudio principal", "primary_file": "Archivo principal", "recently_added_objects": "{objects} añadidas recientemente", @@ -1468,14 +1509,15 @@ "blank": "${path} no puede estar vacío", "date_invalid_form": "${path} tiene que tener el formato YYYY-MM-DD", "required": "${path} es un campo requerido", - "unique": "${path} tiene que ser único" + "unique": "${path} tiene que ser único", + "end_time_before_start_time": "El tiempo de finalización debe ser mayor o igual que el tiempo de inicio" }, "subsidiary_studio_count": "Número de estudios secundarios", "tag_parent_tooltip": "Tiene etiquetas primarias", "tag_sub_tag_tooltip": "Tiene sub-etiquetas", "time": "Hora", "type": "Tipo", - "odate_recorded_no": "No hay registro de pajas grabado", + "odate_recorded_no": "No hay registro de orgasmos grabado", "urls": "URLs", "zip_file_count": "Números de archivos zip", "unknown_date": "Fecha desconocida", @@ -1490,7 +1532,9 @@ "custom_fields": { "field": "Campo", "value": "Valor", - "title": "Campos personalizados" + "title": "Campos personalizados", + "criteria_format_string": "{criterion} (custom field) {modifierString} {valueString}", + "criteria_format_string_others": "{criterion} (custom field) {modifierString} {valueString} (+{others} others)" }, "sub_group_count": "Recuento de subgrupos", "sub_group_of": "Subgrupo de {parent}", @@ -1500,9 +1544,30 @@ "any": "Cualquiera", "any_of": "Cualquiera de", "none": "Ninguno", - "only": "Solamente" + "only": "Solo" }, "include_sub_group_content": "Incluir contenido de subgrupos", "include_sub_groups": "Incluir subgrupos", - "eta": "Tiempo estimado" + "eta": "Tiempo estimado", + "containing_group": "Grupo contenedor", + "containing_group_count": "Contador del grupo contenedor", + "containing_groups": "Grupo de contenedores", + "login": { + "username": "Nombre de usuario", + "password": "Contraseña", + "invalid_credentials": "Nombre de usuario o contraseña incorrecto", + "login": "Iniciar sesión", + "internal_error": "Error interno inesperado. Consulte los registros para obtener más detalles" + }, + "age_on_date": "{age} durante la producción", + "include_sub_studio_content": "Incluir contenido de subestudios", + "include_sub_tag_content": "Incluir contenido de subetiquetas", + "last_o_at_sfw": "Último «Me gusta» en", + "sort_name": "Ordenar por nombre", + "o_count_sfw": "Me gusta", + "o_history_sfw": "Historial de Me gusta", + "odate_recorded_no_sfw": "Sin fecha de Me gusta registrada", + "scenes_duration": "Duración de la escena", + "sub_group_order": "Orden de subgrupo", + "time_end": "Hora de finalización" } diff --git a/ui/v2.5/src/locales/et-EE.json b/ui/v2.5/src/locales/et-EE.json index d835e54bc..ec377065e 100644 --- a/ui/v2.5/src/locales/et-EE.json +++ b/ui/v2.5/src/locales/et-EE.json @@ -299,7 +299,9 @@ "password_desc": "Parool Stashi pääsemiseks. Jäta tühjaks, kui soovid sisselogimise keelata", "stash-box_integration": "Stash-kasti integratsioon", "username": "Kasutajanimi", - "username_desc": "Kasutajanimi Stashi pääsemiseks. Jäta tühjaks, kui soovid sisselogimise keelata" + "username_desc": "Kasutajanimi Stashi pääsemiseks. Jäta tühjaks, kui soovid sisselogimise keelata", + "log_file_max_size": "Maksimaalne logi suurus", + "log_file_max_size_desc": "Maksimaalne logifaili suurus megabaitides enne tihendamist. 0MB on väljalülitatud. Nõuab taaskäivitust." }, "backup_directory_path": { "description": "Failitee SQLite andmebaasi varundusfailide jaoks", @@ -806,6 +808,18 @@ "heading": "Kompaktselt laiendatud detailid" }, "heading": "Detailide Leht" + }, + "performer_list": { + "heading": "Näitlejate nimekiri", + "options": { + "show_links_on_grid_card": { + "heading": "Kuva linke näitlejate ruudustiku kaartidel" + } + } + }, + "sfw_mode": { + "description": "Luba, kui kasutad stashi SFW sisu jaoks. Peidab või muudab kasutajaliidese mõningaid täiskasvanutele mõeldud sisuga seotud aspekte.", + "heading": "SFW Sisu Režiim" } }, "advanced_mode": "Täpsem Režiim" @@ -969,7 +983,8 @@ "clear_o_history_confirm": "Kas oled kindel, et soovid puhastada O ajaloo?", "clear_play_history_confirm": "Kas oled kindel, et soovid puhastada vaatamise ajaloo?", "set_default_filter_confirm": "Kas oled kindel, et soovid määrata seda filtrit vaikimisi valikuks?", - "overwrite_filter_warning": "Salvestatud filter \"{entityName}\" kirjutatakse üle." + "overwrite_filter_warning": "Salvestatud filter \"{entityName}\" kirjutatakse üle.", + "clear_o_history_confirm_sfw": "Kas oled kindel, et tahad meeldimiste ajalugu tühjendada?" }, "dimensions": "Dimensioonid", "director": "Režissöör", @@ -1093,8 +1108,8 @@ "syncing": "Serveriga sünkroniseerimine", "uploading": "Skripti üleslaadimine" }, - "hasChapters": "Sisaldab Episoode", - "hasMarkers": "On Markereid", + "hasChapters": "Peatükid", + "hasMarkers": "Markerid", "height": "Pikkus", "height_cm": "Pikkus (cm)", "help": "Abi", @@ -1141,7 +1156,6 @@ "name": "Nimi", "new": "Uus", "none": "Puudub", - "o_counter": "O-Loendur", "operations": "Operatsioonid", "organized": "Organiseeritud", "pagination": { @@ -1276,7 +1290,7 @@ }, "paths": { "database_filename_empty_for_default": "andmebaasi failinimi (vaikimisi tühi)", - "description": "Järgmisena peame kindlaks määrama, kust leida su pornokogu ja kuhu salvestada stashi andmebaas, genereeritud failid ja cache. Neid sätteid saab hiljem vajadusel muuta.", + "description": "Järgmisena peame kindlaks määrama, kust leida su sisu ja kuhu salvestada stashi andmebaas, genereeritud failid ja cache. Neid sätteid saab hiljem vajadusel muuta.", "path_to_cache_directory_empty_for_default": "tee cache kaustani (tühi vaikeseadeks)", "path_to_generated_directory_empty_for_default": "genereeritud kataloogi tee (vaikimisi tühi)", "set_up_your_paths": "Seadista oma failiteed", @@ -1287,14 +1301,17 @@ "where_can_stash_store_cache_files": "Kus saab Stash hoida cache faile?", "where_can_stash_store_cache_files_description": "Mõne funktsionaalsuse, nagu HLS/DASH reaalas transkodeerimine, töötamiseks vajab Stash cache kausta ajutiste failide jaoks. Vaikimisi loob Stash cache kausta mis asub konfiguratsioonifailiga samas kaustas. Kui tahad seda muuta, palun sisesta absoluutne või relatiivne (töökaustaga) tee. Stash loob selle kausta kui seda juba ei eksisteeri.", "where_can_stash_store_its_database": "Kuhu saab Stash oma andmebaasi salvestada?", - "where_can_stash_store_its_database_description": "Stash kasutab su porno metaandmete salvestamiseks SQLite'i andmebaasi. Vaikimisi luuakse see konfiguratsioonifaili sisaldavasse kataloogi kui stash-go.sqlite. Kui soovid seda muuta, sisesta absoluutne või suhteline failinimi (praeguse töökataloogi suhtes).", + "where_can_stash_store_its_database_description": "Stash kasutab su sisu metaandmete salvestamiseks SQLite'i andmebaasi. Vaikimisi luuakse see konfiguratsioonifaili sisaldavasse kataloogi kui stash-go.sqlite. Kui soovid seda muuta, sisesta absoluutne või suhteline failinimi (praeguse töökataloogi suhtes).", "where_can_stash_store_its_database_warning": "HOIATUS: hoides andmebaasi erineval süsteemil kui millel Stash jookseb (nt hoides andmebaasi NASil kui Stash jookseb teisel arvutil) on mitte toetatud! SQLite ei ole mõeldud kasutamiseks üle võrgu ja selle proovimine võib väga kergesti viia andmebaasi korrupeerumiseni.", "where_can_stash_store_its_generated_content": "Kus saab Stash oma genereeritud sisu salvestada?", "where_can_stash_store_its_generated_content_description": "Pisipiltide, eelvaadete ja spraitide pakkumiseks loob Stash pilte ja videoid. See hõlmab ka toetamata failivormingute ümbertöötlemist. Vaikimisi loob Stash konfiguratsioonifaili sisaldavas kaustas genereeritud kausta. Kui soovid muuta seda, kus see loodud meedium salvestatakse, sisesta absoluutne või suhteline failitee (praeguse töökataloogi suhtes). Stash loob selle kausta, kui seda veel pole.", - "where_is_your_porn_located": "Kus su porno asub?", - "where_is_your_porn_located_description": "Lisage oma pornovideoid ja pilte sisaldavad kataloogid. Stash kasutab neid katalooge skanimise ajal videote ja piltide otsimiseks.", + "where_is_your_porn_located": "Kus su sisu asub?", + "where_is_your_porn_located_description": "Lisage oma videoid ja pilte sisaldavad kataloogid. Stash kasutab neid katalooge skanimise ajal videote ja piltide otsimiseks.", "path_to_blobs_directory_empty_for_default": "blobsi kataloogi tee (vaikimisi tühi)", - "store_blobs_in_database": "Salvesta blobid andmebaasi" + "store_blobs_in_database": "Salvesta blobid andmebaasi", + "sfw_content_settings": "Kasutad stashi SFW sisu jaoks?", + "sfw_content_settings_description": "stashi saab kasutada SFW-sisu, näiteks fotograafia, kunsti, koomiksite ja muu haldamiseks. Selle valiku lubamine muudab kasutajaliidese käitumist SFW-sisu jaoks sobivamaks.", + "use_sfw_content_mode": "Kasuta SFW sisu režiimi" }, "stash_setup_wizard": "Stashi Ülessättimise Viisard", "success": { @@ -1547,5 +1564,10 @@ "login": "Logi Sisse" }, "age_on_date": "{age} filmimisel", - "sort_name": "Sorteeritud Nimi" + "sort_name": "Sorteeritud Nimi", + "scenes_duration": "Stseeni Pikkus", + "last_o_at_sfw": "Viimane Meeldimine", + "o_count_sfw": "Meeldimisi", + "o_history_sfw": "Meeldimiste Ajalugu", + "odate_recorded_no_sfw": "Meeldimise Kuupäeva Pole Salvestatud" } diff --git a/ui/v2.5/src/locales/fi-FI.json b/ui/v2.5/src/locales/fi-FI.json index d1636230b..2256d8e5d 100644 --- a/ui/v2.5/src/locales/fi-FI.json +++ b/ui/v2.5/src/locales/fi-FI.json @@ -1024,7 +1024,6 @@ "name": "Nimi", "new": "Uusi", "none": "Ei mitään", - "o_counter": "O-Laskuri", "operations": "Operaatiot", "organized": "Järjestelty", "pagination": { diff --git a/ui/v2.5/src/locales/fr-FR.json b/ui/v2.5/src/locales/fr-FR.json index 997a672be..94780b647 100644 --- a/ui/v2.5/src/locales/fr-FR.json +++ b/ui/v2.5/src/locales/fr-FR.json @@ -305,7 +305,9 @@ "password_desc": "Mot de passe pour accéder à Stash. Laisser vide pour désactiver l'authentification utilisateur", "stash-box_integration": "Intégration de Stash-Box", "username": "Nom d'utilisateur", - "username_desc": "Nom d'utilisateur pour accéder à Stash. Laisser vide pour désactiver l'authentification utilisateur" + "username_desc": "Nom d'utilisateur pour accéder à Stash. Laisser vide pour désactiver l'authentification utilisateur", + "log_file_max_size": "Taille maximale du journal", + "log_file_max_size_desc": "Taille maximale en mégaoctets du fichier journal avant compression. 0 Mo est désactivé. Nécessite un redémarrage." }, "backup_directory_path": { "description": "Emplacement de sauvegarde des bases de données SQLite", @@ -812,6 +814,18 @@ "use_stash_hosted_funscript": { "description": "Activée, les scripts interactifs sont transmis directement de Stash à votre dispositif Handy sans recourir au serveur Handy de tierce partie. Nécessite que Stash soit accessible depuis votre dispositif Handy, et qu'une clé API soit générée si Stash a des informations d'identification configurées.", "heading": "Transmettre directement les funscripts" + }, + "performer_list": { + "heading": "Liste de performeurs", + "options": { + "show_links_on_grid_card": { + "heading": "Afficher les liens sur les fiches des performeurs" + } + } + }, + "sfw_mode": { + "description": "Activez cette option si vous utilisez Stash pour stocker du contenu SFW. Masque ou modifie certains aspects de l'interface utilisateur liés au contenu pour adultes.", + "heading": "Mode contenu SFW" } }, "advanced_mode": "Mode avancé" @@ -975,7 +989,8 @@ "clear_o_history_confirm": "Êtes-vous sûr de vouloir effacer l'historique des O ?", "clear_play_history_confirm": "Êtes-vous sûr de vouloir effacer l'historique de lecture ?", "overwrite_filter_warning": "Le filtre enregistré \"{entityName}\" sera remplacé.", - "set_default_filter_confirm": "Êtes-vous sûr de vouloir définir ce filtre par défaut ?" + "set_default_filter_confirm": "Êtes-vous sûr de vouloir définir ce filtre par défaut ?", + "clear_o_history_confirm_sfw": "Êtes-vous sûr de vouloir effacer l'historique des \"J'aime\" ?" }, "dimensions": "Dimensions", "director": "Réalisateur", @@ -1099,8 +1114,8 @@ "syncing": "Synchronisation avec le serveur", "uploading": "Script de chargement" }, - "hasChapters": "A des chapitres", - "hasMarkers": "Dispose de marqueurs", + "hasChapters": "Chapitres", + "hasMarkers": "Marqueurs", "height": "Taille", "height_cm": "Taille (cm)", "help": "Aide", @@ -1148,7 +1163,6 @@ "name": "Nom", "new": "Nouveau", "none": "Aucun", - "o_counter": "O-Compteur", "operations": "Opérations", "organized": "Organisé", "pagination": { @@ -1287,7 +1301,7 @@ }, "paths": { "database_filename_empty_for_default": "Nom de fichier de la base de données (vide par défaut)", - "description": "Ensuite, nous devons déterminer où trouver votre collection pornographique, et où stocker la base de données Stash, les fichiers générés et les fichiers cache. Ces paramètres peuvent être modifiés ultérieurement si nécessaire.", + "description": "Ensuite, nous devons déterminer où trouver votre contenu, et où stocker la base de données Stash, les fichiers générés et les fichiers cache. Ces paramètres peuvent être modifiés ultérieurement si nécessaire.", "path_to_blobs_directory_empty_for_default": "chemin vers le répertoire des blobs (vide par défaut)", "path_to_cache_directory_empty_for_default": "chemin du répertoire du cache (vide par défaut)", "path_to_generated_directory_empty_for_default": "Chemin vers le répertoire généré (vide par défaut)", @@ -1300,12 +1314,15 @@ "where_can_stash_store_cache_files": "Où Stash peut-il stocker les fichiers cache ?", "where_can_stash_store_cache_files_description": "Pour que certaines fonctionnalités telles que le transcodage en temps réel HLS/DASH puissent fonctionner, Stash a besoin d'un répertoire de cache pour les fichiers temporaires. Par défaut, Stash créera un sous-répertoire cache dans le répertoire contenant votre fichier de configuration. Si vous souhaitez le modifier, merci de saisir un chemin absolu ou relatif (par rapport au répertoire de travail actuel). Stash créera ce sous-répertoire s'il n'existe pas déjà.", "where_can_stash_store_its_database": "Où Stash peut-il stocker sa base de données ?", - "where_can_stash_store_its_database_description": "Stash utilise une base de données SQLite pour stocker vos métadonnées pornographiques. Par défaut, cette base sera créée en tant que stash-go.sqlite dans le répertoire contenant votre fichier de configuration. Si vous souhaitez modifier cela, saisissez un nom de fichier absolu ou relatif ( vers le répertoire de travail actuel).", + "where_can_stash_store_its_database_description": "Stash utilise une base de données SQLite pour stocker vos métadonnées de contenu. Par défaut, cette base sera créée en tant que stash-go.sqlite dans le répertoire contenant votre fichier de configuration. Si vous souhaitez modifier cela, saisissez un nom de fichier absolu ou relatif ( vers le répertoire de travail actuel).", "where_can_stash_store_its_database_warning": "AVERTISSEMENT : Le stockage de la base de données sur un système différent de celui à partir duquel Stash est exécuté (par exemple, le stockage de la base de données sur un NAS tout en exécutant le serveur Stash sur un autre ordinateur) est non pris en charge ! SQLite n'est pas conçu pour être utilisé sur un réseau, et toute tentative de le faire peut très facilement entraîner la corruption de l'ensemble de votre base de données.", "where_can_stash_store_its_generated_content": "Où Stash peut-il stocker son contenu généré ?", "where_can_stash_store_its_generated_content_description": "Afin de produire les vignettes, aperçus et sprites, Stash génère des images et des vidéos. Cela inclut également les transcodes pour les formats de fichiers non pris en charge. Par défaut, Stash crée un répertoire generated dans le répertoire contenant votre fichier de configuration. Si vous souhaitez modifier l'emplacement où seront stockés les médias générés, veuillez saisir un chemin absolu ou relatif ( vers le répertoire de travail actuel). Stash créera ce répertoire s'il n'existe pas déjà.", - "where_is_your_porn_located": "Où se trouve votre porno ?", - "where_is_your_porn_located_description": "Ajoutez des répertoires contenant vos vidéos et images pornographiques. Stash utilisera ces répertoires pour rechercher les vidéos et les images lors de l'analyse." + "where_is_your_porn_located": "Où se trouve votre contenu ?", + "where_is_your_porn_located_description": "Ajoutez des répertoires contenant vos vidéos et images. Stash utilisera ces répertoires pour rechercher les vidéos et les images lors de l'analyse.", + "sfw_content_settings": "Utiliser Stash pour du contenu SFW ?", + "sfw_content_settings_description": "Stash peut être utilisé pour gérer du contenu SFW tel que des photographies, des illustrations, des bandes dessinées, et plus. L'activation de cette option modifiera certains comportements de l'interface utilisateur pour les rendre plus appropriés au contenu SFW.", + "use_sfw_content_mode": "Utiliser le mode de contenu SFW" }, "stash_setup_wizard": "Assistant de configuration de Stash", "success": { @@ -1496,13 +1513,13 @@ "last_o_at": "Dernier O le", "o_count": "Nombre d'O", "o_history": "Historique d'O", - "odate_recorded_no": "Aucune date d'O enregistrée", + "odate_recorded_no": "Aucun O daté enregistré", "subsidiary_studio_count": "Nombre de studios affiliés", "tag_sub_tag_tooltip": "A des étiquettes affiliées", "time": "Temps", "photographer": "Photographe", "play_history": "Historique de Lecture", - "playdate_recorded_no": "Aucune date de lecture enregistrée", + "playdate_recorded_no": "Aucune lecture datée enregistrée", "plays": "{value} lectures", "unknown_date": "Date inconnue", "history": "Historique", @@ -1547,5 +1564,10 @@ "internal_error": "Erreur interne inattendue. Consulter le journal pour plus de détails", "login": "Identification", "username": "Nom d'utilisateur" - } + }, + "scenes_duration": "Durée de la scène", + "last_o_at_sfw": "Dernier J'aime", + "o_count_sfw": "J'aime", + "odate_recorded_no_sfw": "Aucun “J’aime” daté enregistré", + "o_history_sfw": "Historique des \"J’aime\"" } diff --git a/ui/v2.5/src/locales/hu-HU.json b/ui/v2.5/src/locales/hu-HU.json index be47f77a9..b35e4579e 100644 --- a/ui/v2.5/src/locales/hu-HU.json +++ b/ui/v2.5/src/locales/hu-HU.json @@ -503,7 +503,6 @@ "name": "Név", "new": "Új", "none": "Nincs", - "o_counter": "O-Számláló", "operations": "Műveletek", "organized": "Rendezve", "pagination": { diff --git a/ui/v2.5/src/locales/id-ID.json b/ui/v2.5/src/locales/id-ID.json index ba0c9bca7..a1b4f43f2 100644 --- a/ui/v2.5/src/locales/id-ID.json +++ b/ui/v2.5/src/locales/id-ID.json @@ -756,7 +756,6 @@ "generic": "Memuat…" }, "tag": "Tag", - "o_counter": "Penghitung Crot", "operations": "Operasi", "organized": "Terorganisir", "orientation": "Orientasi", diff --git a/ui/v2.5/src/locales/it-IT.json b/ui/v2.5/src/locales/it-IT.json index 349abaabb..0735540d6 100644 --- a/ui/v2.5/src/locales/it-IT.json +++ b/ui/v2.5/src/locales/it-IT.json @@ -937,7 +937,6 @@ "name": "Nome", "new": "Nuovo", "none": "Nessuno/a", - "o_counter": "Contatore-O", "operations": "Operazioni", "organized": "Ordinato", "pagination": { diff --git a/ui/v2.5/src/locales/ja-JP.json b/ui/v2.5/src/locales/ja-JP.json index 005083d6d..0c5a8ef89 100644 --- a/ui/v2.5/src/locales/ja-JP.json +++ b/ui/v2.5/src/locales/ja-JP.json @@ -791,6 +791,9 @@ "direction": "方向", "heading": "画像ウォール", "margin": "マージン (px単位)" + }, + "performer_list": { + "heading": "AV女優・男優リスト" } }, "advanced_mode": "高度なモード" @@ -808,7 +811,7 @@ }, "country": "国", "cover_image": "カバー画像", - "created_at": "作成者:", + "created_at": "作成日", "criterion": { "greater_than": "より大きい", "less_than": "より小さい", @@ -1076,7 +1079,6 @@ "name": "名前", "new": "新規作成", "none": "なし", - "o_counter": "発射カウンター", "operations": "オペレーション", "organized": "分類済み", "pagination": { @@ -1328,5 +1330,6 @@ "value": "値" }, "distance": "距離", - "age_on_date": "撮影時の年齢 {age}歳" + "age_on_date": "撮影時の年齢 {age}歳", + "containing_group": "含まれるグループ" } diff --git a/ui/v2.5/src/locales/ko-KR.json b/ui/v2.5/src/locales/ko-KR.json index f022b7790..15d285434 100644 --- a/ui/v2.5/src/locales/ko-KR.json +++ b/ui/v2.5/src/locales/ko-KR.json @@ -45,9 +45,9 @@ "from_url": "URL로 불러오기…", "full_export": "전부 내보내기", "full_import": "전부 불러오기", - "generate": "만들기", - "generate_thumb_default": "기본 썸네일 만들기", - "generate_thumb_from_current": "현재 화면으로 썸네일 만들기", + "generate": "생성", + "generate_thumb_default": "기본 썸네일 생성", + "generate_thumb_from_current": "현재 화면으로 썸네일 생성", "hash_migration": "해쉬 값 마이그레이션", "hide": "숨기기", "hide_configuration": "설정 숨기기", @@ -65,7 +65,7 @@ "next_action": "다음", "not_running": "실행 중이 아님", "open_in_external_player": "외부 플레이어에서 열기", - "open_random": "랜덤 배우 정보 열기", + "open_random": "랜덤 열기", "overwrite": "덮어쓰기", "play_random": "랜덤 영상 재생", "play_selected": "선택된 영상 재생", @@ -104,7 +104,7 @@ "show": "보여주기", "show_configuration": "설정 보여주기", "skip": "건너뛰기", - "split": "나누기", + "split": "분할", "stop": "정지", "submit": "제출", "submit_stash_box": "Stash-Box에 제출하기", @@ -214,7 +214,7 @@ "fp_matches": "영상 길이가 일치함", "fp_matches_multi": "영상 길이가 {durationsLength}개 중 {matchCount}개의 식별값과 일치합니다", "hash_matches": "{hash_type}이 일치함", - "match_failed_already_tagged": "이미 태깅된 영상", + "match_failed_already_tagged": "이미 태그된 영상", "match_failed_no_result": "결과 없음", "match_success": "영상 태깅 성공", "phash_matches": "{count}개의 PHash가 일치함", @@ -432,7 +432,7 @@ }, "plugins": { "hooks": "후크", - "triggers_on": "트리거 켜기", + "triggers_on": "작동 조건", "available_plugins": "사용 가능한 플러그인", "installed_plugins": "설치된 플러그인" }, @@ -487,9 +487,9 @@ "generating_from_paths": "다음 경로에서 영상 생성 중", "generating_scenes": "{num}개의 {scene} 생성 중" }, - "generate_clip_previews_during_scan": "이미지 클립 미리보기 생성하기", + "generate_clip_previews_during_scan": "이미지 클립 미리보기 생성", "generate_desc": "이미지, 스프라이트, 비디오, vtt 등 파일을 생성합니다.", - "generate_phashes_during_scan": "컨텐츠 해시 값 생성", + "generate_phashes_during_scan": "해쉬 값 생성", "generate_phashes_during_scan_tooltip": "중복된 파일 확인과 영상 식별에 사용됩니다.", "generate_previews_during_scan": "움직이는 이미지 미리보기 생성", "generate_previews_during_scan_tooltip": "애니메이션(webp) 미리보기 또한 생성합니다. 영상/마커 월의 미리보기 유형이 '애니메이션 이미지'로 설정된 경우에만 필요합니다. '애니메이션 미리보기'는 '비디오 미리보기'보다 CPU를 덜 사용하지만, '비디오 미리보기'에 추가적으로 '애니메이션 미리보기'가 생성되기 때문에 파일 크기가 커집니다.", @@ -626,12 +626,12 @@ }, "editing": { "disable_dropdown_create": { - "description": "드랍다운 메뉴에서 새로운 오브젝트를 추가할 수 없도록 합니다", - "heading": "드랍다운 메뉴 비활성화" + "description": "선택창에서 새로운 오브젝트를 추가할 수 없도록 합니다", + "heading": "선택창 비활성화" }, "heading": "수정하기", "max_options_shown": { - "label": "Dropdown에 표시되는 최대 개수" + "label": "선택창에 표시되는 최대 개수" }, "rating_system": { "star_precision": { @@ -811,6 +811,14 @@ "description": "활성화되면, 모든 컨텐츠 세부사항이 기본값으로 보여지게 되고, 각각의 세부사항들이 하나의 열에 위아래로 정렬됩니다", "heading": "모든 세부사항 보여주기" } + }, + "performer_list": { + "heading": "배우 목록", + "options": { + "show_links_on_grid_card": { + "heading": "배우 그리드 카드에 링크 표시" + } + } } }, "advanced_mode": "고급 설정 모드" @@ -838,7 +846,7 @@ "criterion_modifier": { "between": "구간", "equals": "=", - "excludes": "포함하지 않음", + "excludes": "제외", "format_string": "{criterion} {modifierString} {valueString}", "greater_than": ">", "includes": "포함", @@ -933,7 +941,7 @@ "override_preview_generation_options": "미리보기 생성 옵션 재정의", "override_preview_generation_options_desc": "이 작업에 대한 미리보기 생성 옵션을 재정의합니다. 기본값은 '시스템' -> '미리보기 생성'에서 설정됩니다.", "overwrite": "기존 파일들 덮어쓰기", - "phash": "해시", + "phash": "해쉬", "preview_exclude_end_time_desc": "영상 미리보기에서 마지막 x 초를 제외합니다. 초 단위, 혹은 전체 영상 재생 길이 중 비율(예: 2%)로 나타낼 수 있습니다.", "preview_exclude_end_time_head": "마지막 영상 부분 제외", "preview_exclude_start_time_desc": "영상 미리보기에서 처음 x 초를 제외합니다. 초 단위, 혹은 전체 영상 재생 길이 중 비율(예: 2%)로 나타낼 수 있습니다.", @@ -990,7 +998,7 @@ "donate": "후원", "dupe_check": { "description": "'정확' 이하의 수준에서는 계산이 오래 걸릴 수 있습니다. 낮은 정밀도 수준에서는 부정확한 결과가 함께 나올 수 있습니다.", - "duration_diff": "최대 영상 재생 시간 차이", + "duration_diff": "최대 영상 길이 차이", "duration_options": { "any": "상관 없음", "equal": "같음" @@ -1037,7 +1045,7 @@ }, "empty_server": "이 페이지에서 추천 영상들을 확인하려면 영상을 추가하세요.", "errors": { - "image_index_greater_than_zero": "이미지 인덱스는 0보다 커야 합니다", + "image_index_greater_than_zero": "이미지 번호는 0보다 커야 합니다", "lazy_component_error_help": "만약 최근에 Stash를 업그레이드했다면, 웹페이지를 새로고침하거나 브라우저 캐시를 삭제해주세요.", "something_went_wrong": "오류가 발생했습니다.", "header": "오류", @@ -1098,8 +1106,8 @@ "syncing": "서버와 동기화 중", "uploading": "스크립트 업로드 중" }, - "hasChapters": "챕터 유무", - "hasMarkers": "마커 유무", + "hasChapters": "챕터", + "hasMarkers": "마커", "height": "키", "height_cm": "키 (cm)", "help": "도움말", @@ -1146,7 +1154,6 @@ "name": "이름", "new": "새로 만들기", "none": "없음", - "o_counter": "싼 횟수", "operations": "작업", "organized": "정리됨", "pagination": { @@ -1374,20 +1381,20 @@ "title": "제목", "toast": { "added_entity": "{singularEntity}을(를) 추가했습니다", - "added_generation_job_to_queue": "생성 작업을 대기열에 추가했습니다", + "added_generation_job_to_queue": "컨텐츠 생성 작업을 대기열에 추가했습니다", "created_entity": "{entity}를 생성했습니다", "default_filter_set": "기본 필터가 설정되었습니다", "delete_past_tense": "{count, plural, one {{singularEntity}} other {{pluralEntity}}}이(가) 삭제되었습니다", "generating_screenshot": "스크린샷을 생성하는 중…", "image_index_too_large": "오류: 이미지 번호가 갤러리의 이미지 개수보다 큽니다", - "merged_scenes": "영상들을 합쳤습니다", - "merged_tags": "병합된 태그", - "reassign_past_tense": "재할당된 파일", + "merged_scenes": "영상이 병합되었습니다", + "merged_tags": "태그가 병합되었습니다", + "reassign_past_tense": "파일이 재할당되었습니다", "removed_entity": "{singularEntity}을(를) 제거했습니다", "rescanning_entity": "{count, plural, one {{singularEntity}} other {{pluralEntity}}} 다시 스캔하는 중…", "saved_entity": "{entity}를 저장했습니다", "started_auto_tagging": "자동 태깅을 시작했습니다", - "started_generating": "생성을 시작했습니다", + "started_generating": "컨텐츠 생성을 시작했습니다", "started_importing": "불러오기를 시작했습니다", "updated_entity": "{entity}를 수정했습니다" }, @@ -1402,7 +1409,7 @@ "required": "${path}는 필수 항목입니다", "unique": "${path}은(는) 유일해야 합니다", "blank": "${path}를 빈 칸으로 둘 수 없습니다", - "end_time_before_start_time": "종료 시간은 시작 시간보다 크거나 같아야 합니다" + "end_time_before_start_time": "종료 시간은 시작 시간 이후여야 합니다" }, "videos": "비디오", "view_all": "모두 보기", @@ -1488,7 +1495,7 @@ "show_all": "모두 보여주기", "update": "업데이트", "selected_only": "선택된 것만", - "required_by": "{packages}로 인해 요구됨" + "required_by": "{packages}가 정상 동작하기 위해 설치되어야 함" }, "o_count": "싼 횟수", "orientation": "방향", @@ -1547,5 +1554,6 @@ "any": "값 존재", "any_of": "해당 값 중 일부 포함" }, - "eta": "예상 소요 시간" + "eta": "예상 소요 시간", + "scenes_duration": "영상 길이" } diff --git a/ui/v2.5/src/locales/nb-NO.json b/ui/v2.5/src/locales/nb-NO.json index a160a2991..1eff0a044 100644 --- a/ui/v2.5/src/locales/nb-NO.json +++ b/ui/v2.5/src/locales/nb-NO.json @@ -1415,7 +1415,6 @@ "name": "Navn", "new": "Ny", "none": "Ingen", - "o_counter": "O-Teller", "o_history": "O Historie", "odate_recorded_no": "Ingen O-dato registrert", "operations": "Operasjoner", diff --git a/ui/v2.5/src/locales/nl-NL.json b/ui/v2.5/src/locales/nl-NL.json index dd9b9dc05..fea685a1e 100644 --- a/ui/v2.5/src/locales/nl-NL.json +++ b/ui/v2.5/src/locales/nl-NL.json @@ -150,7 +150,8 @@ "show_results": "Resultaat tonen", "show_count_results": "{count} resultaten tonen", "play": "Afspelen", - "load_filter": "Laad filter" + "load_filter": "Laad filter", + "load": "Laden" }, "actions_name": "Acties", "age": "Leeftijd", @@ -291,7 +292,9 @@ "password_desc": "Wachtwoord om je verzameling te openen. Laat leeg om inloggen uit te schakelen", "stash-box_integration": "Stash-boxintegratie", "username": "Gebruikersnaam", - "username_desc": "Gebruikersnaam om je verzameling te openen. Laat leeg om inloggen uit te schakelen" + "username_desc": "Gebruikersnaam om je verzameling te openen. Laat leeg om inloggen uit te schakelen", + "log_file_max_size": "Maximale loggrootte", + "log_file_max_size_desc": "Maximale grootte in megabytes van het logbestand voordat het wordt gecomprimeerd. 0 MB is uitgeschakeld. Vereist herstart." }, "cache_location": "Locatie van de cachemap. Vereist als je streamt via HLS (zoals op Apple apparaten) of DASH.", "cache_path_head": "Cache pad", @@ -355,7 +358,7 @@ "database": "Database", "ffmpeg": { "download_ffmpeg": { - "heading": "Download FFmpeg", + "heading": "FFmpeg downloaden", "description": "Downloadt FFmpeg naar de configuratiemap en wist de ffmpeg- en ffprobe-paden zodat deze uit de configuratiemap kunnen worden opgehaald." }, "hardware_acceleration": { @@ -536,7 +539,9 @@ }, "migrate_scene_screenshots": { "delete_files": "Verwijder screenshotbestanden" - } + }, + "generate_sprites_during_scan_tooltip": "De set afbeeldingen die onder de videospeler worden weergegeven voor eenvoudige navigatie.", + "generate_video_covers_during_scan": "Scène-covers genereren" }, "tools": { "scene_duplicate_checker": "Scène Duplicator Checker", @@ -593,6 +598,14 @@ "decimal": "Decimaal", "stars": "Sterren" } + }, + "star_precision": { + "options": { + "full": "Vol", + "half": "Half", + "quarter": "Kwart", + "tenth": "Tiende" + } } } }, @@ -712,7 +725,8 @@ } }, "image_wall": { - "margin": "Marge (pixels)" + "margin": "Marge (pixels)", + "direction": "Richting" } }, "advanced_mode": "Geavanceerde modus" @@ -831,7 +845,9 @@ "transcodes": "Transcoderingen", "transcodes_tooltip": "MP4-conversies van niet-ondersteunde video-indelingen", "video_previews": "Voorbeelden", - "video_previews_tooltip": "Videovoorbeelden die worden afgespeeld wanneer u over een scène beweegt" + "video_previews_tooltip": "Videovoorbeelden die worden afgespeeld wanneer u over een scène beweegt", + "covers": "Scène-covers", + "image_thumbnails": "Afbeeldingsminiaturen" }, "scenes_found": "{count} scenes gevonden", "scrape_entity_query": "{entity_type} Schraper Query", @@ -844,7 +860,13 @@ "destination": "Bestemming", "source": "Bron" }, - "performers_found": "{count} artiesten gevonden" + "performers_found": "{count} artiesten gevonden", + "imagewall": { + "direction": { + "column": "Kolom", + "row": "Rij" + } + } }, "dimensions": "Dimensies", "director": "Regisseur", @@ -866,7 +888,11 @@ "medium": "Medium" }, "search_accuracy_label": "Zoek accuraatheid", - "title": "Dubbele Scènes" + "title": "Dubbele Scènes", + "duration_options": { + "equal": "Gelijk" + }, + "select_none": "Niets selecteren" }, "duplicated_phash": "Gedupliceerd (phash)", "duration": "Looptijd", @@ -936,7 +962,7 @@ "syncing": "Synchroniseren met server", "uploading": "Script uploaden" }, - "hasMarkers": "Heeft Markeringen", + "hasMarkers": "Markeringen", "height": "Hoogte", "help": "Help", "ignore_auto_tag": "Negeer automatische tag", @@ -977,7 +1003,6 @@ "name": "Naam", "new": "Nieuw", "none": "Geen", - "o_counter": "O-Teller", "operations": "Operaties", "organized": "Georganiseerd", "pagination": { @@ -1075,7 +1100,7 @@ }, "paths": { "database_filename_empty_for_default": "database bestandsnaam (leeg als standaard)", - "description": "Vervolgens moeten we bepalen waar we je pornocollectie kunnen vinden, waar we de stash-database en gegenereerde bestanden kunnen opslaan. Deze instellingen kunnen indien nodig later worden gewijzigd.", + "description": "Vervolgens moeten we bepalen waar we je collectie kunnen vinden, waar we de stash-database en gegenereerde bestanden kunnen opslaan. Deze instellingen kunnen indien nodig later worden gewijzigd.", "path_to_generated_directory_empty_for_default": "pad naar gegenereerde map (standaard leeg)", "set_up_your_paths": "Stel je paden in", "stash_alert": "Er zijn geen bibliotheekpaden geselecteerd. Er kan dan geen media worden gescand in Stash. Weet je zeker dat?", @@ -1084,7 +1109,7 @@ "where_can_stash_store_its_generated_content": "Waar kan Stash de gegenereerde inhoud opslaan?", "where_can_stash_store_its_generated_content_description": "Om thumbnails, previews en sprites aan te bieden, genereert Stash afbeeldingen en video's. Dit omvat ook transcodes voor niet-ondersteunde bestandsindelingen. Standaard zal Stash een generated directory aanmaken in de directory die uw configuratiebestand bevat. Als u wilt wijzigen waar deze gegenereerde media wordt opgeslagen, voert u een absoluut of relatief (ten opzichte van de huidige werkmap) pad in. Stash maakt deze map aan als deze nog niet bestaat.", "where_is_your_porn_located": "Waar staat je porno?", - "where_is_your_porn_located_description": "Voeg mappen toe die uw pornovideo's en afbeeldingen bevatten. Stash gebruikt deze mappen om video's en afbeeldingen te vinden tijdens het scannen." + "where_is_your_porn_located_description": "Voeg mappen toe die uw video's en afbeeldingen bevatten. Stash gebruikt deze mappen om video's en afbeeldingen te vinden tijdens het scannen." }, "stash_setup_wizard": "Stash-installatiewizard", "success": { @@ -1176,7 +1201,7 @@ "UNCUT": "Nee" }, "folder": "Folder", - "hasChapters": "Heeft hoofdstukken", + "hasChapters": "Hoofdstukken", "image_index": "Afbeelding #", "include_sub_groups": "Inclusief subgroepen", "index_of_total": "{index} van {total}", @@ -1216,5 +1241,36 @@ "refresh_tagged_studios": "Vernieuwen getagde studio's", "refreshing_will_update_the_data": "Vernieuwen zal de gegevens van alle getagde studio's van de stash-box bijwerken.", "create_or_tag_parent_studios": "Maak ontbrekende of label bestaande moederstudio's" - } + }, + "zip_file_count": "Aantal Zipbestanden", + "unknown_date": "Onbekende datum", + "urls": "URL's", + "date_format": "JJJJ-MM-DD", + "description": "Omschrijving", + "distance": "Afstand", + "package_manager": { + "description": "Omschrijving", + "install": "Installeer", + "package": "Pakket", + "source": { + "name": "Naam" + }, + "uninstall": "Verwijderen", + "unknown": "", + "update": "Updaten", + "version": "Versie" + }, + "penis": "Penis", + "photographer": "Fotograaf", + "second": "Seconde", + "statistics": "Statistieken", + "time": "Tijd", + "criterion_modifier_values": { + "none": "Geen" + }, + "custom_fields": { + "field": "Veld", + "value": "Waarde" + }, + "datetime_format": "YYYY-MM-DD HH:MM" } diff --git a/ui/v2.5/src/locales/nn-NO.json b/ui/v2.5/src/locales/nn-NO.json index a55928e53..995336e58 100644 --- a/ui/v2.5/src/locales/nn-NO.json +++ b/ui/v2.5/src/locales/nn-NO.json @@ -208,7 +208,6 @@ "last": "Siste" }, "o_count": "Tal på O", - "o_counter": "O-teljar", "organized": "Organisert", "playdate_recorded_no": "Ingen avspelingsdato er registrert", "play_duration": "Avspelingslengd", diff --git a/ui/v2.5/src/locales/pl-PL.json b/ui/v2.5/src/locales/pl-PL.json index 4d830a6e7..413460800 100644 --- a/ui/v2.5/src/locales/pl-PL.json +++ b/ui/v2.5/src/locales/pl-PL.json @@ -1036,7 +1036,6 @@ "name": "Nazwa", "new": "Dodaj", "none": "Brak", - "o_counter": "O-Licznik", "operations": "Operacje", "organized": "Uporządkowany", "pagination": { diff --git a/ui/v2.5/src/locales/pt-BR.json b/ui/v2.5/src/locales/pt-BR.json index 40ff46319..eb183b234 100644 --- a/ui/v2.5/src/locales/pt-BR.json +++ b/ui/v2.5/src/locales/pt-BR.json @@ -864,7 +864,6 @@ "name": "Nome", "new": "Novo", "none": "Nenhum", - "o_counter": "O-contador", "operations": "Operações", "organized": "Organizado", "pagination": { diff --git a/ui/v2.5/src/locales/ro-RO.json b/ui/v2.5/src/locales/ro-RO.json index 4e2237729..1b4375207 100644 --- a/ui/v2.5/src/locales/ro-RO.json +++ b/ui/v2.5/src/locales/ro-RO.json @@ -394,7 +394,6 @@ "metadata": "Metadate", "name": "Nume", "new": "Nou", - "o_counter": "O-Contor", "operations": "Operațiuni", "organized": "Organizat", "pagination": { diff --git a/ui/v2.5/src/locales/ru-RU.json b/ui/v2.5/src/locales/ru-RU.json index 8a3bc0473..9b1d790d2 100644 --- a/ui/v2.5/src/locales/ru-RU.json +++ b/ui/v2.5/src/locales/ru-RU.json @@ -149,7 +149,9 @@ "close": "Закрыть панель" }, "show_count_results": "Показать {count} результат(ов)", - "play": "Воспроизвести" + "play": "Воспроизвести", + "load": "Загрузить", + "load_filter": "Загрузить фильтр" }, "actions_name": "Действия", "age": "Возраст", @@ -1119,7 +1121,6 @@ "name": "Имя", "new": "Новый", "none": "Отсутствует", - "o_counter": "О-Счетчик", "operations": "Операции", "organized": "Организован", "pagination": { @@ -1206,7 +1207,8 @@ "saved_filters": "Сохраненные фильтры", "update_filter": "Обновить фильтр", "edit_filter": "Изменить фильтр", - "more_filter_criteria": "+ещё {count}" + "more_filter_criteria": "+ещё {count}", + "search_term": "Поисковый запрос" }, "seconds": "Секунды", "settings": "Настройки", diff --git a/ui/v2.5/src/locales/sv-SE.json b/ui/v2.5/src/locales/sv-SE.json index 152f137d2..71ba3e99f 100644 --- a/ui/v2.5/src/locales/sv-SE.json +++ b/ui/v2.5/src/locales/sv-SE.json @@ -149,7 +149,9 @@ "close": "Stäng sidolisten", "open": "Öppna sidolisten" }, - "show_results": "Visa resultat" + "show_results": "Visa resultat", + "load": "Ladda", + "load_filter": "Ladda filter" }, "actions_name": "Handlingar", "age": "Ålder", @@ -810,6 +812,14 @@ "use_stash_hosted_funscript": { "description": "När aktiverat kommer funscripts att skickas direkt från Stash till din Handy-enhet utan att använda tredjeparts Handy-servern. Kräver att Stash kan nås från din Handy-enhet och att en API-nyckel är genererad om stash har lösenord aktiverat.", "heading": "Skicka funscripts direkt" + }, + "performer_list": { + "heading": "Lista av stjärnor", + "options": { + "show_links_on_grid_card": { + "heading": "Visa länkar på stjärnors kort" + } + } } }, "advanced_mode": "Avancerat Läge" @@ -1097,8 +1107,8 @@ "syncing": "Synkar med server", "uploading": "Laddar upp skript" }, - "hasChapters": "Har Kapitel", - "hasMarkers": "Har Markörer", + "hasChapters": "Kapitel", + "hasMarkers": "Markörer", "height": "Längd", "height_cm": "Längd (cm)", "help": "Hjälp", @@ -1146,7 +1156,6 @@ "name": "Namn", "new": "Ny", "none": "Ingen", - "o_counter": "O-räknare", "operations": "Operationer", "organized": "Organiserad", "pagination": { @@ -1237,7 +1246,8 @@ "name": "Filter", "saved_filters": "Sparade filter", "update_filter": "Uppdatera filter", - "more_filter_criteria": "+{count} fler" + "more_filter_criteria": "+{count} fler", + "search_term": "Sökterm" }, "second": "Sekund", "seconds": "Sekunder", @@ -1544,5 +1554,6 @@ "login": "Inlogg", "invalid_credentials": "Ogiltigt användarnamn eller lösenord" }, - "age_on_date": "{age} vid produktion" + "age_on_date": "{age} vid produktion", + "scenes_duration": "Scen Speltid" } diff --git a/ui/v2.5/src/locales/th-TH.json b/ui/v2.5/src/locales/th-TH.json index e8fdf0e52..9467a0832 100644 --- a/ui/v2.5/src/locales/th-TH.json +++ b/ui/v2.5/src/locales/th-TH.json @@ -1302,7 +1302,6 @@ "name": "ชื่อเรื่อง", "new": "เพิ่ม", "none": "ไม่มี", - "o_counter": "O-Counter", "o_history": "ประวัติ O", "organized": "จัดระเบียบแล้ว", "disambiguation": "แก้ความกำกวม", diff --git a/ui/v2.5/src/locales/tr-TR.json b/ui/v2.5/src/locales/tr-TR.json index 672212dc8..d220e4f6e 100644 --- a/ui/v2.5/src/locales/tr-TR.json +++ b/ui/v2.5/src/locales/tr-TR.json @@ -1054,7 +1054,6 @@ "name": "Ad", "new": "Yeni", "none": "Hiçbiri", - "o_counter": "O-Sayacı", "operations": "İşlemler", "organized": "Düzenlendi", "pagination": { diff --git a/ui/v2.5/src/locales/uk-UA.json b/ui/v2.5/src/locales/uk-UA.json index 3f44afca1..3ecac7c2e 100644 --- a/ui/v2.5/src/locales/uk-UA.json +++ b/ui/v2.5/src/locales/uk-UA.json @@ -141,7 +141,17 @@ "copy_to_clipboard": "Копіювати до буфера обміну", "set_back_image": "Зворотне зображення…", "set_front_image": "Переднє зображення…", - "unset": "Скинути" + "unset": "Скинути", + "load": "Завантажити", + "load_filter": "Завантажити фільтр", + "play": "Відтворити", + "show_results": "Висвітити вислід", + "show_count_results": "Висвітити {count} висліди", + "sidebar": { + "close": "Сховати бічну панель", + "open": "Розгорнути бічну панель", + "toggle": "Перемикнути бічну панель" + } }, "actions_name": "Дії", "age": "Вік", @@ -520,7 +530,9 @@ "title": "Парсер Імені Файлу Сцени" }, "scene_tools": "Інструменти Сцени", - "scene_duplicate_checker": "Перевірка сцен на дублікати" + "scene_duplicate_checker": "Перевірка сцен на дублікати", + "graphql_playground": "GraphQL ігровий майданчик", + "heading": "Начиння" }, "ui": { "scene_player": { @@ -543,7 +555,8 @@ "enable_chromecast": "Увімкнути Chromecast", "show_scrubber": "Показати скруббер", "track_activity": "Увімкнути історію відтворення сцен", - "auto_start_video": "Автозапуск відео" + "auto_start_video": "Автозапуск відео", + "show_range_markers": "Виявити Позначки Охоплення" }, "heading": "Плеєр сцени" }, @@ -697,7 +710,8 @@ "description": "За замовчуванням використовуються відео-прев’ю (mp4). Для меншого навантаження на CPU під час перегляду можна використовувати анімовані зображення (webp) як прев’ю. Однак їх потрібно генерувати додатково до відео-прев’ю, і вони займають більше місця на диску.", "options": { "animated": "Анімоване зображення", - "static": "Статичне зображення" + "static": "Статичне зображення", + "video": "Відео" }, "heading": "Тип попереднього перегляду" }, @@ -743,7 +757,15 @@ "toggle_sound": "Увімкнути звук" } }, - "title": "Користувацький інтерфейс" + "title": "Користувацький інтерфейс", + "performer_list": { + "heading": "Перелік виконавців", + "options": { + "show_links_on_grid_card": { + "heading": "Відображати посилання на картки виконавців" + } + } + } }, "plugins": { "hooks": "Хуки", @@ -774,7 +796,9 @@ "endpoint": "Кінцева точка", "name": "Назва", "graphql_endpoint": "Кінцева точка GraphQL", - "api_key": "API ключ" + "api_key": "API ключ", + "max_requests_per_minute": "Макс запитів у хвилину", + "max_requests_per_minute_description": "Викорситовує значення за замовчуванням {defaultValue} якщо встановлено в 0" }, "system": { "transcoding": "Транскодування" @@ -992,7 +1016,6 @@ "generic": "Завантаження…", "plugins": "Завантаження плагінів…" }, - "o_counter": "O-Лічильник", "performer_tagger": { "status_tagging_job_queued": "Статус: Задача проставлення міток в черзі", "number_of_performers_will_be_processed": "Будуть оброблені {performer_count} виконавців", @@ -1274,7 +1297,12 @@ "not_equals": "не є", "greater_than": "більше ніж", "includes_all": "включає все", - "is_null": "є null" + "is_null": "є null", + "between": "поміж", + "excludes": "виключення", + "format_string_excludes": "{criterion} {modifierString} {valueString} (за виключенням {excludedString})", + "format_string_excludes_depth": "{criterion} {modifierString} {valueString} (за виключенням {excludedString}) (+{глибина, множина, =-1 {all} інші {{depth}}})", + "includes": "включно" }, "toast": { "rescanning_entity": "Повторне сканування {count, plural, one {{singularEntity}} other {{pluralEntity}}}…", @@ -1321,7 +1349,8 @@ "created_at": "Створено", "criterion": { "greater_than": "Більше ніж", - "less_than": "Менше ніж" + "less_than": "Менше ніж", + "value": "Значення" }, "containing_group": "Група, що містить", "cover_image": "Обкладинка зображення", @@ -1329,7 +1358,8 @@ "custom_fields": { "title": "Користувацькі поля", "field": "Поле", - "value": "Значення" + "value": "Значення", + "criteria_format_string": "{criterion} (своє поле) {modifierString} {valueString}" }, "death_date": "Дата смерті", "developmentVersion": "Розробницька версія", @@ -1416,5 +1446,8 @@ "plays": "{value} відтворень", "subsidiary_studios": "Дочірні студії", "subsidiary_studio_count": "Кількість дочірніх студій", - "age_on_date": "{age} років під час зйомок" + "age_on_date": "{age} років під час зйомок", + "configuration": "Обрис", + "country": "Країна", + "custom": "Свій" } diff --git a/ui/v2.5/src/locales/zh-CN.json b/ui/v2.5/src/locales/zh-CN.json index 415c17976..5e0751171 100644 --- a/ui/v2.5/src/locales/zh-CN.json +++ b/ui/v2.5/src/locales/zh-CN.json @@ -304,7 +304,9 @@ "password_desc": "登录 Stash 时所需的密码.留空表示关闭身份验证", "stash-box_integration": "整合 Stash-box", "username": "用户名", - "username_desc": "登录 Stash 时所需的用户名.留空表示关闭身份验证" + "username_desc": "登录 Stash 时所需的用户名.留空表示关闭身份验证", + "log_file_max_size": "最大日志尺寸", + "log_file_max_size_desc": "日志文件压缩前的最大大小(以兆字节为单位)。0MB 表示禁用此功能。需要重启。" }, "backup_directory_path": { "description": "备份SQLite 数据库文件的目录路径", @@ -811,6 +813,18 @@ "use_stash_hosted_funscript": { "description": "启用后,funscript将直接从Stash提供到您的Handy设备,而无需使用第三方Handy服务器。要求可以从您的Handy设备访问Stash,并且如果stash配置了凭据,则会生成API密钥。", "heading": "直接提供funscripts服务" + }, + "performer_list": { + "heading": "演员列表", + "options": { + "show_links_on_grid_card": { + "heading": "在演员网格卡片上显示链接" + } + } + }, + "sfw_mode": { + "description": "如果使用 stash 存储SFW(工作场合安全)内容,请启用。它隐藏或更改了 UI 中与成人内容相关的某些方面。", + "heading": "SFW(工作场合安全)内容模式" } }, "advanced_mode": "高级模式" @@ -974,7 +988,8 @@ "clear_o_history_confirm": "真的确定要清空高潮记录吗?", "clear_play_history_confirm": "真的确定要清空播放历史?", "set_default_filter_confirm": "你确定要设置这个过滤器为默认吗?", - "overwrite_filter_warning": "已保存的过滤器 \"{entityName}\" 将被覆盖。" + "overwrite_filter_warning": "已保存的过滤器 \"{entityName}\" 将被覆盖。", + "clear_o_history_confirm_sfw": "你确定要清除点赞的历史?" }, "dimensions": "大小", "director": "导演", @@ -1098,8 +1113,8 @@ "syncing": "正在和服务器同步", "uploading": "上传脚本中" }, - "hasChapters": "已有章节", - "hasMarkers": "含有章节标记", + "hasChapters": "章节", + "hasMarkers": "章节标记", "height": "身高", "height_cm": "高(cm)", "help": "说明", @@ -1146,7 +1161,6 @@ "name": "名称", "new": "新增", "none": "空", - "o_counter": "高潮次数", "operations": "操作", "organized": "是否已经整理", "pagination": { @@ -1281,7 +1295,7 @@ }, "paths": { "database_filename_empty_for_default": "数据库文件名 (留空则用默认名)", - "description": "接下来,我们需要决定哪里找到你的收藏,哪里存放 stash 数据库和产生资料文件。如果需要,这些设定可在以后再修改。", + "description": "接下来,我们需要决定哪里找到你的内容,哪里存放 stash 数据库和产生资料文件。如果需要,这些设定可在以后再修改。", "path_to_cache_directory_empty_for_default": "缓存目录的路径(默认为空)", "path_to_generated_directory_empty_for_default": "生成资料的文件夹路径 (留空则使用默认路径)", "set_up_your_paths": "设立你的路径", @@ -1292,14 +1306,17 @@ "where_can_stash_store_cache_files": "Stash可以在哪里存储缓存文件?", "where_can_stash_store_cache_files_description": "为了使HLS/DASH实时转码等功能正常运行,Stash需要一个临时文件的缓存目录。默认情况下,Stash将在包含您的配置文件的目录中创建一个cache目录。如果您想更改此设置,请输入绝对或相对(与当前工作目录)路径。如果Stash不存在,它将创建此目录。", "where_can_stash_store_its_database": "在哪里可以储存Stash的数据库?", - "where_can_stash_store_its_database_description": "Stash 使用 sqlite 数据库来存放你的收藏的元数据。默认情况下,会建立stash-go.sqlite在包含有你配置文件的目录里。如果你想改动,请输入一个绝对,或者相对(对于当前目录)的文件名。", + "where_can_stash_store_its_database_description": "Stash 使用 sqlite 数据库来存放你的内容的元数据。默认情况下,会建立stash-go.sqlite在包含有你配置文件的目录里。如果你想改动,请输入一个绝对,或者相对(对于当前目录)的文件名。", "where_can_stash_store_its_database_warning": "警告:不支持将数据库存储在运行 Stash 的不同系统上(例如,在另一台计算机上运行 Stash 服务器时将数据库存储到 NAS 上)!SQLite 不适合在网络上使用,尝试这样做很容易导致整个数据库损坏。", "where_can_stash_store_its_generated_content": "哪里可以存放Stash产生的资料?", "where_can_stash_store_its_generated_content_description": "为了可以提供缩图,预览和浏览图,Stash生成图片和视频。同时也包括将不支持的文件转码后的视频。默认情况下,Stash会建立一个generated文件夹在含有你配置文件的目录中。如果你要修改生成媒体的地方,请输入一个绝对,或者相对(对于当前工作目录)的路径。如果此目录不存在,Stash会自动建立它。", - "where_is_your_porn_located": "你的收藏在哪里?", - "where_is_your_porn_located_description": "添加含有你收藏的视频和图片的目录。Stash会在扫描时使用这些目录去寻找视频和图片。", + "where_is_your_porn_located": "你的内容存放在哪里?", + "where_is_your_porn_located_description": "添加含有你的视频和图片的目录。Stash会在扫描时使用这些目录去寻找视频和图片。", "path_to_blobs_directory_empty_for_default": "blobs目录的路径(默认为空)", - "store_blobs_in_database": "将 blobs存储到数据库" + "store_blobs_in_database": "将 blobs存储到数据库", + "sfw_content_settings": "为了SFW(工作场合安全)内容使用stash?", + "sfw_content_settings_description": "stash能被用来管理SFW(工作场合安全)内容,例如摄影、艺术、漫画等。启用此选项将调整部分界面行为,使其更适合SFW内容。", + "use_sfw_content_mode": "使用SFW(工作场合安全)模式" }, "stash_setup_wizard": "Stash 设定向导", "success": { @@ -1547,5 +1564,10 @@ "invalid_credentials": "无效的用户名或密码", "internal_error": "意外的内部错误。有关更多详细信息,请查看日志", "login": "登录" - } + }, + "scenes_duration": "场景持续时间", + "last_o_at_sfw": "最近一次点赞在", + "o_count_sfw": "点赞", + "o_history_sfw": "点赞历史", + "odate_recorded_no_sfw": "没有已记录的点赞日期" } diff --git a/ui/v2.5/src/locales/zh-TW.json b/ui/v2.5/src/locales/zh-TW.json index 90db9e296..a3fe67cb8 100644 --- a/ui/v2.5/src/locales/zh-TW.json +++ b/ui/v2.5/src/locales/zh-TW.json @@ -149,7 +149,9 @@ }, "show_results": "顯示結果", "show_count_results": "顯示 {count} 筆結果", - "play": "播放" + "play": "播放", + "load": "載入", + "load_filter": "載入篩選結果" }, "actions_name": "動作", "age": "年齡", @@ -803,6 +805,14 @@ "use_stash_hosted_funscript": { "description": "啟用後,funscript 將直接從 Stash 傳送至您的 Handy 裝置,而無需使用第三方 Handy 伺服器。要求可從您的 Handy 裝置存取 Stash,且如果 stash 已設定憑證,則會產生 API 金鑰。", "heading": "直接為 funscript 服務" + }, + "performer_list": { + "options": { + "show_links_on_grid_card": { + "heading": "在表演者卡片上顯示連結" + } + }, + "heading": "表演者清單" } }, "advanced_mode": "進階模式" @@ -1073,7 +1083,7 @@ "syncing": "與伺服器同步中", "uploading": "上傳腳本中" }, - "hasMarkers": "含有章節標記", + "hasMarkers": "章節標記", "height": "身高", "height_cm": "高度 (cm)", "help": "說明", @@ -1119,7 +1129,6 @@ "name": "名稱", "new": "新增", "none": "無", - "o_counter": "尻尻計數", "operations": "動作", "organized": "是否已整理", "pagination": { @@ -1206,7 +1215,8 @@ "saved_filters": "已儲存的過濾條件", "update_filter": "更新篩選", "edit_filter": "編輯篩選器", - "more_filter_criteria": "+{count} 更多" + "more_filter_criteria": "+{count} 更多", + "search_term": "搜尋詞組" }, "seconds": "秒", "settings": "設定", @@ -1252,16 +1262,16 @@ }, "paths": { "database_filename_empty_for_default": "資料庫檔案名稱(留空以使用預設)", - "description": "接下來,我們需要確定可以在哪裡找到你的片片,在哪裡儲存資料庫及其生成檔案等等。如果需要,您稍後可以再更改這些設定。", + "description": "接下來,我們需要確定可以在哪裡找到你的內容,在哪裡儲存資料庫及其生成檔案等等。如果需要,您稍後可以再更改這些設定。", "path_to_generated_directory_empty_for_default": "生成媒體資料夾路徑(留空以使用預設)", "set_up_your_paths": "設定你的路徑", "stash_alert": "您尚未選取任何路徑,Stash 將無法掃描你的檔案。你確定要繼續嗎?", "where_can_stash_store_its_database": "Stash 可以在哪裡儲存資料庫?", - "where_can_stash_store_its_database_description": "Stash 使用 SQLite 資料庫來儲存您片片的資料。預設情況下,Stash 將在您的設定檔路徑下以 stash-go.sqlite 這個檔案來儲存此資料庫內容。如果您想要更改此設定,請在此輸入您所想要的絕對或相對路徑(相對於目前工作目錄)。", + "where_can_stash_store_its_database_description": "Stash 使用 SQLite 資料庫來儲存您內容的資料。預設情況下,Stash 將在您的設定檔路徑下以 stash-go.sqlite 這個檔案來儲存此資料庫內容。如果您想要更改此設定,請在此輸入您所想要的絕對或相對路徑(相對於目前工作目錄)。", "where_can_stash_store_its_generated_content": "Stash 可以在哪裡儲存其生成內容?", "where_can_stash_store_its_generated_content_description": "為提供縮圖、預覽和其他預覽資料,Stash 將自動生成圖片和影片資訊。這包括不支援的檔案格式之轉檔。預設情況下,Stash 將在包含您設定檔案的資料夾中建立一個新的 generated 資料夾。如果要更改此生成媒體的儲存位置,請在此輸入絕對或相對路徑(相對於目前工作目錄)。如果該資料夾不存在,Stash 將自動建立此目錄。", - "where_is_your_porn_located": "你的片片都藏哪?", - "where_is_your_porn_located_description": "在此選擇你A片及圖片的資料夾,Stash 將在掃描影片及圖片時使用這些路徑。", + "where_is_your_porn_located": "你的內容都藏哪?", + "where_is_your_porn_located_description": "在此選擇你視訊及圖片的資料夾,Stash 將在掃描影片及圖片時使用這些路徑。", "path_to_blobs_directory_empty_for_default": "blobs 目錄的路徑 (預設為空)", "path_to_cache_directory_empty_for_default": "快取目錄的路徑 (預設為空)", "store_blobs_in_database": "將 blobs 儲存到資料庫", @@ -1404,7 +1414,7 @@ "group_count": "群組計數", "group_scene_number": "短片編號", "groups": "群組", - "hasChapters": "擁有章節", + "hasChapters": "章節", "history": "歷史紀錄", "image_index": "圖片#", "index_of_total": "第{index}個,共 {total}個", @@ -1544,5 +1554,6 @@ }, "sort_name": "分類名稱", "eta": "預估剩餘時間", - "age_on_date": "在{age}歲時製作" + "age_on_date": "在{age}歲時製作", + "scenes_duration": "場景持續時間" } From d14053b570110fb560d49c934d8004185377788d Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Tue, 25 Nov 2025 17:06:13 -0800 Subject: [PATCH 155/157] Bugfix: Tagger Ignoing Disambiguation When Linking Performer (#6308) --- ui/v2.5/src/components/Tagger/context.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Tagger/context.tsx b/ui/v2.5/src/components/Tagger/context.tsx index 028e83ed0..8557cc94a 100644 --- a/ui/v2.5/src/components/Tagger/context.tsx +++ b/ui/v2.5/src/components/Tagger/context.tsx @@ -593,7 +593,12 @@ export const TaggerContext: React.FC = ({ children }) => { return { ...r, performers: r.performers.map((p) => { - if (p.name === performer.name) { + // Match by remote_site_id if available, otherwise fall back to name + const matches = performer.remote_site_id + ? p.remote_site_id === performer.remote_site_id + : p.name === performer.name; + + if (matches) { return { ...p, stored_id: performerID, From d10995302d99bb2f40894059f91ec56688484859 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Tue, 25 Nov 2025 20:38:19 -0600 Subject: [PATCH 156/157] Feature: Add trash support (#6237) --- graphql/schema/types/config.graphql | 4 + internal/api/resolver_mutation_configure.go | 9 ++ internal/api/resolver_mutation_file.go | 4 +- internal/api/resolver_mutation_gallery.go | 4 +- internal/api/resolver_mutation_image.go | 8 +- internal/api/resolver_mutation_scene.go | 15 ++- internal/api/resolver_query_configuration.go | 1 + internal/manager/config/config.go | 11 +++ internal/manager/manager_tasks.go | 1 + pkg/file/clean.go | 7 +- pkg/file/delete.go | 97 ++++++++++++++++--- pkg/fsutil/trash.go | 43 ++++++++ pkg/image/delete.go | 3 +- pkg/scene/delete.go | 8 +- ui/v2.5/graphql/data/config.graphql | 1 + .../Galleries/DeleteGalleriesDialog.tsx | 7 +- .../components/Images/DeleteImagesDialog.tsx | 7 +- .../components/Scenes/DeleteScenesDialog.tsx | 7 +- .../Settings/SettingsSystemPanel.tsx | 8 ++ .../components/Shared/DeleteFilesDialog.tsx | 11 ++- ui/v2.5/src/locales/en-GB.json | 5 + 21 files changed, 226 insertions(+), 35 deletions(-) create mode 100644 pkg/fsutil/trash.go diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 6a1ac72be..732296572 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -69,6 +69,8 @@ input ConfigGeneralInput { databasePath: String "Path to backup directory" backupDirectoryPath: String + "Path to trash directory - if set, deleted files will be moved here instead of being permanently deleted" + deleteTrashPath: String "Path to generated files" generatedPath: String "Path to import/export files" @@ -191,6 +193,8 @@ type ConfigGeneralResult { databasePath: String! "Path to backup directory" backupDirectoryPath: String! + "Path to trash directory - if set, deleted files will be moved here instead of being permanently deleted" + deleteTrashPath: String! "Path to generated files" generatedPath: String! "Path to import/export files" diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 3299c01a8..d49105916 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -150,6 +150,15 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen c.SetString(config.BackupDirectoryPath, *input.BackupDirectoryPath) } + existingDeleteTrashPath := c.GetDeleteTrashPath() + if input.DeleteTrashPath != nil && existingDeleteTrashPath != *input.DeleteTrashPath { + if err := validateDir(config.DeleteTrashPath, *input.DeleteTrashPath, true); err != nil { + return makeConfigGeneralResult(), err + } + + c.SetString(config.DeleteTrashPath, *input.DeleteTrashPath) + } + existingGeneratedPath := c.GetGeneratedPath() if input.GeneratedPath != nil && existingGeneratedPath != *input.GeneratedPath { if err := validateDir(config.Generated, *input.GeneratedPath, false); err != nil { diff --git a/internal/api/resolver_mutation_file.go b/internal/api/resolver_mutation_file.go index c303446e1..c5e5e3530 100644 --- a/internal/api/resolver_mutation_file.go +++ b/internal/api/resolver_mutation_file.go @@ -149,7 +149,9 @@ func (r *mutationResolver) DeleteFiles(ctx context.Context, ids []string) (ret b return false, fmt.Errorf("converting ids: %w", err) } - fileDeleter := file.NewDeleter() + trashPath := manager.GetInstance().Config.GetDeleteTrashPath() + + fileDeleter := file.NewDeleterWithTrash(trashPath) destroyer := &file.ZipDestroyer{ FileDestroyer: r.repository.File, FolderDestroyer: r.repository.Folder, diff --git a/internal/api/resolver_mutation_gallery.go b/internal/api/resolver_mutation_gallery.go index 5d5cd4b37..db6862274 100644 --- a/internal/api/resolver_mutation_gallery.go +++ b/internal/api/resolver_mutation_gallery.go @@ -333,10 +333,12 @@ func (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.Gall return false, fmt.Errorf("converting ids: %w", err) } + trashPath := manager.GetInstance().Config.GetDeleteTrashPath() + var galleries []*models.Gallery var imgsDestroyed []*models.Image fileDeleter := &image.FileDeleter{ - Deleter: file.NewDeleter(), + Deleter: file.NewDeleterWithTrash(trashPath), Paths: manager.GetInstance().Paths, } diff --git a/internal/api/resolver_mutation_image.go b/internal/api/resolver_mutation_image.go index 721598634..82d9be4cd 100644 --- a/internal/api/resolver_mutation_image.go +++ b/internal/api/resolver_mutation_image.go @@ -308,9 +308,11 @@ func (r *mutationResolver) ImageDestroy(ctx context.Context, input models.ImageD return false, fmt.Errorf("converting id: %w", err) } + trashPath := manager.GetInstance().Config.GetDeleteTrashPath() + var i *models.Image fileDeleter := &image.FileDeleter{ - Deleter: file.NewDeleter(), + Deleter: file.NewDeleterWithTrash(trashPath), Paths: manager.GetInstance().Paths, } if err := r.withTxn(ctx, func(ctx context.Context) error { @@ -348,9 +350,11 @@ func (r *mutationResolver) ImagesDestroy(ctx context.Context, input models.Image return false, fmt.Errorf("converting ids: %w", err) } + trashPath := manager.GetInstance().Config.GetDeleteTrashPath() + var images []*models.Image fileDeleter := &image.FileDeleter{ - Deleter: file.NewDeleter(), + Deleter: file.NewDeleterWithTrash(trashPath), Paths: manager.GetInstance().Paths, } if err := r.withTxn(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index b81ac0974..ae5903112 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -428,10 +428,11 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD } fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm() + trashPath := manager.GetInstance().Config.GetDeleteTrashPath() var s *models.Scene fileDeleter := &scene.FileDeleter{ - Deleter: file.NewDeleter(), + Deleter: file.NewDeleterWithTrash(trashPath), FileNamingAlgo: fileNamingAlgo, Paths: manager.GetInstance().Paths, } @@ -482,9 +483,10 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene var scenes []*models.Scene fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm() + trashPath := manager.GetInstance().Config.GetDeleteTrashPath() fileDeleter := &scene.FileDeleter{ - Deleter: file.NewDeleter(), + Deleter: file.NewDeleterWithTrash(trashPath), FileNamingAlgo: fileNamingAlgo, Paths: manager.GetInstance().Paths, } @@ -593,8 +595,9 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput } mgr := manager.GetInstance() + trashPath := mgr.Config.GetDeleteTrashPath() fileDeleter := &scene.FileDeleter{ - Deleter: file.NewDeleter(), + Deleter: file.NewDeleterWithTrash(trashPath), FileNamingAlgo: mgr.Config.GetVideoFileNamingAlgorithm(), Paths: mgr.Paths, } @@ -736,9 +739,10 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar } mgr := manager.GetInstance() + trashPath := mgr.Config.GetDeleteTrashPath() fileDeleter := &scene.FileDeleter{ - Deleter: file.NewDeleter(), + Deleter: file.NewDeleterWithTrash(trashPath), FileNamingAlgo: mgr.Config.GetVideoFileNamingAlgorithm(), Paths: mgr.Paths, } @@ -949,9 +953,10 @@ func (r *mutationResolver) SceneMarkersDestroy(ctx context.Context, markerIDs [] var markers []*models.SceneMarker fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm() + trashPath := manager.GetInstance().Config.GetDeleteTrashPath() fileDeleter := &scene.FileDeleter{ - Deleter: file.NewDeleter(), + Deleter: file.NewDeleterWithTrash(trashPath), FileNamingAlgo: fileNamingAlgo, Paths: manager.GetInstance().Paths, } diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index 7213f8447..8a20fcad1 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -82,6 +82,7 @@ func makeConfigGeneralResult() *ConfigGeneralResult { Stashes: config.GetStashPaths(), DatabasePath: config.GetDatabasePath(), BackupDirectoryPath: config.GetBackupDirectoryPath(), + DeleteTrashPath: config.GetDeleteTrashPath(), GeneratedPath: config.GetGeneratedPath(), MetadataPath: config.GetMetadataPath(), ConfigFilePath: config.GetConfigFile(), diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index eda863663..c7b1c1fdf 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -272,6 +272,9 @@ const ( DeleteGeneratedDefault = "defaults.delete_generated" deleteGeneratedDefaultDefault = true + // Trash/Recycle Bin options + DeleteTrashPath = "delete_trash_path" + // Desktop Integration Options NoBrowser = "nobrowser" NoBrowserDefault = false @@ -1469,6 +1472,14 @@ func (i *Config) GetDeleteGeneratedDefault() bool { return i.getBoolDefault(DeleteGeneratedDefault, deleteGeneratedDefaultDefault) } +func (i *Config) GetDeleteTrashPath() string { + return i.getString(DeleteTrashPath) +} + +func (i *Config) SetDeleteTrashPath(value string) { + i.SetString(DeleteTrashPath, value) +} + // GetDefaultIdentifySettings returns the default Identify task settings. // Returns nil if the settings could not be unmarshalled, or if it // has not been set. diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index b85a4c2cf..085c4459e 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -294,6 +294,7 @@ func (s *Manager) Clean(ctx context.Context, input CleanMetadataInput) int { Handlers: []file.CleanHandler{ &cleanHandler{}, }, + TrashPath: s.Config.GetDeleteTrashPath(), } j := cleanJob{ diff --git a/pkg/file/clean.go b/pkg/file/clean.go index 8c54fd0e0..53b2e0612 100644 --- a/pkg/file/clean.go +++ b/pkg/file/clean.go @@ -18,7 +18,8 @@ type Cleaner struct { FS models.FS Repository Repository - Handlers []CleanHandler + Handlers []CleanHandler + TrashPath string } type cleanJob struct { @@ -392,7 +393,7 @@ func (j *cleanJob) shouldCleanFolder(ctx context.Context, f *models.Folder) bool func (j *cleanJob) deleteFile(ctx context.Context, fileID models.FileID, fn string) { // delete associated objects - fileDeleter := NewDeleter() + fileDeleter := NewDeleterWithTrash(j.TrashPath) r := j.Repository if err := r.WithTxn(ctx, func(ctx context.Context) error { fileDeleter.RegisterHooks(ctx) @@ -410,7 +411,7 @@ func (j *cleanJob) deleteFile(ctx context.Context, fileID models.FileID, fn stri func (j *cleanJob) deleteFolder(ctx context.Context, folderID models.FolderID, fn string) { // delete associated objects - fileDeleter := NewDeleter() + fileDeleter := NewDeleterWithTrash(j.TrashPath) r := j.Repository if err := r.WithTxn(ctx, func(ctx context.Context) error { fileDeleter.RegisterHooks(ctx) diff --git a/pkg/file/delete.go b/pkg/file/delete.go index 88eb5169e..c36068faa 100644 --- a/pkg/file/delete.go +++ b/pkg/file/delete.go @@ -58,20 +58,33 @@ func newRenamerRemoverImpl() renamerRemoverImpl { // Deleter is used to safely delete files and directories from the filesystem. // During a transaction, files and directories are marked for deletion using -// the Files and Dirs methods. This will rename the files/directories to be -// deleted. If the transaction is rolled back, then the files/directories can -// be restored to their original state with the Abort method. If the -// transaction is committed, the marked files are then deleted from the -// filesystem using the Complete method. +// the Files and Dirs methods. If TrashPath is set, files are moved to trash +// immediately. Otherwise, they are renamed with a .delete suffix. If the +// transaction is rolled back, then the files/directories can be restored to +// their original state with the Rollback method. If the transaction is +// committed, the marked files are then deleted from the filesystem using the +// Commit method. type Deleter struct { RenamerRemover RenamerRemover files []string dirs []string + TrashPath string // if set, files will be moved to this directory instead of being permanently deleted + trashedPaths map[string]string // map of original path -> trash path (only used when TrashPath is set) } func NewDeleter() *Deleter { return &Deleter{ RenamerRemover: newRenamerRemoverImpl(), + TrashPath: "", + trashedPaths: make(map[string]string), + } +} + +func NewDeleterWithTrash(trashPath string) *Deleter { + return &Deleter{ + RenamerRemover: newRenamerRemoverImpl(), + TrashPath: trashPath, + trashedPaths: make(map[string]string), } } @@ -92,6 +105,17 @@ func (d *Deleter) RegisterHooks(ctx context.Context) { // Abort should be called to restore marked files if this function returns an // error. func (d *Deleter) Files(paths []string) error { + return d.filesInternal(paths, false) +} + +// FilesWithoutTrash designates files to be deleted, bypassing the trash directory. +// Files will be permanently deleted even if TrashPath is configured. +// This is useful for deleting generated files that can be easily recreated. +func (d *Deleter) FilesWithoutTrash(paths []string) error { + return d.filesInternal(paths, true) +} + +func (d *Deleter) filesInternal(paths []string, bypassTrash bool) error { for _, p := range paths { // fail silently if the file does not exist if _, err := d.RenamerRemover.Stat(p); err != nil { @@ -103,7 +127,7 @@ func (d *Deleter) Files(paths []string) error { return fmt.Errorf("check file %q exists: %w", p, err) } - if err := d.renameForDelete(p); err != nil { + if err := d.renameForDelete(p, bypassTrash); err != nil { return fmt.Errorf("marking file %q for deletion: %w", p, err) } d.files = append(d.files, p) @@ -118,6 +142,17 @@ func (d *Deleter) Files(paths []string) error { // Abort should be called to restore marked files/directories if this function returns an // error. func (d *Deleter) Dirs(paths []string) error { + return d.dirsInternal(paths, false) +} + +// DirsWithoutTrash designates directories to be deleted, bypassing the trash directory. +// Directories will be permanently deleted even if TrashPath is configured. +// This is useful for deleting generated directories that can be easily recreated. +func (d *Deleter) DirsWithoutTrash(paths []string) error { + return d.dirsInternal(paths, true) +} + +func (d *Deleter) dirsInternal(paths []string, bypassTrash bool) error { for _, p := range paths { // fail silently if the file does not exist if _, err := d.RenamerRemover.Stat(p); err != nil { @@ -129,7 +164,7 @@ func (d *Deleter) Dirs(paths []string) error { return fmt.Errorf("check directory %q exists: %w", p, err) } - if err := d.renameForDelete(p); err != nil { + if err := d.renameForDelete(p, bypassTrash); err != nil { return fmt.Errorf("marking directory %q for deletion: %w", p, err) } d.dirs = append(d.dirs, p) @@ -150,33 +185,65 @@ func (d *Deleter) Rollback() { d.files = nil d.dirs = nil + d.trashedPaths = make(map[string]string) } // Commit deletes all files marked for deletion and clears the marked list. +// When using trash, files have already been moved during renameForDelete, so +// this just clears the tracking. Otherwise, permanently delete the .delete files. // Any errors encountered are logged. All files will be attempted, regardless // of the errors encountered. func (d *Deleter) Commit() { - for _, f := range d.files { - if err := d.RenamerRemover.Remove(f + deleteFileSuffix); err != nil { - logger.Warnf("Error deleting file %q: %v", f+deleteFileSuffix, err) + if d.TrashPath != "" { + // Files were already moved to trash during renameForDelete, just clear tracking + logger.Debugf("Commit: %d files and %d directories already in trash, clearing tracking", len(d.files), len(d.dirs)) + } else { + // Permanently delete files and directories marked with .delete suffix + for _, f := range d.files { + if err := d.RenamerRemover.Remove(f + deleteFileSuffix); err != nil { + logger.Warnf("Error deleting file %q: %v", f+deleteFileSuffix, err) + } } - } - for _, f := range d.dirs { - if err := d.RenamerRemover.RemoveAll(f + deleteFileSuffix); err != nil { - logger.Warnf("Error deleting directory %q: %v", f+deleteFileSuffix, err) + for _, f := range d.dirs { + if err := d.RenamerRemover.RemoveAll(f + deleteFileSuffix); err != nil { + logger.Warnf("Error deleting directory %q: %v", f+deleteFileSuffix, err) + } } } d.files = nil d.dirs = nil + d.trashedPaths = make(map[string]string) } -func (d *Deleter) renameForDelete(path string) error { +func (d *Deleter) renameForDelete(path string, bypassTrash bool) error { + if d.TrashPath != "" && !bypassTrash { + // Move file to trash immediately + trashDest, err := fsutil.MoveToTrash(path, d.TrashPath) + if err != nil { + return err + } + d.trashedPaths[path] = trashDest + logger.Infof("Moved %q to trash at %s", path, trashDest) + return nil + } + + // Standard behavior: rename with .delete suffix (or when bypassing trash) return d.RenamerRemover.Rename(path, path+deleteFileSuffix) } func (d *Deleter) renameForRestore(path string) error { + if d.TrashPath != "" { + // Restore file from trash + trashPath, ok := d.trashedPaths[path] + if !ok { + return fmt.Errorf("no trash path found for %q", path) + } + return d.RenamerRemover.Rename(trashPath, path) + } + + // Standard behavior: restore from .delete suffix return d.RenamerRemover.Rename(path+deleteFileSuffix, path) } diff --git a/pkg/fsutil/trash.go b/pkg/fsutil/trash.go new file mode 100644 index 000000000..9a3bed835 --- /dev/null +++ b/pkg/fsutil/trash.go @@ -0,0 +1,43 @@ +package fsutil + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +// MoveToTrash moves a file or directory to a custom trash directory. +// If a file with the same name already exists in the trash, a timestamp is appended. +// Returns the destination path where the file was moved to. +func MoveToTrash(sourcePath string, trashPath string) (string, error) { + // Get absolute path for the source + absSourcePath, err := filepath.Abs(sourcePath) + if err != nil { + return "", fmt.Errorf("failed to get absolute path: %w", err) + } + + // Ensure trash directory exists + if err := os.MkdirAll(trashPath, 0755); err != nil { + return "", fmt.Errorf("failed to create trash directory: %w", err) + } + + // Get the base name of the file/directory + baseName := filepath.Base(absSourcePath) + destPath := filepath.Join(trashPath, baseName) + + // If a file with the same name already exists in trash, append timestamp + if _, err := os.Stat(destPath); err == nil { + ext := filepath.Ext(baseName) + nameWithoutExt := baseName[:len(baseName)-len(ext)] + timestamp := time.Now().Format("20060102-150405") + destPath = filepath.Join(trashPath, fmt.Sprintf("%s_%s%s", nameWithoutExt, timestamp, ext)) + } + + // Move the file to trash using SafeMove to support cross-filesystem moves + if err := SafeMove(absSourcePath, destPath); err != nil { + return "", fmt.Errorf("failed to move to trash: %w", err) + } + + return destPath, nil +} diff --git a/pkg/image/delete.go b/pkg/image/delete.go index 69fba9bd6..aa3a9c1c8 100644 --- a/pkg/image/delete.go +++ b/pkg/image/delete.go @@ -19,6 +19,7 @@ type FileDeleter struct { } // MarkGeneratedFiles marks for deletion the generated files for the provided image. +// Generated files bypass trash and are permanently deleted since they can be regenerated. func (d *FileDeleter) MarkGeneratedFiles(image *models.Image) error { var files []string thumbPath := d.Paths.Generated.GetThumbnailPath(image.Checksum, models.DefaultGthumbWidth) @@ -32,7 +33,7 @@ func (d *FileDeleter) MarkGeneratedFiles(image *models.Image) error { files = append(files, prevPath) } - return d.Files(files) + return d.FilesWithoutTrash(files) } // Destroy destroys an image, optionally marking the file and generated files for deletion. diff --git a/pkg/scene/delete.go b/pkg/scene/delete.go index 7426c390b..c34bbdf14 100644 --- a/pkg/scene/delete.go +++ b/pkg/scene/delete.go @@ -21,6 +21,7 @@ type FileDeleter struct { } // MarkGeneratedFiles marks for deletion the generated files for the provided scene. +// Generated files bypass trash and are permanently deleted since they can be regenerated. func (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error { sceneHash := scene.GetHash(d.FileNamingAlgo) @@ -32,7 +33,7 @@ func (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error { exists, _ := fsutil.FileExists(markersFolder) if exists { - if err := d.Dirs([]string{markersFolder}); err != nil { + if err := d.DirsWithoutTrash([]string{markersFolder}); err != nil { return err } } @@ -75,11 +76,12 @@ func (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error { files = append(files, heatmapPath) } - return d.Files(files) + return d.FilesWithoutTrash(files) } // MarkMarkerFiles deletes generated files for a scene marker with the // provided scene and timestamp. +// Generated files bypass trash and are permanently deleted since they can be regenerated. func (d *FileDeleter) MarkMarkerFiles(scene *models.Scene, seconds int) error { videoPath := d.Paths.SceneMarkers.GetVideoPreviewPath(scene.GetHash(d.FileNamingAlgo), seconds) imagePath := d.Paths.SceneMarkers.GetWebpPreviewPath(scene.GetHash(d.FileNamingAlgo), seconds) @@ -102,7 +104,7 @@ func (d *FileDeleter) MarkMarkerFiles(scene *models.Scene, seconds int) error { files = append(files, screenshotPath) } - return d.Files(files) + return d.FilesWithoutTrash(files) } // Destroy deletes a scene and its associated relationships from the diff --git a/ui/v2.5/graphql/data/config.graphql b/ui/v2.5/graphql/data/config.graphql index 192fb8053..ac3656efb 100644 --- a/ui/v2.5/graphql/data/config.graphql +++ b/ui/v2.5/graphql/data/config.graphql @@ -6,6 +6,7 @@ fragment ConfigGeneralData on ConfigGeneralResult { } databasePath backupDirectoryPath + deleteTrashPath generatedPath metadataPath scrapersPath diff --git a/ui/v2.5/src/components/Galleries/DeleteGalleriesDialog.tsx b/ui/v2.5/src/components/Galleries/DeleteGalleriesDialog.tsx index 0e50c16b8..35aaea797 100644 --- a/ui/v2.5/src/components/Galleries/DeleteGalleriesDialog.tsx +++ b/ui/v2.5/src/components/Galleries/DeleteGalleriesDialog.tsx @@ -84,6 +84,11 @@ export const DeleteGalleriesDialog: React.FC = ( return; } + const deleteTrashPath = config?.general.deleteTrashPath; + const deleteAlertId = deleteTrashPath + ? "dialogs.delete_alert_to_trash" + : "dialogs.delete_alert"; + return (

@@ -93,7 +98,7 @@ export const DeleteGalleriesDialog: React.FC = ( singularEntity: intl.formatMessage({ id: "file" }), pluralEntity: intl.formatMessage({ id: "files" }), }} - id="dialogs.delete_alert" + id={deleteAlertId} />

    diff --git a/ui/v2.5/src/components/Images/DeleteImagesDialog.tsx b/ui/v2.5/src/components/Images/DeleteImagesDialog.tsx index ec442a5ca..d57c60ab4 100644 --- a/ui/v2.5/src/components/Images/DeleteImagesDialog.tsx +++ b/ui/v2.5/src/components/Images/DeleteImagesDialog.tsx @@ -80,6 +80,11 @@ export const DeleteImagesDialog: React.FC = ( deletedFiles.push(...paths); }); + const deleteTrashPath = config?.general.deleteTrashPath; + const deleteAlertId = deleteTrashPath + ? "dialogs.delete_alert_to_trash" + : "dialogs.delete_alert"; + return (

    @@ -89,7 +94,7 @@ export const DeleteImagesDialog: React.FC = ( singularEntity: intl.formatMessage({ id: "file" }), pluralEntity: intl.formatMessage({ id: "files" }), }} - id="dialogs.delete_alert" + id={deleteAlertId} />

      diff --git a/ui/v2.5/src/components/Scenes/DeleteScenesDialog.tsx b/ui/v2.5/src/components/Scenes/DeleteScenesDialog.tsx index 3cf9b7ecf..56cbd69b0 100644 --- a/ui/v2.5/src/components/Scenes/DeleteScenesDialog.tsx +++ b/ui/v2.5/src/components/Scenes/DeleteScenesDialog.tsx @@ -94,6 +94,11 @@ export const DeleteScenesDialog: React.FC = ( } }); + const deleteTrashPath = config?.general.deleteTrashPath; + const deleteAlertId = deleteTrashPath + ? "dialogs.delete_alert_to_trash" + : "dialogs.delete_alert"; + return (

      @@ -103,7 +108,7 @@ export const DeleteScenesDialog: React.FC = ( singularEntity: intl.formatMessage({ id: "file" }), pluralEntity: intl.formatMessage({ id: "files" }), }} - id="dialogs.delete_alert" + id={deleteAlertId} />

        diff --git a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx index 3baeca1e2..34fb634b2 100644 --- a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx @@ -222,6 +222,14 @@ export const SettingsConfigurationPanel: React.FC = () => { value={general.backupDirectoryPath ?? undefined} onChange={(v) => saveGeneral({ backupDirectoryPath: v })} /> + + saveGeneral({ deleteTrashPath: v })} + /> diff --git a/ui/v2.5/src/components/Shared/DeleteFilesDialog.tsx b/ui/v2.5/src/components/Shared/DeleteFilesDialog.tsx index e7d5af9ac..7d790539b 100644 --- a/ui/v2.5/src/components/Shared/DeleteFilesDialog.tsx +++ b/ui/v2.5/src/components/Shared/DeleteFilesDialog.tsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import { mutateDeleteFiles } from "src/core/StashService"; import { ModalComponent } from "./Modal"; import { useToast } from "src/hooks/Toast"; +import { ConfigurationContext } from "src/hooks/Config"; import { FormattedMessage, useIntl } from "react-intl"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; @@ -40,6 +41,9 @@ export const DeleteFilesDialog: React.FC = ( // Network state const [isDeleting, setIsDeleting] = useState(false); + const context = React.useContext(ConfigurationContext); + const config = context?.configuration; + async function onDelete() { setIsDeleting(true); try { @@ -56,6 +60,11 @@ export const DeleteFilesDialog: React.FC = ( function renderDeleteFileAlert() { const deletedFiles = props.selected.map((f) => f.path); + const deleteTrashPath = config?.general.deleteTrashPath; + const deleteAlertId = deleteTrashPath + ? "dialogs.delete_alert_to_trash" + : "dialogs.delete_alert"; + return (

        @@ -65,7 +74,7 @@ export const DeleteFilesDialog: React.FC = ( singularEntity: intl.formatMessage({ id: "file" }), pluralEntity: intl.formatMessage({ id: "files" }), }} - id="dialogs.delete_alert" + id={deleteAlertId} />

          diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 6a230736a..28d16e486 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -315,6 +315,10 @@ "description": "Directory location for SQLite database file backups", "heading": "Backup Directory Path" }, + "delete_trash_path": { + "description": "Path where deleted files will be moved to instead of being permanently deleted. Leave empty to permanently delete files.", + "heading": "Trash Path" + }, "blobs_path": { "description": "Where in the filesystem to store binary data. Applicable only when using the Filesystem blob storage type. WARNING: changing this requires manually moving existing data.", "heading": "Binary data filesystem path" @@ -907,6 +911,7 @@ "clear_play_history_confirm": "Are you sure you want to clear the play history?", "create_new_entity": "Create new {entity}", "delete_alert": "The following {count, plural, one {{singularEntity}} other {{pluralEntity}}} will be deleted permanently:", + "delete_alert_to_trash": "The following {count, plural, one {{singularEntity}} other {{pluralEntity}}} will be moved to trash:", "delete_confirm": "Are you sure you want to delete {entityName}?", "delete_entity_desc": "{count, plural, one {Are you sure you want to delete this {singularEntity}? Unless the file is also deleted, this {singularEntity} will be re-added when scan is performed.} other {Are you sure you want to delete these {pluralEntity}? Unless the files are also deleted, these {pluralEntity} will be re-added when scan is performed.}}", "delete_entity_simple_desc": "{count, plural, one {Are you sure you want to delete this {singularEntity}?} other {Are you sure you want to delete these {pluralEntity}?}}", From a8bb9ae4d3b84f4dc0cd331a9be8fb14b0a993eb Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Tue, 25 Nov 2025 20:57:15 -0600 Subject: [PATCH 157/157] Show fingerprints when 0 scens (#6316) --- ui/v2.5/src/components/Scenes/SceneList.tsx | 2 +- ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 91c07d484..dc3ea76c6 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -207,7 +207,7 @@ const SceneList: React.FC<{ }> = ({ scenes, filter, selectedIds, onSelectChange, fromGroupId }) => { const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]); - if (scenes.length === 0) { + if (scenes.length === 0 && filter.displayMode !== DisplayMode.Tagger) { return null; } diff --git a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx index 695ed2817..34c86e57c 100755 --- a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx @@ -211,6 +211,10 @@ export const Tagger: React.FC = ({ scenes, queue }) => { return; } + if (scenes.length === 0) { + return; + } + if (loadingMulti) { return (