From f9a624b8037b74a9c7fa06e7e2b57af07eca93aa Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 18 Jun 2024 10:51:52 +1000 Subject: [PATCH 01/28] Default view filters (#4962) * Merge/adapt from yoshnopa:defaultDetails * Deprecate and remove default filter calls * Fix weird behaviour when clicking set as default * Update deprecated get/set default filter resolvers * Add config migration --------- Co-authored-by: yoshnopa --- go.mod | 2 +- graphql/schema/schema.graphql | 2 + .../api/resolver_mutation_saved_filter.go | 59 ++++-- .../api/resolver_query_find_saved_filter.go | 38 +++- pkg/models/saved_filter.go | 2 - pkg/sqlite/database.go | 2 +- .../migrations/60_default_filter_move.up.sql | 2 + pkg/sqlite/migrations/60_postmigrate.go | 176 ++++++++++++++++++ pkg/sqlite/saved_filter.go | 33 ---- pkg/sqlite/saved_filter_test.go | 60 ------ ui/v2.5/graphql/client-schema.graphql | 5 - ui/v2.5/graphql/mutations/filter.graphql | 4 - ui/v2.5/graphql/queries/filter.graphql | 6 - .../src/components/Galleries/Galleries.tsx | 4 +- .../GalleryDetails/GalleryImagesPanel.tsx | 9 +- .../src/components/Galleries/GalleryList.tsx | 13 +- ui/v2.5/src/components/Images/ImageList.tsx | 11 +- ui/v2.5/src/components/Images/Images.tsx | 4 +- ui/v2.5/src/components/List/ItemList.tsx | 100 ++-------- ui/v2.5/src/components/List/ListFilter.tsx | 58 ++---- .../src/components/List/SavedFilterList.tsx | 75 ++++++-- ui/v2.5/src/components/List/styles.scss | 2 + ui/v2.5/src/components/List/util.ts | 32 ++++ ui/v2.5/src/components/List/views.ts | 34 ++++ .../Movies/MovieDetails/MovieScenesPanel.tsx | 2 + ui/v2.5/src/components/Movies/MovieList.tsx | 16 +- ui/v2.5/src/components/Movies/Movies.tsx | 3 +- .../PerformerGalleriesPanel.tsx | 9 +- .../PerformerDetails/PerformerImagesPanel.tsx | 9 +- .../PerformerDetails/PerformerMoviesPanel.tsx | 9 +- .../PerformerDetails/PerformerScenesPanel.tsx | 9 +- .../performerAppearsWithPanel.tsx | 2 + .../components/Performers/PerformerList.tsx | 13 +- .../src/components/Performers/Performers.tsx | 4 +- ui/v2.5/src/components/Scenes/SceneList.tsx | 13 +- .../src/components/Scenes/SceneMarkerList.tsx | 7 +- ui/v2.5/src/components/Scenes/Scenes.tsx | 6 +- .../StudioDetails/StudioChildrenPanel.tsx | 10 +- .../StudioDetails/StudioGalleriesPanel.tsx | 9 +- .../StudioDetails/StudioImagesPanel.tsx | 9 +- .../StudioDetails/StudioMoviesPanel.tsx | 9 +- .../StudioDetails/StudioPerformersPanel.tsx | 2 + .../StudioDetails/StudioScenesPanel.tsx | 9 +- ui/v2.5/src/components/Studios/StudioList.tsx | 11 +- ui/v2.5/src/components/Studios/Studios.tsx | 3 +- .../Tags/TagDetails/TagGalleriesPanel.tsx | 9 +- .../Tags/TagDetails/TagImagesPanel.tsx | 9 +- .../Tags/TagDetails/TagMarkersPanel.tsx | 9 +- .../Tags/TagDetails/TagPerformersPanel.tsx | 9 +- .../Tags/TagDetails/TagScenesPanel.tsx | 9 +- ui/v2.5/src/components/Tags/TagList.tsx | 9 +- ui/v2.5/src/core/StashService.ts | 16 -- ui/v2.5/src/core/config.ts | 13 +- ui/v2.5/src/core/createClient.ts | 3 - ui/v2.5/src/docs/en/MigrationNotes/60.md | 1 + ui/v2.5/src/docs/en/MigrationNotes/index.ts | 2 + ui/v2.5/src/models/list-filter/filter.ts | 15 +- ui/v2.5/src/models/sceneQueue.ts | 7 +- ui/v2.5/src/pluginApi.d.ts | 6 - 59 files changed, 611 insertions(+), 403 deletions(-) create mode 100644 pkg/sqlite/migrations/60_default_filter_move.up.sql create mode 100644 pkg/sqlite/migrations/60_postmigrate.go create mode 100644 ui/v2.5/src/components/List/util.ts create mode 100644 ui/v2.5/src/components/List/views.ts create mode 100644 ui/v2.5/src/docs/en/MigrationNotes/60.md diff --git a/go.mod b/go.mod index 1795a6d34..3056e6a95 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/knadh/koanf v1.5.0 github.com/lucasb-eyer/go-colorful v1.2.0 github.com/mattn/go-sqlite3 v1.14.22 + github.com/mitchellh/mapstructure v1.5.0 github.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/remeh/sizedwaitgroup v1.0.0 @@ -88,7 +89,6 @@ require ( github.com/mattn/go-isatty v0.0.19 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index c5b8d6089..a1f163ecc 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -4,6 +4,7 @@ type Query { findSavedFilter(id: ID!): SavedFilter findSavedFilters(mode: FilterMode): [SavedFilter!]! findDefaultFilter(mode: FilterMode!): SavedFilter + @deprecated(reason: "default filter now stored in UI config") "Find a scene by ID or Checksum" findScene(id: ID, checksum: String): Scene @@ -345,6 +346,7 @@ type Mutation { saveFilter(input: SaveFilterInput!): SavedFilter! destroySavedFilter(input: DestroyFilterInput!): Boolean! setDefaultFilter(input: SetDefaultFilterInput!): Boolean! + @deprecated(reason: "now uses UI config") "Change general configuration options" configureGeneral(input: ConfigGeneralInput!): ConfigGeneralResult! diff --git a/internal/api/resolver_mutation_saved_filter.go b/internal/api/resolver_mutation_saved_filter.go index 13b5d87fa..e49c1214c 100644 --- a/internal/api/resolver_mutation_saved_filter.go +++ b/internal/api/resolver_mutation_saved_filter.go @@ -7,7 +7,10 @@ import ( "strconv" "strings" + "github.com/mitchellh/mapstructure" + "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" ) func (r *mutationResolver) SaveFilter(ctx context.Context, input SaveFilterInput) (ret *models.SavedFilter, err error) { @@ -67,30 +70,48 @@ func (r *mutationResolver) DestroySavedFilter(ctx context.Context, input Destroy } func (r *mutationResolver) SetDefaultFilter(ctx context.Context, input SetDefaultFilterInput) (bool, error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { - qb := r.repository.SavedFilter + // deprecated - write to the config in the meantime + config := config.GetInstance() - if input.FindFilter == nil && input.ObjectFilter == nil && input.UIOptions == nil { - // clearing - def, err := qb.FindDefault(ctx, input.Mode) - if err != nil { - return err - } + uiConfig := config.GetUIConfiguration() + if uiConfig == nil { + uiConfig = make(map[string]interface{}) + } - if def != nil { - return qb.Destroy(ctx, def.ID) - } + m := utils.NestedMap(uiConfig) - return nil + if input.FindFilter == nil && input.ObjectFilter == nil && input.UIOptions == nil { + // clearing + m.Delete("defaultFilters." + strings.ToLower(input.Mode.String())) + config.SetUIConfiguration(m) + + if err := config.Write(); err != nil { + return false, err } - return qb.SetDefault(ctx, &models.SavedFilter{ - Mode: input.Mode, - FindFilter: input.FindFilter, - ObjectFilter: input.ObjectFilter, - UIOptions: input.UIOptions, - }) - }); err != nil { + return true, nil + } + + subMap := make(map[string]interface{}) + d, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + TagName: "json", + WeaklyTypedInput: true, + Result: &subMap, + }) + + if err != nil { + return false, err + } + + if err := d.Decode(input); err != nil { + return false, err + } + + m.Set("defaultFilters."+strings.ToLower(input.Mode.String()), subMap) + + config.SetUIConfiguration(m) + + if err := config.Write(); err != nil { return false, err } diff --git a/internal/api/resolver_query_find_saved_filter.go b/internal/api/resolver_query_find_saved_filter.go index 4f196fd65..1ba68e31d 100644 --- a/internal/api/resolver_query_find_saved_filter.go +++ b/internal/api/resolver_query_find_saved_filter.go @@ -3,8 +3,12 @@ package api import ( "context" "strconv" + "strings" + "github.com/mitchellh/mapstructure" + "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" ) func (r *queryResolver) FindSavedFilter(ctx context.Context, id string) (ret *models.SavedFilter, err error) { @@ -37,11 +41,35 @@ func (r *queryResolver) FindSavedFilters(ctx context.Context, mode *models.Filte } func (r *queryResolver) FindDefaultFilter(ctx context.Context, mode models.FilterMode) (ret *models.SavedFilter, err error) { - if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = r.repository.SavedFilter.FindDefault(ctx, mode) - return err - }); err != nil { + // deprecated - read from the config in the meantime + config := config.GetInstance() + + uiConfig := config.GetUIConfiguration() + if uiConfig == nil { + return nil, nil + } + + m := utils.NestedMap(uiConfig) + filterRaw, _ := m.Get("defaultFilters." + strings.ToLower(mode.String())) + + if filterRaw == nil { + return nil, nil + } + + ret = &models.SavedFilter{} + d, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + TagName: "json", + WeaklyTypedInput: true, + Result: ret, + }) + + if err != nil { return nil, err } - return ret, err + + if err := d.Decode(filterRaw); err != nil { + return nil, err + } + + return ret, nil } diff --git a/pkg/models/saved_filter.go b/pkg/models/saved_filter.go index a8e4f20c3..919f0a1a6 100644 --- a/pkg/models/saved_filter.go +++ b/pkg/models/saved_filter.go @@ -7,13 +7,11 @@ type SavedFilterReader interface { Find(ctx context.Context, id int) (*SavedFilter, error) FindMany(ctx context.Context, ids []int, ignoreNotFound bool) ([]*SavedFilter, error) FindByMode(ctx context.Context, mode FilterMode) ([]*SavedFilter, error) - FindDefault(ctx context.Context, mode FilterMode) (*SavedFilter, error) } type SavedFilterWriter interface { Create(ctx context.Context, obj *SavedFilter) error Update(ctx context.Context, obj *SavedFilter) error - SetDefault(ctx context.Context, obj *SavedFilter) error Destroy(ctx context.Context, id int) error } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 3475e955a..7303400a3 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -30,7 +30,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 59 +var appSchemaVersion uint = 60 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/60_default_filter_move.up.sql b/pkg/sqlite/migrations/60_default_filter_move.up.sql new file mode 100644 index 000000000..2c6f6e1fc --- /dev/null +++ b/pkg/sqlite/migrations/60_default_filter_move.up.sql @@ -0,0 +1,2 @@ +-- no schema changes +-- default filters will be removed in post-migration \ No newline at end of file diff --git a/pkg/sqlite/migrations/60_postmigrate.go b/pkg/sqlite/migrations/60_postmigrate.go new file mode 100644 index 000000000..dfed33f18 --- /dev/null +++ b/pkg/sqlite/migrations/60_postmigrate.go @@ -0,0 +1,176 @@ +package migrations + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/jmoiron/sqlx" + "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/sqlite" +) + +type schema60Migrator struct { + migrator +} + +func post60(ctx context.Context, db *sqlx.DB) error { + logger.Info("Running post-migration for schema version 60") + + m := schema60Migrator{ + migrator: migrator{ + db: db, + }, + } + + return m.migrate(ctx) +} + +func (m *schema60Migrator) decodeJSON(s string, v interface{}) { + if s == "" { + return + } + + if err := json.Unmarshal([]byte(s), v); err != nil { + logger.Errorf("error decoding json %q: %v", s, err) + } +} + +type schema60DefaultFilters map[string]interface{} + +func (m *schema60Migrator) migrate(ctx context.Context) error { + + // save default filters into the UI config + if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { + query := "SELECT id, mode, find_filter, object_filter, ui_options FROM `saved_filters` WHERE `name` = ''" + + rows, err := m.db.Query(query) + if err != nil { + return err + } + defer rows.Close() + + defaultFilters := make(schema60DefaultFilters) + + for rows.Next() { + var ( + id int + mode string + findFilterStr string + objectFilterStr string + uiOptionsStr string + ) + + if err := rows.Scan(&id, &mode, &findFilterStr, &objectFilterStr, &uiOptionsStr); err != nil { + return err + } + + // convert the filters to the correct format + findFilter := make(map[string]interface{}) + objectFilter := make(map[string]interface{}) + uiOptions := make(map[string]interface{}) + + m.decodeJSON(findFilterStr, &findFilter) + m.decodeJSON(objectFilterStr, &objectFilter) + m.decodeJSON(uiOptionsStr, &uiOptions) + + o := map[string]interface{}{ + "mode": mode, + "find_filter": findFilter, + "object_filter": objectFilter, + "ui_options": uiOptions, + } + + defaultFilters[strings.ToLower(mode)] = o + } + + if err := rows.Err(); err != nil { + return err + } + + if err := m.saveDefaultFilters(defaultFilters); err != nil { + return fmt.Errorf("saving default filters: %w", err) + } + + // remove the default filters from the database + query = "DELETE FROM `saved_filters` WHERE `name` = ''" + if _, err := m.db.Exec(query); err != nil { + return fmt.Errorf("deleting default filters: %w", err) + } + + return nil + }); err != nil { + return err + } + + return nil +} + +func (m *schema60Migrator) saveDefaultFilters(defaultFilters schema60DefaultFilters) error { + if len(defaultFilters) == 0 { + logger.Debugf("no default filters to save") + return nil + } + + // save the default filters into the UI config + config := config.GetInstance() + + orgPath := config.GetConfigFile() + + if orgPath == "" { + // no config file to migrate (usually in a test or new system) + logger.Debugf("no config file to migrate") + return nil + } + + uiConfig := config.GetUIConfiguration() + if uiConfig == nil { + uiConfig = make(map[string]interface{}) + } + + // if the defaultFilters key already exists, don't overwrite them + if _, found := uiConfig["defaultFilters"]; found { + logger.Warn("defaultFilters already exists in the UI config, skipping migration") + return nil + } + + if err := m.backupConfig(orgPath); err != nil { + return fmt.Errorf("backing up config: %w", err) + } + + uiConfig["defaultFilters"] = map[string]interface{}(defaultFilters) + config.SetUIConfiguration(uiConfig) + + if err := config.Write(); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + + return nil +} + +func (m *schema60Migrator) backupConfig(orgPath string) error { + c := config.GetInstance() + + // save a backup of the original config file + backupPath := fmt.Sprintf("%s.59.%s", orgPath, time.Now().Format("20060102_150405")) + + data, err := c.Marshal() + if err != nil { + return fmt.Errorf("failed to marshal backup config: %w", err) + } + + logger.Infof("Backing up config to %s", backupPath) + if err := os.WriteFile(backupPath, data, 0644); err != nil { + return fmt.Errorf("failed to write backup config: %w", err) + } + + return nil +} + +func init() { + sqlite.RegisterPostMigration(60, post60) +} diff --git a/pkg/sqlite/saved_filter.go b/pkg/sqlite/saved_filter.go index e4369bda5..49b1f45ed 100644 --- a/pkg/sqlite/saved_filter.go +++ b/pkg/sqlite/saved_filter.go @@ -141,23 +141,6 @@ func (qb *SavedFilterStore) Update(ctx context.Context, updatedObject *models.Sa return nil } -func (qb *SavedFilterStore) SetDefault(ctx context.Context, obj *models.SavedFilter) error { - // find the existing default - existing, err := qb.FindDefault(ctx, obj.Mode) - if err != nil { - return err - } - - obj.Name = savedFilterDefaultName - - if existing != nil { - obj.ID = existing.ID - return qb.Update(ctx, obj) - } - - return qb.Create(ctx, obj) -} - func (qb *SavedFilterStore) Destroy(ctx context.Context, id int) error { return qb.destroyExisting(ctx, []int{id}) } @@ -258,22 +241,6 @@ func (qb *SavedFilterStore) FindByMode(ctx context.Context, mode models.FilterMo return ret, nil } -func (qb *SavedFilterStore) FindDefault(ctx context.Context, mode models.FilterMode) (*models.SavedFilter, error) { - // SELECT * FROM saved_filters WHERE mode = ? AND name = ? - table := qb.table() - sq := qb.selectDataset().Prepared(true).Where( - table.Col("mode").Eq(mode), - table.Col("name").Eq(savedFilterDefaultName), - ) - - ret, err := qb.get(ctx, sq) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return nil, err - } - - return ret, nil -} - func (qb *SavedFilterStore) All(ctx context.Context) ([]*models.SavedFilter, error) { return qb.getMany(ctx, qb.selectDataset()) } diff --git a/pkg/sqlite/saved_filter_test.go b/pkg/sqlite/saved_filter_test.go index aa98121fd..60592a923 100644 --- a/pkg/sqlite/saved_filter_test.go +++ b/pkg/sqlite/saved_filter_test.go @@ -96,66 +96,6 @@ func TestSavedFilterDestroy(t *testing.T) { }) } -func TestSavedFilterFindDefault(t *testing.T) { - withTxn(func(ctx context.Context) error { - def, err := db.SavedFilter.FindDefault(ctx, models.FilterModeScenes) - if err == nil { - assert.Equal(t, savedFilterIDs[savedFilterIdxDefaultScene], def.ID) - } - - return err - }) -} - -func TestSavedFilterSetDefault(t *testing.T) { - filterQ := "" - filterPage := 1 - filterPerPage := 40 - filterSort := "date" - filterDirection := models.SortDirectionEnumAsc - findFilter := models.FindFilterType{ - Q: &filterQ, - Page: &filterPage, - PerPage: &filterPerPage, - Sort: &filterSort, - Direction: &filterDirection, - } - objectFilter := map[string]interface{}{ - "test": "foo", - } - uiOptions := map[string]interface{}{ - "display_mode": 1, - "zoom_index": 1, - } - - withTxn(func(ctx context.Context) error { - err := db.SavedFilter.SetDefault(ctx, &models.SavedFilter{ - Mode: models.FilterModeMovies, - FindFilter: &findFilter, - ObjectFilter: objectFilter, - UIOptions: uiOptions, - }) - - return err - }) - - var defID int - withTxn(func(ctx context.Context) error { - def, err := db.SavedFilter.FindDefault(ctx, models.FilterModeMovies) - if err == nil { - defID = def.ID - assert.Equal(t, &findFilter, def.FindFilter) - } - - return err - }) - - // destroy it again - withTxn(func(ctx context.Context) error { - return db.SavedFilter.Destroy(ctx, defID) - }) -} - // TODO Update // TODO Destroy // TODO Find diff --git a/ui/v2.5/graphql/client-schema.graphql b/ui/v2.5/graphql/client-schema.graphql index 92f53cc3a..57eff3343 100644 --- a/ui/v2.5/graphql/client-schema.graphql +++ b/ui/v2.5/graphql/client-schema.graphql @@ -16,11 +16,6 @@ extend input SaveFilterInput { ui_options: SavedUIOptions } -extend input SetDefaultFilterInput { - object_filter: SavedObjectFilter - ui_options: SavedUIOptions -} - extend type Mutation { configureUI(input: Map, partial: Map): UIConfig! } diff --git a/ui/v2.5/graphql/mutations/filter.graphql b/ui/v2.5/graphql/mutations/filter.graphql index 5d8013123..68a6403a1 100644 --- a/ui/v2.5/graphql/mutations/filter.graphql +++ b/ui/v2.5/graphql/mutations/filter.graphql @@ -7,7 +7,3 @@ mutation SaveFilter($input: SaveFilterInput!) { mutation DestroySavedFilter($input: DestroyFilterInput!) { destroySavedFilter(input: $input) } - -mutation SetDefaultFilter($input: SetDefaultFilterInput!) { - setDefaultFilter(input: $input) -} diff --git a/ui/v2.5/graphql/queries/filter.graphql b/ui/v2.5/graphql/queries/filter.graphql index 67fbaf6cf..276c22d75 100644 --- a/ui/v2.5/graphql/queries/filter.graphql +++ b/ui/v2.5/graphql/queries/filter.graphql @@ -9,9 +9,3 @@ query FindSavedFilters($mode: FilterMode) { ...SavedFilterData } } - -query FindDefaultFilter($mode: FilterMode!) { - findDefaultFilter(mode: $mode) { - ...SavedFilterData - } -} diff --git a/ui/v2.5/src/components/Galleries/Galleries.tsx b/ui/v2.5/src/components/Galleries/Galleries.tsx index d61f124ac..cc2e84ff7 100644 --- a/ui/v2.5/src/components/Galleries/Galleries.tsx +++ b/ui/v2.5/src/components/Galleries/Galleries.tsx @@ -2,16 +2,16 @@ import React from "react"; import { Route, Switch } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; -import { PersistanceLevel } from "../List/ItemList"; import Gallery from "./GalleryDetails/Gallery"; import GalleryCreate from "./GalleryDetails/GalleryCreate"; import { GalleryList } from "./GalleryList"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; +import { View } from "../List/views"; const Galleries: React.FC = () => { useScrollToTopOnMount(); - return ; + return ; }; const GalleryRoutes: React.FC = () => { diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx index c0c539fa9..ea28ffabf 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx @@ -4,14 +4,12 @@ import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries"; import { ListFilterModel } from "src/models/list-filter/filter"; import { ImageList } from "src/components/Images/ImageList"; import { mutateRemoveGalleryImages } from "src/core/StashService"; -import { - showWhenSelected, - PersistanceLevel, -} from "src/components/List/ItemList"; +import { showWhenSelected } from "src/components/List/ItemList"; import { useToast } from "src/hooks/Toast"; import { useIntl } from "react-intl"; import { faMinus } from "@fortawesome/free-solid-svg-icons"; import { galleryTitle } from "src/core/galleries"; +import { View } from "src/components/List/views"; interface IGalleryDetailsProps { active: boolean; @@ -102,8 +100,7 @@ export const GalleryImagesPanel: React.FC = ({ filterHook={filterHook} alterQuery={active} extraOperations={otherOperations} - persistState={PersistanceLevel.VIEW} - persistanceKey="galleryimages" + view={View.GalleryImages} chapters={gallery.chapters} /> ); diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index 3ef5acf1f..ba6334e33 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -4,11 +4,7 @@ import cloneDeep from "lodash-es/cloneDeep"; import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; -import { - makeItemList, - PersistanceLevel, - showWhenSelected, -} from "../List/ItemList"; +import { makeItemList, showWhenSelected } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { queryFindGalleries, useFindGalleries } from "src/core/StashService"; @@ -18,6 +14,7 @@ import { DeleteGalleriesDialog } from "./DeleteGalleriesDialog"; import { ExportDialog } from "../Shared/ExportDialog"; import { GalleryListTable } from "./GalleryListTable"; import { GalleryCardGrid } from "./GalleryGridCard"; +import { View } from "../List/views"; const GalleryItemList = makeItemList({ filterMode: GQL.FilterMode.Galleries, @@ -32,13 +29,13 @@ const GalleryItemList = makeItemList({ interface IGalleryList { filterHook?: (filter: ListFilterModel) => ListFilterModel; - persistState?: PersistanceLevel; + view?: View; alterQuery?: boolean; } export const GalleryList: React.FC = ({ filterHook, - persistState, + view, alterQuery, }) => { const intl = useIntl(); @@ -192,7 +189,7 @@ export const GalleryList: React.FC = ({ zoomable selectable filterHook={filterHook} - persistState={persistState} + view={view} alterQuery={alterQuery} otherOperations={otherOperations} addKeybinds={addKeybinds} diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index c0339a44c..3000195d9 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -14,7 +14,6 @@ import { queryFindImages, useFindImages } from "src/core/StashService"; import { makeItemList, IItemListOperation, - PersistanceLevel, showWhenSelected, } from "../List/ItemList"; import { useLightbox } from "src/hooks/Lightbox/hooks"; @@ -31,6 +30,7 @@ import { objectTitle } from "src/core/files"; import TextUtils from "src/utils/text"; import { ConfigurationContext } from "src/hooks/Config"; import { ImageGridCard } from "./ImageGridCard"; +import { View } from "../List/views"; interface IImageWallProps { images: GQL.SlimImageDataFragment[]; @@ -270,8 +270,7 @@ const ImageItemList = makeItemList({ interface IImageList { filterHook?: (filter: ListFilterModel) => ListFilterModel; - persistState?: PersistanceLevel; - persistanceKey?: string; + view?: View; alterQuery?: boolean; extraOperations?: IItemListOperation[]; chapters?: GQL.GalleryChapterDataFragment[]; @@ -279,8 +278,7 @@ interface IImageList { export const ImageList: React.FC = ({ filterHook, - persistState, - persistanceKey, + view, alterQuery, extraOperations, chapters = [], @@ -421,8 +419,7 @@ export const ImageList: React.FC = ({ zoomable selectable filterHook={filterHook} - persistState={persistState} - persistanceKey={persistanceKey} + view={view} alterQuery={alterQuery} otherOperations={otherOperations} addKeybinds={addKeybinds} diff --git a/ui/v2.5/src/components/Images/Images.tsx b/ui/v2.5/src/components/Images/Images.tsx index 90c6858b0..c0a6b67c8 100644 --- a/ui/v2.5/src/components/Images/Images.tsx +++ b/ui/v2.5/src/components/Images/Images.tsx @@ -2,15 +2,15 @@ import React from "react"; import { Route, Switch } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; -import { PersistanceLevel } from "../List/ItemList"; import Image from "./ImageDetails/Image"; import { ImageList } from "./ImageList"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; +import { View } from "../List/views"; const Images: React.FC = () => { useScrollToTopOnMount(); - return ; + return ; }; const ImageRoutes: React.FC = () => { diff --git a/ui/v2.5/src/components/List/ItemList.tsx b/ui/v2.5/src/components/List/ItemList.tsx index 7b91844c8..5ffe97d4e 100644 --- a/ui/v2.5/src/components/List/ItemList.tsx +++ b/ui/v2.5/src/components/List/ItemList.tsx @@ -19,11 +19,9 @@ import { } from "src/models/list-filter/criteria/criterion"; import { ListFilterModel } from "src/models/list-filter/filter"; import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; -import { useInterfaceLocalForage } from "src/hooks/LocalForage"; import { useHistory, useLocation } from "react-router-dom"; import { ConfigurationContext } from "src/hooks/Config"; import { getFilterOptions } from "src/models/list-filter/factory"; -import { useFindDefaultFilter } from "src/core/StashService"; import { Pagination, PaginationIndex } from "./Pagination"; import { EditFilterDialog } from "src/components/List/EditFilterDialog"; import { ListFilter } from "./ListFilter"; @@ -33,15 +31,8 @@ import { ListOperationButtons } from "./ListOperationButtons"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { DisplayMode } from "src/models/list-filter/types"; import { ButtonToolbar } from "react-bootstrap"; - -export enum PersistanceLevel { - // do not load default query or persist display mode - NONE, - // load default query, don't load or persist display mode - ALL, - // load and persist display mode only - VIEW, -} +import { View } from "./views"; +import { useDefaultFilter } from "./util"; interface IDataItem { id: string; @@ -79,8 +70,7 @@ interface IRenderListProps { } interface IItemListProps { - persistState?: PersistanceLevel; - persistanceKey?: string; + view?: View; defaultSort?: string; filterHook?: (filter: ListFilterModel) => ListFilterModel; filterDialog?: ( @@ -140,7 +130,7 @@ export function makeItemList({ filterHook, onChangePage: _onChangePage, updateFilter, - persistState, + view, zoomable, selectable, otherOperations, @@ -480,7 +470,7 @@ export function makeItemList({ filter={filter} filterOptions={filterOptions} openFilterDialog={() => setShowEditFilter(true)} - persistState={persistState} + view={view} /> ({ const ItemList: React.FC> = (props) => { const { - persistState, - persistanceKey = filterMode, + view, defaultSort = filterOptions.defaultSortBy, defaultZoomIndex, alterQuery = true, @@ -540,7 +529,6 @@ export function makeItemList({ const history = useHistory(); const location = useLocation(); - const [interfaceState, setInterfaceState] = useInterfaceLocalForage(); const [filterInitialised, setFilterInitialised] = useState(false); const { configuration: config } = useContext(ConfigurationContext); @@ -550,35 +538,11 @@ export function makeItemList({ () => new ListFilterModel(filterMode) ); - const updateSavedFilter = useCallback( - (updatedFilter: ListFilterModel) => { - setInterfaceState((prevState) => { - if (!prevState.queryConfig) { - prevState.queryConfig = {}; - } - - const oldFilter = prevState.queryConfig[persistanceKey]?.filter ?? ""; - const newFilter = new URLSearchParams(oldFilter); - newFilter.set("disp", String(updatedFilter.displayMode)); - - return { - ...prevState, - queryConfig: { - ...prevState.queryConfig, - [persistanceKey]: { - ...prevState.queryConfig[persistanceKey], - filter: newFilter.toString(), - }, - }, - }; - }); - }, - [persistanceKey, setInterfaceState] + const { defaultFilter, loading: defaultFilterLoading } = useDefaultFilter( + filterMode, + view ); - const { data: defaultFilter, loading: defaultFilterLoading } = - useFindDefaultFilter(filterMode); - const updateQueryParams = useCallback( (newFilter: ListFilterModel) => { if (!alterQuery) return; @@ -593,11 +557,8 @@ export function makeItemList({ (newFilter: ListFilterModel) => { setFilter(newFilter); updateQueryParams(newFilter); - if (persistState === PersistanceLevel.VIEW) { - updateSavedFilter(newFilter); - } }, - [persistState, updateSavedFilter, updateQueryParams] + [updateQueryParams] ); // 'Startup' hook, initialises the filters @@ -605,53 +566,28 @@ export function makeItemList({ // Only run once if (filterInitialised) return; - let newFilter = new ListFilterModel( - filterMode, - config, - defaultSort, - defaultDisplayMode, - defaultZoomIndex - ); + let newFilter = new ListFilterModel(filterMode, config, defaultZoomIndex); let loadDefault = true; if (alterQuery && location.search) { loadDefault = false; newFilter.configureFromQueryString(location.search); } - if (persistState === PersistanceLevel.ALL) { + if (view) { // only set default filter if uninitialised if (loadDefault) { // wait until default filter is loaded if (defaultFilterLoading) return; - if (defaultFilter?.findDefaultFilter) { - newFilter.currentPage = 1; - try { - newFilter.configureFromSavedFilter( - defaultFilter.findDefaultFilter - ); - } catch (err) { - console.log(err); - // ignore - } + if (defaultFilter) { + newFilter = defaultFilter.clone(); + // #1507 - reset random seed when loaded newFilter.randomSeed = -1; } } - } else if (persistState === PersistanceLevel.VIEW) { - // wait until forage is initialised - if (interfaceState.loading) return; - - const storedQuery = interfaceState.data?.queryConfig?.[persistanceKey]; - if (persistState === PersistanceLevel.VIEW && storedQuery) { - const displayMode = new URLSearchParams(storedQuery.filter).get( - "disp" - ); - if (displayMode) { - newFilter.displayMode = Number.parseInt(displayMode, 10); - } - } } + setFilter(newFilter); updateQueryParams(newFilter); @@ -664,12 +600,10 @@ export function makeItemList({ defaultDisplayMode, defaultZoomIndex, alterQuery, - persistState, + view, updateQueryParams, defaultFilter, defaultFilterLoading, - interfaceState, - persistanceKey, ]); // This hook runs on every page location change (ie navigation), diff --git a/ui/v2.5/src/components/List/ListFilter.tsx b/ui/v2.5/src/components/List/ListFilter.tsx index b7023bfb9..ccfd32409 100644 --- a/ui/v2.5/src/components/List/ListFilter.tsx +++ b/ui/v2.5/src/components/List/ListFilter.tsx @@ -1,11 +1,5 @@ import cloneDeep from "lodash-es/cloneDeep"; -import React, { - HTMLAttributes, - useCallback, - useEffect, - useRef, - useState, -} from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import cx from "classnames"; import Mousetrap from "mousetrap"; import { SortDirectionEnum } from "src/core/generated-graphql"; @@ -27,10 +21,8 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import useFocus from "src/utils/focus"; import { ListFilterOptions } from "src/models/list-filter/filter-options"; import { FormattedMessage, useIntl } from "react-intl"; -import { PersistanceLevel } from "./ItemList"; -import { SavedFilterList } from "./SavedFilterList"; +import { SavedFilterDropdown } from "./SavedFilterList"; import { - faBookmark, faCaretDown, faCaretUp, faCheck, @@ -39,12 +31,13 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { FilterButton } from "./Filters/FilterButton"; import { useDebounce } from "src/hooks/debounce"; +import { View } from "./views"; interface IListFilterProps { onFilterUpdate: (newFilter: ListFilterModel) => void; filter: ListFilterModel; filterOptions: ListFilterOptions; - persistState?: PersistanceLevel; + view?: View; openFilterDialog: () => void; } @@ -55,7 +48,7 @@ export const ListFilter: React.FC = ({ filter, filterOptions, openFilterDialog, - persistState, + view, }) => { const [customPageSizeShowing, setCustomPageSizeShowing] = useState(false); const [queryRef, setQueryFocus] = useFocus(); @@ -191,22 +184,6 @@ export const ListFilter: React.FC = ({ )); } - const SavedFilterDropdown = React.forwardRef< - HTMLDivElement, - HTMLAttributes - >(({ style, className }: HTMLAttributes, ref) => ( -
- { - onFilterUpdate(f); - }} - persistState={persistState} - /> -
- )); - SavedFilterDropdown.displayName = "SavedFilterDropdown"; - function render() { const currentSortBy = filterOptions.sortByOptions.find( (o) => o.value === filter.sortBy @@ -257,24 +234,13 @@ export const ListFilter: React.FC = ({ - - - - - } - > - - - - - - + { + onFilterUpdate(f); + }} + view={view} + /> void; - persistState?: PersistanceLevel; + view?: View; } export const SavedFilterList: React.FC = ({ filter, onSetFilter, - persistState, + view, }) => { const Toast = useToast(); const intl = useIntl(); @@ -51,7 +51,7 @@ export const SavedFilterList: React.FC = ({ const [saveFilter] = useSaveFilter(); const [destroyFilter] = useSavedFilterDestroy(); - const [setDefaultFilter] = useSetDefaultFilter(); + const [saveUI] = useConfigureUI(); const savedFilters = data?.findSavedFilters ?? []; @@ -127,18 +127,26 @@ export const SavedFilterList: React.FC = ({ } async function onSetDefaultFilter() { + if (!view) { + return; + } + const filterCopy = filter.clone(); try { setSaving(true); - await setDefaultFilter({ + await saveUI({ variables: { - input: { - mode: filter.mode, - find_filter: filterCopy.makeFindFilter(), - object_filter: filterCopy.makeSavedFilter(), - ui_options: filterCopy.makeSavedUIOptions(), + partial: { + defaultFilters: { + [view.toString()]: { + mode: filter.mode, + find_filter: filterCopy.makeFindFilter(), + object_filter: filterCopy.makeSavedFilter(), + ui_options: filterCopy.makeSavedUIOptions(), + }, + }, }, }, }); @@ -302,17 +310,19 @@ export const SavedFilterList: React.FC = ({ } function maybeRenderSetDefaultButton() { - if (persistState === PersistanceLevel.ALL) { + if (view) { return (
- +
); } @@ -357,3 +367,36 @@ export const SavedFilterList: React.FC = ({ ); }; + +export const SavedFilterDropdown: React.FC = (props) => { + const SavedFilterDropdownRef = React.forwardRef< + HTMLDivElement, + HTMLAttributes + >(({ style, className }: HTMLAttributes, ref) => ( +
+ +
+ )); + SavedFilterDropdownRef.displayName = "SavedFilterDropdown"; + + return ( + + + + + } + > + + + + + + + ); +}; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 1c8d993a3..5b1e3b845 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -86,6 +86,8 @@ input[type="range"].zoom-slider { .set-as-default-button { float: right; margin-right: 0.5rem; + padding: 0.25rem 0.5rem; + width: auto; } .LoadingIndicator { diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts new file mode 100644 index 000000000..7ebe679b9 --- /dev/null +++ b/ui/v2.5/src/components/List/util.ts @@ -0,0 +1,32 @@ +import { useContext, useMemo } from "react"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import * as GQL from "src/core/generated-graphql"; +import { ConfigurationContext } from "src/hooks/Config"; +import { View } from "./views"; + +export function useDefaultFilter(mode: GQL.FilterMode, view?: View) { + const emptyFilter = useMemo(() => new ListFilterModel(mode), [mode]); + const { configuration: config, loading } = useContext(ConfigurationContext); + + const defaultFilter = useMemo(() => { + if (view && config?.ui.defaultFilters?.[view]) { + const savedFilter = config.ui.defaultFilters[view]!; + const newFilter = emptyFilter.clone(); + + newFilter.currentPage = 1; + try { + newFilter.configureFromSavedFilter(savedFilter); + } catch (err) { + console.log(err); + // ignore + } + // #1507 - reset random seed when loaded + newFilter.randomSeed = -1; + return newFilter; + } + }, [view, config?.ui.defaultFilters, emptyFilter]); + + const retFilter = loading ? undefined : defaultFilter ?? emptyFilter; + + return { defaultFilter: retFilter, loading }; +} diff --git a/ui/v2.5/src/components/List/views.ts b/ui/v2.5/src/components/List/views.ts new file mode 100644 index 000000000..7e8880f9d --- /dev/null +++ b/ui/v2.5/src/components/List/views.ts @@ -0,0 +1,34 @@ +export enum View { + Galleries = "galleries", + Images = "images", + Scenes = "scenes", + Movies = "movies", + Performers = "performers", + Tags = "tags", + SceneMarkers = "scene_markers", + Studios = "studios", + + TagMarkers = "tag_markers", + TagGalleries = "tag_galleries", + TagScenes = "tag_scenes", + TagImages = "tag_images", + TagPerformers = "tag_performers", + + PerformerScenes = "performer_scenes", + PerformerGalleries = "performer_galleries", + PerformerImages = "performer_images", + PerformerMovies = "performer_movies", + PerformerAppearsWith = "performer_appears_with", + + StudioGalleries = "studio_galleries", + StudioImages = "studio_images", + + GalleryImages = "gallery_images", + + StudioScenes = "studio_scenes", + StudioMovies = "studio_movies", + StudioPerformers = "studio_performers", + StudioChildren = "studio_children", + + MovieScenes = "movie_scenes", +} diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx index 9bfbf8b55..0ceff9b47 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx @@ -3,6 +3,7 @@ import * as GQL from "src/core/generated-graphql"; import { MoviesCriterion } from "src/models/list-filter/criteria/movies"; import { ListFilterModel } from "src/models/list-filter/filter"; import { SceneList } from "src/components/Scenes/SceneList"; +import { View } from "src/components/List/views"; interface IMovieScenesPanel { active: boolean; @@ -51,6 +52,7 @@ export const MovieScenesPanel: React.FC = ({ filterHook={filterHook} defaultSort="movie_scene_number" alterQuery={active} + view={View.MovieScenes} /> ); } diff --git a/ui/v2.5/src/components/Movies/MovieList.tsx b/ui/v2.5/src/components/Movies/MovieList.tsx index 55b34a783..8b42a3b73 100644 --- a/ui/v2.5/src/components/Movies/MovieList.tsx +++ b/ui/v2.5/src/components/Movies/MovieList.tsx @@ -11,15 +11,12 @@ import { useFindMovies, useMoviesDestroy, } from "src/core/StashService"; -import { - makeItemList, - PersistanceLevel, - showWhenSelected, -} from "../List/ItemList"; +import { makeItemList, showWhenSelected } from "../List/ItemList"; import { ExportDialog } from "../Shared/ExportDialog"; import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; import { MovieCardGrid } from "./MovieCardGrid"; import { EditMoviesDialog } from "./EditMoviesDialog"; +import { View } from "../List/views"; const MovieItemList = makeItemList({ filterMode: GQL.FilterMode.Movies, @@ -34,10 +31,15 @@ const MovieItemList = makeItemList({ interface IMovieList { filterHook?: (filter: ListFilterModel) => ListFilterModel; + view?: View; alterQuery?: boolean; } -export const MovieList: React.FC = ({ filterHook, alterQuery }) => { +export const MovieList: React.FC = ({ + filterHook, + alterQuery, + view, +}) => { const intl = useIntl(); const history = useHistory(); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); @@ -175,7 +177,7 @@ export const MovieList: React.FC = ({ filterHook, alterQuery }) => { { useScrollToTopOnMount(); - return ; + return ; }; const MovieRoutes: React.FC = () => { diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx index f1ea3db2c..4424ff740 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { GalleryList } from "src/components/Galleries/GalleryList"; import { usePerformerFilterHook } from "src/core/performers"; +import { View } from "src/components/List/views"; interface IPerformerDetailsProps { active: boolean; @@ -13,5 +14,11 @@ export const PerformerGalleriesPanel: React.FC = ({ performer, }) => { const filterHook = usePerformerFilterHook(performer); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx index 478f7027f..40c2d88b8 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { ImageList } from "src/components/Images/ImageList"; import { usePerformerFilterHook } from "src/core/performers"; +import { View } from "src/components/List/views"; interface IPerformerImagesPanel { active: boolean; @@ -13,5 +14,11 @@ export const PerformerImagesPanel: React.FC = ({ performer, }) => { const filterHook = usePerformerFilterHook(performer); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx index 4c417bac8..0f1c8b7d5 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { MovieList } from "src/components/Movies/MovieList"; import { usePerformerFilterHook } from "src/core/performers"; +import { View } from "src/components/List/views"; interface IPerformerDetailsProps { active: boolean; @@ -13,5 +14,11 @@ export const PerformerMoviesPanel: React.FC = ({ performer, }) => { const filterHook = usePerformerFilterHook(performer); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx index d05db77c4..5eca04f6c 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { SceneList } from "src/components/Scenes/SceneList"; import { usePerformerFilterHook } from "src/core/performers"; +import { View } from "src/components/List/views"; interface IPerformerDetailsProps { active: boolean; @@ -13,5 +14,11 @@ export const PerformerScenesPanel: React.FC = ({ performer, }) => { const filterHook = usePerformerFilterHook(performer); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx index a05ec5e9f..8806bd3ab 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { PerformerList } from "src/components/Performers/PerformerList"; import { usePerformerFilterHook } from "src/core/performers"; +import { View } from "src/components/List/views"; interface IPerformerDetailsProps { active: boolean; @@ -28,6 +29,7 @@ export const PerformerAppearsWithPanel: React.FC = ({ filterHook={filterHook} extraCriteria={extraCriteria} alterQuery={active} + view={View.PerformerAppearsWith} /> ); }; diff --git a/ui/v2.5/src/components/Performers/PerformerList.tsx b/ui/v2.5/src/components/Performers/PerformerList.tsx index 257079bce..9dd0ff277 100644 --- a/ui/v2.5/src/components/Performers/PerformerList.tsx +++ b/ui/v2.5/src/components/Performers/PerformerList.tsx @@ -9,11 +9,7 @@ import { useFindPerformers, usePerformersDestroy, } from "src/core/StashService"; -import { - makeItemList, - PersistanceLevel, - showWhenSelected, -} from "../List/ItemList"; +import { makeItemList, showWhenSelected } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { PerformerTagger } from "../Tagger/performers/PerformerTagger"; @@ -25,6 +21,7 @@ import { EditPerformersDialog } from "./EditPerformersDialog"; import { cmToImperial, cmToInches, kgToLbs } from "src/utils/units"; import TextUtils from "src/utils/text"; import { PerformerCardGrid } from "./PerformerCardGrid"; +import { View } from "../List/views"; const PerformerItemList = makeItemList({ filterMode: GQL.FilterMode.Performers, @@ -162,14 +159,14 @@ export const FormatPenisLength = (penis_length?: number | null) => { interface IPerformerList { filterHook?: (filter: ListFilterModel) => ListFilterModel; - persistState?: PersistanceLevel; + view?: View; alterQuery?: boolean; extraCriteria?: IPerformerCardExtraCriteria; } export const PerformerList: React.FC = ({ filterHook, - persistState, + view, alterQuery, extraCriteria, }) => { @@ -325,7 +322,7 @@ export const PerformerList: React.FC = ({ { useScrollToTopOnMount(); - return ; + return ; }; const PerformerRoutes: React.FC = () => { diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 89c52f429..4ffd25040 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -5,11 +5,7 @@ import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { queryFindScenes, useFindScenes } from "src/core/StashService"; -import { - makeItemList, - PersistanceLevel, - showWhenSelected, -} from "../List/ItemList"; +import { makeItemList, showWhenSelected } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { Tagger } from "../Tagger/scenes/SceneTagger"; @@ -28,6 +24,7 @@ import { faPlay } from "@fortawesome/free-solid-svg-icons"; import { SceneMergeModal } from "./SceneMergeDialog"; import { objectTitle } from "src/core/files"; import TextUtils from "src/utils/text"; +import { View } from "../List/views"; const SceneItemList = makeItemList({ filterMode: GQL.FilterMode.Scenes, @@ -78,14 +75,14 @@ const SceneItemList = makeItemList({ interface ISceneList { filterHook?: (filter: ListFilterModel) => ListFilterModel; defaultSort?: string; - persistState?: PersistanceLevel; + view?: View; alterQuery?: boolean; } export const SceneList: React.FC = ({ filterHook, defaultSort, - persistState, + view, alterQuery, }) => { const intl = useIntl(); @@ -357,7 +354,7 @@ export const SceneList: React.FC = ({ zoomable selectable filterHook={filterHook} - persistState={persistState} + view={view} alterQuery={alterQuery} otherOperations={otherOperations} addKeybinds={addKeybinds} diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index 6de661671..b81a4aecf 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -9,10 +9,11 @@ import { useFindSceneMarkers, } from "src/core/StashService"; import NavUtils from "src/utils/navigation"; -import { makeItemList, PersistanceLevel } from "../List/ItemList"; +import { makeItemList } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { MarkerWallPanel } from "../Wall/WallPanel"; +import { View } from "../List/views"; const SceneMarkerItemList = makeItemList({ filterMode: GQL.FilterMode.SceneMarkers, @@ -27,11 +28,13 @@ const SceneMarkerItemList = makeItemList({ interface ISceneMarkerList { filterHook?: (filter: ListFilterModel) => ListFilterModel; + view?: View; alterQuery?: boolean; } export const SceneMarkerList: React.FC = ({ filterHook, + view, alterQuery, }) => { const intl = useIntl(); @@ -96,7 +99,7 @@ export const SceneMarkerList: React.FC = ({ return ( import("./SceneList")); const SceneMarkerList = lazyComponent(() => import("./SceneMarkerList")); @@ -14,7 +14,7 @@ const SceneCreate = lazyComponent(() => import("./SceneDetails/SceneCreate")); const Scenes: React.FC = () => { useScrollToTopOnMount(); - return ; + return ; }; const SceneMarkers: React.FC = () => { @@ -24,7 +24,7 @@ const SceneMarkers: React.FC = () => { return ( <> - + ); }; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx index 8048de66a..62d773a51 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx @@ -3,6 +3,7 @@ import * as GQL from "src/core/generated-graphql"; import { ParentStudiosCriterion } from "src/models/list-filter/criteria/studios"; import { ListFilterModel } from "src/models/list-filter/filter"; import { StudioList } from "../StudioList"; +import { View } from "src/components/List/views"; interface IStudioChildrenPanel { active: boolean; @@ -45,5 +46,12 @@ export const StudioChildrenPanel: React.FC = ({ return filter; } - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx index 42aca05dc..2519d41a3 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { GalleryList } from "src/components/Galleries/GalleryList"; import { useStudioFilterHook } from "src/core/studios"; +import { View } from "src/components/List/views"; interface IStudioGalleriesPanel { active: boolean; @@ -13,5 +14,11 @@ export const StudioGalleriesPanel: React.FC = ({ studio, }) => { const filterHook = useStudioFilterHook(studio); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx index c2ecb4e0f..3e6f66f25 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useStudioFilterHook } from "src/core/studios"; import { ImageList } from "src/components/Images/ImageList"; +import { View } from "src/components/List/views"; interface IStudioImagesPanel { active: boolean; @@ -13,5 +14,11 @@ export const StudioImagesPanel: React.FC = ({ studio, }) => { const filterHook = useStudioFilterHook(studio); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioMoviesPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioMoviesPanel.tsx index 93a820242..3127f5251 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioMoviesPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioMoviesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { MovieList } from "src/components/Movies/MovieList"; import { useStudioFilterHook } from "src/core/studios"; +import { View } from "src/components/List/views"; interface IStudioMoviesPanel { active: boolean; @@ -13,5 +14,11 @@ export const StudioMoviesPanel: React.FC = ({ studio, }) => { const filterHook = useStudioFilterHook(studio); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx index e13c8b2ec..0eb146a1b 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx @@ -3,6 +3,7 @@ import * as GQL from "src/core/generated-graphql"; import { useStudioFilterHook } from "src/core/studios"; import { PerformerList } from "src/components/Performers/PerformerList"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; +import { View } from "src/components/List/views"; interface IStudioPerformersPanel { active: boolean; @@ -34,6 +35,7 @@ export const StudioPerformersPanel: React.FC = ({ filterHook={filterHook} extraCriteria={extraCriteria} alterQuery={active} + view={View.StudioPerformers} /> ); }; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioScenesPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioScenesPanel.tsx index 84ba8751d..47663f696 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioScenesPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioScenesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { SceneList } from "src/components/Scenes/SceneList"; import { useStudioFilterHook } from "src/core/studios"; +import { View } from "src/components/List/views"; interface IStudioScenesPanel { active: boolean; @@ -13,5 +14,11 @@ export const StudioScenesPanel: React.FC = ({ studio, }) => { const filterHook = useStudioFilterHook(studio); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Studios/StudioList.tsx b/ui/v2.5/src/components/Studios/StudioList.tsx index 4e75c6405..952a6a6ee 100644 --- a/ui/v2.5/src/components/Studios/StudioList.tsx +++ b/ui/v2.5/src/components/Studios/StudioList.tsx @@ -9,17 +9,14 @@ import { useFindStudios, useStudiosDestroy, } from "src/core/StashService"; -import { - makeItemList, - PersistanceLevel, - showWhenSelected, -} from "../List/ItemList"; +import { makeItemList, showWhenSelected } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { ExportDialog } from "../Shared/ExportDialog"; import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; import { StudioTagger } from "../Tagger/studios/StudioTagger"; import { StudioCardGrid } from "./StudioCardGrid"; +import { View } from "../List/views"; const StudioItemList = makeItemList({ filterMode: GQL.FilterMode.Studios, @@ -35,12 +32,14 @@ const StudioItemList = makeItemList({ interface IStudioList { fromParent?: boolean; filterHook?: (filter: ListFilterModel) => ListFilterModel; + view?: View; alterQuery?: boolean; } export const StudioList: React.FC = ({ fromParent, filterHook, + view, alterQuery, }) => { const intl = useIntl(); @@ -181,7 +180,7 @@ export const StudioList: React.FC = ({ { useScrollToTopOnMount(); - return ; + return ; }; const StudioRoutes: React.FC = () => { diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx index 203715bfb..7d46c4e31 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useTagFilterHook } from "src/core/tags"; import { GalleryList } from "src/components/Galleries/GalleryList"; +import { View } from "src/components/List/views"; interface ITagGalleriesPanel { active: boolean; @@ -13,5 +14,11 @@ export const TagGalleriesPanel: React.FC = ({ tag, }) => { const filterHook = useTagFilterHook(tag); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx index 1c6ea2ec9..61e235499 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useTagFilterHook } from "src/core/tags"; import { ImageList } from "src/components/Images/ImageList"; +import { View } from "src/components/List/views"; interface ITagImagesPanel { active: boolean; @@ -10,5 +11,11 @@ interface ITagImagesPanel { export const TagImagesPanel: React.FC = ({ active, tag }) => { const filterHook = useTagFilterHook(tag); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx index 95d2d5607..2bd4658d5 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx @@ -6,6 +6,7 @@ import { TagsCriterionOption, } from "src/models/list-filter/criteria/tags"; import { SceneMarkerList } from "src/components/Scenes/SceneMarkerList"; +import { View } from "src/components/List/views"; interface ITagMarkersPanel { active: boolean; @@ -52,5 +53,11 @@ export const TagMarkersPanel: React.FC = ({ return filter; } - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagPerformersPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagPerformersPanel.tsx index 255acaf64..192b89dcc 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagPerformersPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagPerformersPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useTagFilterHook } from "src/core/tags"; import { PerformerList } from "src/components/Performers/PerformerList"; +import { View } from "src/components/List/views"; interface ITagPerformersPanel { active: boolean; @@ -13,5 +14,11 @@ export const TagPerformersPanel: React.FC = ({ tag, }) => { const filterHook = useTagFilterHook(tag); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagScenesPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagScenesPanel.tsx index 5de9ed916..39d850d35 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagScenesPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagScenesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { SceneList } from "src/components/Scenes/SceneList"; import { useTagFilterHook } from "src/core/tags"; +import { View } from "src/components/List/views"; interface ITagScenesPanel { active: boolean; @@ -10,5 +11,11 @@ interface ITagScenesPanel { export const TagScenesPanel: React.FC = ({ active, tag }) => { const filterHook = useTagFilterHook(tag); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index 2458a273b..42a6316f9 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -3,11 +3,7 @@ import cloneDeep from "lodash-es/cloneDeep"; import Mousetrap from "mousetrap"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; -import { - makeItemList, - PersistanceLevel, - showWhenSelected, -} from "../List/ItemList"; +import { makeItemList, showWhenSelected } from "../List/ItemList"; import { Button } from "react-bootstrap"; import { Link, useHistory } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; @@ -29,6 +25,7 @@ import { tagRelationHook } from "../../core/tags"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; import { TagCardGrid } from "./TagCardGrid"; import { EditTagsDialog } from "./EditTagsDialog"; +import { View } from "../List/views"; interface ITagList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -363,7 +360,7 @@ export const TagList: React.FC = ({ filterHook, alterQuery }) => { zoomable defaultZoomIndex={0} filterHook={filterHook} - persistState={PersistanceLevel.ALL} + view={View.Tags} alterQuery={alterQuery} otherOperations={otherOperations} addKeybinds={addKeybinds} diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 251df72f5..d7518b6c7 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -452,11 +452,6 @@ export const useFindSavedFilters = (mode?: GQL.FilterMode) => variables: { mode }, }); -export const useFindDefaultFilter = (mode: GQL.FilterMode) => - GQL.useFindDefaultFilterQuery({ - variables: { mode }, - }); - /// Object Mutations // Increases/decreases the given field of the Stats query by diff @@ -1956,15 +1951,6 @@ export const useSaveFilter = () => }, }); -export const useSetDefaultFilter = () => - GQL.useSetDefaultFilterMutation({ - update(cache, result) { - if (!result.data?.setDefaultFilter) return; - - evictQueries(cache, [GQL.FindDefaultFilterDocument]); - }, - }); - export const useSavedFilterDestroy = () => GQL.useDestroySavedFilterMutation({ update(cache, result, { variables }) { @@ -1972,8 +1958,6 @@ export const useSavedFilterDestroy = () => const obj = { __typename: "SavedFilter", id: variables.input.id }; deleteObject(cache, obj, GQL.FindSavedFilterDocument); - - evictQueries(cache, [GQL.FindDefaultFilterDocument]); }, }); diff --git a/ui/v2.5/src/core/config.ts b/ui/v2.5/src/core/config.ts index 1e0d1030a..3e7585df7 100644 --- a/ui/v2.5/src/core/config.ts +++ b/ui/v2.5/src/core/config.ts @@ -2,7 +2,12 @@ import { IntlShape } from "react-intl"; import { ITypename } from "src/utils/data"; import { ImageWallOptions } from "src/utils/imageWall"; import { RatingSystemOptions } from "src/utils/rating"; -import { FilterMode, SortDirectionEnum } from "./generated-graphql"; +import { + FilterMode, + SavedFilterDataFragment, + SortDirectionEnum, +} from "./generated-graphql"; +import { View } from "src/components/List/views"; // NOTE: double capitals aren't converted correctly in the backend @@ -25,6 +30,10 @@ export interface ICustomFilter extends ITypename { direction: SortDirectionEnum; } +export type DefaultFilters = { + [P in View]?: SavedFilterDataFragment; +}; + export type FrontPageContent = ISavedFilterRow | ICustomFilter; export const defaultMaxOptionsShown = 200; @@ -86,6 +95,8 @@ export interface IUIConfig { advancedMode?: boolean; taskDefaults?: Record; + + defaultFilters?: DefaultFilters; } export function getFrontPageContent( diff --git a/ui/v2.5/src/core/createClient.ts b/ui/v2.5/src/core/createClient.ts index 045f27676..e5f502cca 100644 --- a/ui/v2.5/src/core/createClient.ts +++ b/ui/v2.5/src/core/createClient.ts @@ -61,9 +61,6 @@ const typePolicies: TypePolicies = { findSavedFilter: { read: readReference("SavedFilter"), }, - findDefaultFilter: { - read: readDanglingNull, - }, }, }, Scene: { diff --git a/ui/v2.5/src/docs/en/MigrationNotes/60.md b/ui/v2.5/src/docs/en/MigrationNotes/60.md new file mode 100644 index 000000000..5d2feaf0e --- /dev/null +++ b/ui/v2.5/src/docs/en/MigrationNotes/60.md @@ -0,0 +1 @@ +This migration moves default filters from the database into the configuration file. A backup of the current `config.yml` will be created in the same directory with the name `config.yml.59.`. The exact filename is written to the log. \ No newline at end of file diff --git a/ui/v2.5/src/docs/en/MigrationNotes/index.ts b/ui/v2.5/src/docs/en/MigrationNotes/index.ts index dd4f3fcd1..5a201a091 100644 --- a/ui/v2.5/src/docs/en/MigrationNotes/index.ts +++ b/ui/v2.5/src/docs/en/MigrationNotes/index.ts @@ -2,10 +2,12 @@ import migration32 from "./32.md"; import migration39 from "./39.md"; import migration48 from "./48.md"; import migration58 from "./58.md"; +import migration60 from "./60.md"; export const migrationNotes: Record = { 32: migration32, 39: migration39, 48: migration48, 58: migration58, + 60: migration60, }; diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index cd84eaada..794fd2a7e 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -17,6 +17,7 @@ import { SavedObjectFilter, SavedUIOptions, } from "./types"; +import { ListFilterOptions } from "./filter-options"; interface IDecodedParams { perPage?: number; @@ -49,7 +50,8 @@ const DEFAULT_PARAMS = { // TODO: handle customCriteria export class ListFilterModel { - public mode: FilterMode; + public readonly mode: FilterMode; + public readonly options: ListFilterOptions; private config?: ConfigDataFragment; public searchTerm: string = ""; public currentPage = DEFAULT_PARAMS.currentPage; @@ -65,19 +67,18 @@ export class ListFilterModel { public constructor( mode: FilterMode, config?: ConfigDataFragment, - defaultSort?: string, - defaultDisplayMode?: DisplayMode, defaultZoomIndex?: number ) { this.mode = mode; this.config = config; - this.sortBy = defaultSort; + this.options = getFilterOptions(mode); + const { defaultSortBy, displayModeOptions } = this.options; + + this.sortBy = defaultSortBy; if (this.sortBy === "date") { this.sortDirection = SortDirectionEnum.Desc; } - if (defaultDisplayMode !== undefined) { - this.displayMode = defaultDisplayMode; - } + this.displayMode = displayModeOptions[0]; if (defaultZoomIndex !== undefined) { this.defaultZoomIndex = defaultZoomIndex; this.zoomIndex = defaultZoomIndex; diff --git a/ui/v2.5/src/models/sceneQueue.ts b/ui/v2.5/src/models/sceneQueue.ts index 83b7e7820..ceec69090 100644 --- a/ui/v2.5/src/models/sceneQueue.ts +++ b/ui/v2.5/src/models/sceneQueue.ts @@ -1,6 +1,5 @@ import { FilterMode, Scene } from "src/core/generated-graphql"; import { ListFilterModel } from "./list-filter/filter"; -import { SceneListFilterOptions } from "./list-filter/scenes"; import { INamedObject } from "src/utils/navigation"; export type QueuedScene = Pick & { @@ -97,11 +96,7 @@ export class SceneQueue { c: params.getAll("qfc"), }; const decoded = ListFilterModel.decodeParams(translated); - const query = new ListFilterModel( - FilterMode.Scenes, - undefined, - SceneListFilterOptions.defaultSortBy - ); + const query = new ListFilterModel(FilterMode.Scenes); query.configureFromDecodedParams(decoded); ret.query = query; } else if (params.has("qs")) { diff --git a/ui/v2.5/src/pluginApi.d.ts b/ui/v2.5/src/pluginApi.d.ts index 4967eedbf..ae292ceee 100644 --- a/ui/v2.5/src/pluginApi.d.ts +++ b/ui/v2.5/src/pluginApi.d.ts @@ -39,7 +39,6 @@ declare namespace PluginApi { const EnableDlnaDocument: { [key: string]: any }; const ExportObjectsDocument: { [key: string]: any }; const FilterMode: { [key: string]: any }; - const FindDefaultFilterDocument: { [key: string]: any }; const FindDuplicateScenesDocument: { [key: string]: any }; const FindGalleriesDocument: { [key: string]: any }; const FindGalleriesForSelectDocument: { [key: string]: any }; @@ -208,7 +207,6 @@ declare namespace PluginApi { const SelectPerformerDataFragmentDoc: { [key: string]: any }; const SelectStudioDataFragmentDoc: { [key: string]: any }; const SelectTagDataFragmentDoc: { [key: string]: any }; - const SetDefaultFilterDocument: { [key: string]: any }; const SetPluginsEnabledDocument: { [key: string]: any }; const SetupDocument: { [key: string]: any }; const SlimGalleryDataFragmentDoc: { [key: string]: any }; @@ -254,7 +252,6 @@ declare namespace PluginApi { function refetchConfigurationQuery(...args: any[]): any; function refetchDirectoryQuery(...args: any[]): any; function refetchDlnaStatusQuery(...args: any[]): any; - function refetchFindDefaultFilterQuery(...args: any[]): any; function refetchFindDuplicateScenesQuery(...args: any[]): any; function refetchFindGalleriesForSelectQuery(...args: any[]): any; function refetchFindGalleriesQuery(...args: any[]): any; @@ -349,9 +346,6 @@ declare namespace PluginApi { function useDlnaStatusSuspenseQuery(...args: any[]): any; function useEnableDlnaMutation(...args: any[]): any; function useExportObjectsMutation(...args: any[]): any; - function useFindDefaultFilterLazyQuery(...args: any[]): any; - function useFindDefaultFilterQuery(...args: any[]): any; - function useFindDefaultFilterSuspenseQuery(...args: any[]): any; function useFindDuplicateScenesLazyQuery(...args: any[]): any; function useFindDuplicateScenesQuery(...args: any[]): any; function useFindDuplicateScenesSuspenseQuery(...args: any[]): any; From fda4776d30f2b97c736bbe4b83f6d8340f60942d Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 18 Jun 2024 11:24:15 +1000 Subject: [PATCH 02/28] Movie/Group tags (#4969) * Combine common tag control code into hook * Combine common scraped tag row code into hook --- graphql/schema/types/filters.graphql | 7 + graphql/schema/types/movie.graphql | 4 + graphql/schema/types/scraped-movie.graphql | 2 + graphql/schema/types/tag.graphql | 1 + internal/api/resolver_model_movie.go | 14 + internal/api/resolver_model_tag.go | 12 + internal/api/resolver_mutation_movie.go | 16 + internal/api/resolver_query_scraper.go | 26 +- internal/manager/task_export.go | 9 + internal/manager/task_import.go | 1 + pkg/models/jsonschema/movie.go | 1 + pkg/models/mocks/MovieReaderWriter.go | 23 + pkg/models/mocks/TagReaderWriter.go | 23 + pkg/models/model_movie.go | 16 +- pkg/models/model_scraped_item.go | 1 + pkg/models/movie.go | 4 + pkg/models/repository_movie.go | 1 + pkg/models/repository_tag.go | 1 + pkg/models/tag.go | 2 + pkg/movie/import.go | 77 +++ pkg/movie/import_test.go | 98 ++++ pkg/movie/query.go | 12 + pkg/scraper/mapped.go | 21 +- pkg/scraper/postprocessing.go | 23 +- pkg/sqlite/database.go | 2 +- pkg/sqlite/migrations/61_movie_tags.up.sql | 10 + pkg/sqlite/movies.go | 33 ++ pkg/sqlite/movies_filter.go | 27 ++ pkg/sqlite/movies_test.go | 449 +++++++++++++++++- pkg/sqlite/relationships.go | 41 ++ pkg/sqlite/setup_test.go | 28 +- pkg/sqlite/table.go | 11 + pkg/sqlite/tables.go | 11 + pkg/sqlite/tag.go | 29 ++ pkg/sqlite/tag_filter.go | 12 + pkg/sqlite/tag_test.go | 31 ++ ui/v2.5/graphql/data/movie.graphql | 4 + ui/v2.5/graphql/data/scrapers.graphql | 6 + ui/v2.5/graphql/data/tag.graphql | 2 + .../GalleryDetails/GalleryEditPanel.tsx | 49 +- .../GalleryDetails/GalleryScrapeDialog.tsx | 34 +- .../Images/ImageDetails/ImageEditPanel.tsx | 30 +- .../components/Movies/EditMoviesDialog.tsx | 34 ++ ui/v2.5/src/components/Movies/MovieCard.tsx | 69 ++- .../components/Movies/MovieDetails/Movie.tsx | 1 + .../Movies/MovieDetails/MovieDetailsPanel.tsx | 37 +- .../Movies/MovieDetails/MovieEditPanel.tsx | 16 + .../Movies/MovieDetails/MovieScrapeDialog.tsx | 72 +-- .../PerformerDetails/PerformerEditPanel.tsx | 130 +---- .../PerformerScrapeDialog.tsx | 41 +- .../Scenes/SceneDetails/SceneEditPanel.tsx | 49 +- .../Scenes/SceneDetails/SceneScrapeDialog.tsx | 34 +- .../Shared/ScrapeDialog/scrapedTags.tsx | 53 +++ ui/v2.5/src/components/Shared/TagLink.tsx | 4 +- ui/v2.5/src/components/Tags/TagCard.tsx | 14 + .../src/components/Tags/TagDetails/Tag.tsx | 30 +- .../Tags/TagDetails/TagMoviesPanel.tsx | 12 + ui/v2.5/src/hooks/tagsEdit.tsx | 148 ++++++ ui/v2.5/src/locales/en-GB.json | 1 + ui/v2.5/src/models/list-filter/movies.ts | 13 +- ui/v2.5/src/models/list-filter/tags.ts | 5 + ui/v2.5/src/models/list-filter/types.ts | 1 + ui/v2.5/src/utils/navigation.ts | 68 +-- 63 files changed, 1586 insertions(+), 450 deletions(-) create mode 100644 pkg/sqlite/migrations/61_movie_tags.up.sql create mode 100644 pkg/sqlite/relationships.go create mode 100644 ui/v2.5/src/components/Shared/ScrapeDialog/scrapedTags.tsx create mode 100644 ui/v2.5/src/components/Tags/TagDetails/TagMoviesPanel.tsx create mode 100644 ui/v2.5/src/hooks/tagsEdit.tsx diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 92127416f..1df9d2fba 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -334,6 +334,10 @@ input MovieFilterType { url: StringCriterionInput "Filter to only include movies where performer appears in a scene" performers: MultiCriterionInput + "Filter to only include movies with these tags" + tags: HierarchicalMultiCriterionInput + "Filter by tag count" + tag_count: IntCriterionInput "Filter by date" date: DateCriterionInput "Filter by creation time" @@ -494,6 +498,9 @@ input TagFilterType { "Filter by number of performers with this tag" performer_count: IntCriterionInput + "Filter by number of movies with this tag" + movie_count: IntCriterionInput + "Filter by number of markers with this tag" marker_count: IntCriterionInput diff --git a/graphql/schema/types/movie.graphql b/graphql/schema/types/movie.graphql index 8501d8833..0723bcc4f 100644 --- a/graphql/schema/types/movie.graphql +++ b/graphql/schema/types/movie.graphql @@ -12,6 +12,7 @@ type Movie { synopsis: String url: String @deprecated(reason: "Use urls") urls: [String!]! + tags: [Tag!]! created_at: Time! updated_at: Time! @@ -34,6 +35,7 @@ input MovieCreateInput { synopsis: String url: String @deprecated(reason: "Use urls") urls: [String!] + tag_ids: [ID!] "This should be a URL or a base64 encoded data URL" front_image: String "This should be a URL or a base64 encoded data URL" @@ -53,6 +55,7 @@ input MovieUpdateInput { synopsis: String url: String @deprecated(reason: "Use urls") urls: [String!] + tag_ids: [ID!] "This should be a URL or a base64 encoded data URL" front_image: String "This should be a URL or a base64 encoded data URL" @@ -67,6 +70,7 @@ input BulkMovieUpdateInput { studio_id: ID director: String urls: BulkUpdateStrings + tag_ids: BulkUpdateIds } input MovieDestroyInput { diff --git a/graphql/schema/types/scraped-movie.graphql b/graphql/schema/types/scraped-movie.graphql index f45903cce..5b07a222c 100644 --- a/graphql/schema/types/scraped-movie.graphql +++ b/graphql/schema/types/scraped-movie.graphql @@ -11,6 +11,7 @@ type ScrapedMovie { urls: [String!] synopsis: String studio: ScrapedStudio + tags: [ScrapedTag!] "This should be a base64 encoded data URL" front_image: String @@ -28,4 +29,5 @@ input ScrapedMovieInput { url: String @deprecated(reason: "use urls") urls: [String!] synopsis: String + # not including tags for the input } diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index 6438b52e1..35229c5cb 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -13,6 +13,7 @@ type Tag { image_count(depth: Int): Int! # Resolver gallery_count(depth: Int): Int! # Resolver performer_count(depth: Int): Int! # Resolver + movie_count(depth: Int): Int! # Resolver parents: [Tag!]! children: [Tag!]! diff --git a/internal/api/resolver_model_movie.go b/internal/api/resolver_model_movie.go index 630b7d2a0..d1509c7a1 100644 --- a/internal/api/resolver_model_movie.go +++ b/internal/api/resolver_model_movie.go @@ -57,6 +57,20 @@ func (r *movieResolver) Studio(ctx context.Context, obj *models.Movie) (ret *mod return loaders.From(ctx).StudioByID.Load(*obj.StudioID) } +func (r movieResolver) Tags(ctx context.Context, obj *models.Movie) (ret []*models.Tag, err error) { + if !obj.TagIDs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadTagIDs(ctx, r.repository.Movie) + }); err != nil { + return nil, err + } + } + + var errs []error + ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List()) + return ret, firstError(errs) +} + func (r *movieResolver) FrontImagePath(ctx context.Context, obj *models.Movie) (*string, error) { var hasImage bool if err := r.withReadTxn(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_model_tag.go b/internal/api/resolver_model_tag.go index d219fcc66..7c32667d2 100644 --- a/internal/api/resolver_model_tag.go +++ b/internal/api/resolver_model_tag.go @@ -8,6 +8,7 @@ import ( "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/movie" "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/scene" ) @@ -107,6 +108,17 @@ func (r *tagResolver) PerformerCount(ctx context.Context, obj *models.Tag, depth return ret, nil } +func (r *tagResolver) MovieCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = movie.CountByTagID(ctx, r.repository.Movie, obj.ID, depth) + return err + }); err != nil { + return 0, err + } + + return ret, nil +} + func (r *tagResolver) ImagePath(ctx context.Context, obj *models.Tag) (*string, error) { var hasImage bool if err := r.withReadTxn(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_mutation_movie.go b/internal/api/resolver_mutation_movie.go index 82198c125..c3fce71a6 100644 --- a/internal/api/resolver_mutation_movie.go +++ b/internal/api/resolver_mutation_movie.go @@ -50,6 +50,11 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp return nil, fmt.Errorf("converting studio id: %w", err) } + newMovie.TagIDs, err = translator.relatedIds(input.TagIds) + if err != nil { + return nil, fmt.Errorf("converting tag ids: %w", err) + } + if input.Urls != nil { newMovie.URLs = models.NewRelatedStrings(input.Urls) } else if input.URL != nil { @@ -140,6 +145,11 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInp return nil, fmt.Errorf("converting studio id: %w", err) } + updatedMovie.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids") + if err != nil { + return nil, fmt.Errorf("converting tag ids: %w", err) + } + updatedMovie.URLs = translator.optionalURLs(input.Urls, input.URL) var frontimageData []byte @@ -211,6 +221,12 @@ func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieU if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) } + + updatedMovie.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids") + if err != nil { + return nil, fmt.Errorf("converting tag ids: %w", err) + } + updatedMovie.URLs = translator.optionalURLsBulk(input.Urls, nil) ret := []*models.Movie{} diff --git a/internal/api/resolver_query_scraper.go b/internal/api/resolver_query_scraper.go index 4a65c52f5..503f73b7e 100644 --- a/internal/api/resolver_query_scraper.go +++ b/internal/api/resolver_query_scraper.go @@ -144,6 +144,23 @@ func filterPerformerTags(p []*models.ScrapedPerformer) { } } +// filterMovieTags removes tags matching excluded tag patterns from the provided scraped movies +func filterMovieTags(p []*models.ScrapedMovie) { + excludeRegexps := compileRegexps(manager.GetInstance().Config.GetScraperExcludeTagPatterns()) + + var ignoredTags []string + + for _, s := range p { + var ignored []string + s.Tags, ignored = filterTags(excludeRegexps, s.Tags) + ignoredTags = sliceutil.AppendUniques(ignoredTags, ignored) + } + + if len(ignoredTags) > 0 { + logger.Debugf("Scraping ignored tags: %s", strings.Join(ignoredTags, ", ")) + } +} + func (r *queryResolver) ScrapeSceneURL(ctx context.Context, url string) (*scraper.ScrapedScene, error) { content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeScene) if err != nil { @@ -186,7 +203,14 @@ func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models return nil, err } - return marshalScrapedMovie(content) + ret, err := marshalScrapedMovie(content) + if err != nil { + return nil, err + } + + filterMovieTags([]*models.ScrapedMovie{ret}) + + return ret, nil } func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.Source, input ScrapeSingleSceneInput) ([]*scraper.ScrapedScene, error) { diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index 555502dc5..2daac2008 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -1107,6 +1107,7 @@ func (t *ExportTask) exportMovie(ctx context.Context, wg *sync.WaitGroup, jobCha r := t.repository movieReader := r.Movie studioReader := r.Studio + tagReader := r.Tag for m := range jobChan { if err := m.LoadURLs(ctx, r.Movie); err != nil { @@ -1121,6 +1122,14 @@ func (t *ExportTask) exportMovie(ctx context.Context, wg *sync.WaitGroup, jobCha continue } + tags, err := tagReader.FindByMovieID(ctx, m.ID) + if err != nil { + logger.Errorf("[movies] <%s> error getting image tag names: %v", m.Name, err) + continue + } + + newMovieJSON.Tags = tag.GetNames(tags) + if t.includeDependencies { if m.StudioID != nil { t.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *m.StudioID) diff --git a/internal/manager/task_import.go b/internal/manager/task_import.go index 9b5de7354..c9d5b54ba 100644 --- a/internal/manager/task_import.go +++ b/internal/manager/task_import.go @@ -351,6 +351,7 @@ func (t *ImportTask) ImportMovies(ctx context.Context) { movieImporter := &movie.Importer{ ReaderWriter: r.Movie, StudioWriter: r.Studio, + TagWriter: r.Tag, Input: *movieJSON, MissingRefBehaviour: t.MissingRefBehaviour, } diff --git a/pkg/models/jsonschema/movie.go b/pkg/models/jsonschema/movie.go index 33ce10c1d..eeefe1ed1 100644 --- a/pkg/models/jsonschema/movie.go +++ b/pkg/models/jsonschema/movie.go @@ -23,6 +23,7 @@ type Movie struct { BackImage string `json:"back_image,omitempty"` URLs []string `json:"urls,omitempty"` Studio string `json:"studio,omitempty"` + Tags []string `json:"tags,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"` diff --git a/pkg/models/mocks/MovieReaderWriter.go b/pkg/models/mocks/MovieReaderWriter.go index 3f693be94..0da8c8a19 100644 --- a/pkg/models/mocks/MovieReaderWriter.go +++ b/pkg/models/mocks/MovieReaderWriter.go @@ -312,6 +312,29 @@ func (_m *MovieReaderWriter) GetFrontImage(ctx context.Context, movieID int) ([] return r0, r1 } +// GetTagIDs provides a mock function with given fields: ctx, relatedID +func (_m *MovieReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) { + ret := _m.Called(ctx, relatedID) + + var r0 []int + if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { + r0 = rf(ctx, relatedID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]int) + } + } + + 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 +} + // GetURLs provides a mock function with given fields: ctx, relatedID func (_m *MovieReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) { ret := _m.Called(ctx, relatedID) diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index f4c494016..d18f6a66b 100644 --- a/pkg/models/mocks/TagReaderWriter.go +++ b/pkg/models/mocks/TagReaderWriter.go @@ -266,6 +266,29 @@ func (_m *TagReaderWriter) FindByImageID(ctx context.Context, imageID int) ([]*m return r0, r1 } +// FindByMovieID provides a mock function with given fields: ctx, movieID +func (_m *TagReaderWriter) FindByMovieID(ctx context.Context, movieID int) ([]*models.Tag, error) { + ret := _m.Called(ctx, movieID) + + var r0 []*models.Tag + if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok { + r0 = rf(ctx, movieID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Tag) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, movieID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindByName provides a mock function with given fields: ctx, name, nocase func (_m *TagReaderWriter) FindByName(ctx context.Context, name string, nocase bool) (*models.Tag, error) { ret := _m.Called(ctx, name, nocase) diff --git a/pkg/models/model_movie.go b/pkg/models/model_movie.go index d1ce0d8dc..cd8bb848c 100644 --- a/pkg/models/model_movie.go +++ b/pkg/models/model_movie.go @@ -19,7 +19,8 @@ type Movie struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - URLs RelatedStrings `json:"urls"` + URLs RelatedStrings `json:"urls"` + TagIDs RelatedIDs `json:"tag_ids"` } func NewMovie() Movie { @@ -30,9 +31,15 @@ func NewMovie() Movie { } } -func (g *Movie) LoadURLs(ctx context.Context, l URLLoader) error { - return g.URLs.load(func() ([]string, error) { - return l.GetURLs(ctx, g.ID) +func (m *Movie) LoadURLs(ctx context.Context, l URLLoader) error { + return m.URLs.load(func() ([]string, error) { + return l.GetURLs(ctx, m.ID) + }) +} + +func (m *Movie) LoadTagIDs(ctx context.Context, l TagIDLoader) error { + return m.TagIDs.load(func() ([]int, error) { + return l.GetTagIDs(ctx, m.ID) }) } @@ -47,6 +54,7 @@ type MoviePartial struct { Director OptionalString Synopsis OptionalString URLs *UpdateStrings + TagIDs *UpdateIDs CreatedAt OptionalTime UpdatedAt OptionalTime } diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 5a9f2acb0..5cc5c679c 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -371,6 +371,7 @@ type ScrapedMovie struct { URLs []string `json:"urls"` Synopsis *string `json:"synopsis"` Studio *ScrapedStudio `json:"studio"` + Tags []*ScrapedTag `json:"tags"` // This should be a base64 encoded data URL FrontImage *string `json:"front_image"` // This should be a base64 encoded data URL diff --git a/pkg/models/movie.go b/pkg/models/movie.go index 95c6efdd1..5fb98190d 100644 --- a/pkg/models/movie.go +++ b/pkg/models/movie.go @@ -17,6 +17,10 @@ type MovieFilterType struct { URL *StringCriterionInput `json:"url"` // Filter to only include movies where performer appears in a scene Performers *MultiCriterionInput `json:"performers"` + // Filter to only include performers with these tags + Tags *HierarchicalMultiCriterionInput `json:"tags"` + // Filter by tag count + TagCount *IntCriterionInput `json:"tag_count"` // Filter by date Date *DateCriterionInput `json:"date"` // Filter by related scenes that meet this criteria diff --git a/pkg/models/repository_movie.go b/pkg/models/repository_movie.go index 2518e21b5..dec0e0421 100644 --- a/pkg/models/repository_movie.go +++ b/pkg/models/repository_movie.go @@ -65,6 +65,7 @@ type MovieReader interface { MovieQueryer MovieCounter URLLoader + TagIDLoader All(ctx context.Context) ([]*Movie, error) GetFrontImage(ctx context.Context, movieID int) ([]byte, error) diff --git a/pkg/models/repository_tag.go b/pkg/models/repository_tag.go index 6d38785e6..287aeb211 100644 --- a/pkg/models/repository_tag.go +++ b/pkg/models/repository_tag.go @@ -20,6 +20,7 @@ type TagFinder interface { FindByImageID(ctx context.Context, imageID int) ([]*Tag, error) FindByGalleryID(ctx context.Context, galleryID int) ([]*Tag, error) FindByPerformerID(ctx context.Context, performerID int) ([]*Tag, error) + FindByMovieID(ctx context.Context, movieID int) ([]*Tag, error) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*Tag, error) FindByName(ctx context.Context, name string, nocase bool) (*Tag, error) FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error) diff --git a/pkg/models/tag.go b/pkg/models/tag.go index d51ec9787..7ee0705a4 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -20,6 +20,8 @@ type TagFilterType struct { GalleryCount *IntCriterionInput `json:"gallery_count"` // Filter by number of performers with this tag PerformerCount *IntCriterionInput `json:"performer_count"` + // Filter by number of movies with this tag + MovieCount *IntCriterionInput `json:"movie_count"` // Filter by number of markers with this tag MarkerCount *IntCriterionInput `json:"marker_count"` // Filter by parent tags diff --git a/pkg/movie/import.go b/pkg/movie/import.go index 00e56d4e1..27c25316d 100644 --- a/pkg/movie/import.go +++ b/pkg/movie/import.go @@ -3,9 +3,11 @@ package movie import ( "context" "fmt" + "strings" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" + "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/utils" ) @@ -17,6 +19,7 @@ type ImporterReaderWriter interface { type Importer struct { ReaderWriter ImporterReaderWriter StudioWriter models.StudioFinderCreator + TagWriter models.TagFinderCreator Input jsonschema.Movie MissingRefBehaviour models.ImportMissingRefEnum @@ -32,6 +35,10 @@ func (i *Importer) PreImport(ctx context.Context) error { return err } + if err := i.populateTags(ctx); err != nil { + return err + } + var err error if len(i.Input.FrontImage) > 0 { i.frontImageData, err = utils.ProcessBase64Image(i.Input.FrontImage) @@ -49,6 +56,74 @@ func (i *Importer) PreImport(ctx context.Context) error { return nil } +func (i *Importer) populateTags(ctx context.Context) error { + if len(i.Input.Tags) > 0 { + + tags, err := importTags(ctx, i.TagWriter, i.Input.Tags, i.MissingRefBehaviour) + if err != nil { + return err + } + + for _, p := range tags { + i.movie.TagIDs.Add(p.ID) + } + } + + return nil +} + +func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string, missingRefBehaviour models.ImportMissingRefEnum) ([]*models.Tag, error) { + tags, err := tagWriter.FindByNames(ctx, names, false) + if err != nil { + return nil, err + } + + var pluckedNames []string + for _, tag := range tags { + pluckedNames = append(pluckedNames, tag.Name) + } + + missingTags := sliceutil.Filter(names, func(name string) bool { + return !sliceutil.Contains(pluckedNames, name) + }) + + if len(missingTags) > 0 { + if missingRefBehaviour == models.ImportMissingRefEnumFail { + return nil, fmt.Errorf("tags [%s] not found", strings.Join(missingTags, ", ")) + } + + if missingRefBehaviour == models.ImportMissingRefEnumCreate { + createdTags, err := createTags(ctx, tagWriter, missingTags) + if err != nil { + return nil, fmt.Errorf("error creating tags: %v", err) + } + + tags = append(tags, createdTags...) + } + + // ignore if MissingRefBehaviour set to Ignore + } + + return tags, nil +} + +func createTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string) ([]*models.Tag, error) { + var ret []*models.Tag + for _, name := range names { + newTag := models.NewTag() + newTag.Name = name + + err := tagWriter.Create(ctx, &newTag) + if err != nil { + return nil, err + } + + ret = append(ret, &newTag) + } + + return ret, nil +} + func (i *Importer) movieJSONToMovie(movieJSON jsonschema.Movie) models.Movie { newMovie := models.Movie{ Name: movieJSON.Name, @@ -57,6 +132,8 @@ func (i *Importer) movieJSONToMovie(movieJSON jsonschema.Movie) models.Movie { Synopsis: movieJSON.Synopsis, CreatedAt: movieJSON.CreatedAt.GetTime(), UpdatedAt: movieJSON.UpdatedAt.GetTime(), + + TagIDs: models.NewRelatedIDs([]int{}), } if len(movieJSON.URLs) > 0 { diff --git a/pkg/movie/import_test.go b/pkg/movie/import_test.go index d62f5a890..2cf35319c 100644 --- a/pkg/movie/import_test.go +++ b/pkg/movie/import_test.go @@ -26,6 +26,13 @@ const ( missingStudioName = "existingStudioName" errImageID = 3 + + existingTagID = 105 + errTagsID = 106 + + existingTagName = "existingTagName" + existingTagErr = "existingTagErr" + missingTagName = "missingTagName" ) var testCtx = context.Background() @@ -157,6 +164,97 @@ func TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) { db.AssertExpectations(t) } +func TestImporterPreImportWithTag(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + ReaderWriter: db.Movie, + TagWriter: db.Tag, + MissingRefBehaviour: models.ImportMissingRefEnumFail, + Input: jsonschema.Movie{ + Tags: []string{ + existingTagName, + }, + }, + } + + db.Tag.On("FindByNames", testCtx, []string{existingTagName}, false).Return([]*models.Tag{ + { + ID: existingTagID, + Name: existingTagName, + }, + }, nil).Once() + db.Tag.On("FindByNames", testCtx, []string{existingTagErr}, false).Return(nil, errors.New("FindByNames error")).Once() + + err := i.PreImport(testCtx) + assert.Nil(t, err) + assert.Equal(t, existingTagID, i.movie.TagIDs.List()[0]) + + i.Input.Tags = []string{existingTagErr} + err = i.PreImport(testCtx) + assert.NotNil(t, err) + + db.AssertExpectations(t) +} + +func TestImporterPreImportWithMissingTag(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + ReaderWriter: db.Movie, + TagWriter: db.Tag, + Input: jsonschema.Movie{ + Tags: []string{ + missingTagName, + }, + }, + MissingRefBehaviour: models.ImportMissingRefEnumFail, + } + + db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3) + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Run(func(args mock.Arguments) { + t := args.Get(1).(*models.Tag) + t.ID = existingTagID + }).Return(nil) + + err := i.PreImport(testCtx) + assert.NotNil(t, err) + + i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore + err = i.PreImport(testCtx) + assert.Nil(t, err) + + i.MissingRefBehaviour = models.ImportMissingRefEnumCreate + err = i.PreImport(testCtx) + assert.Nil(t, err) + assert.Equal(t, existingTagID, i.movie.TagIDs.List()[0]) + + db.AssertExpectations(t) +} + +func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + ReaderWriter: db.Movie, + TagWriter: db.Tag, + Input: jsonschema.Movie{ + Tags: []string{ + missingTagName, + }, + }, + MissingRefBehaviour: models.ImportMissingRefEnumCreate, + } + + db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once() + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Return(errors.New("Create error")) + + err := i.PreImport(testCtx) + assert.NotNil(t, err) + + db.AssertExpectations(t) +} + func TestImporterPostImport(t *testing.T) { db := mocks.NewDatabase() diff --git a/pkg/movie/query.go b/pkg/movie/query.go index 3fac932a0..72764b8dd 100644 --- a/pkg/movie/query.go +++ b/pkg/movie/query.go @@ -18,3 +18,15 @@ func CountByStudioID(ctx context.Context, r models.MovieQueryer, id int, depth * return r.QueryCount(ctx, filter, nil) } + +func CountByTagID(ctx context.Context, r models.MovieQueryer, id int, depth *int) (int, error) { + filter := &models.MovieFilterType{ + Tags: &models.HierarchicalMultiCriterionInput{ + Value: []string{strconv.Itoa(id)}, + Modifier: models.CriterionModifierIncludes, + Depth: depth, + }, + } + + return r.QueryCount(ctx, filter, nil) +} diff --git a/pkg/scraper/mapped.go b/pkg/scraper/mapped.go index 1b24379ca..7b0d6dc7e 100644 --- a/pkg/scraper/mapped.go +++ b/pkg/scraper/mapped.go @@ -284,11 +284,13 @@ type mappedMovieScraperConfig struct { mappedConfig Studio mappedConfig `yaml:"Studio"` + Tags mappedConfig `yaml:"Tags"` } type _mappedMovieScraperConfig mappedMovieScraperConfig const ( mappedScraperConfigMovieStudio = "Studio" + mappedScraperConfigMovieTags = "Tags" ) func (s *mappedMovieScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { @@ -303,9 +305,11 @@ func (s *mappedMovieScraperConfig) UnmarshalYAML(unmarshal func(interface{}) err thisMap := make(map[string]interface{}) thisMap[mappedScraperConfigMovieStudio] = parentMap[mappedScraperConfigMovieStudio] - delete(parentMap, mappedScraperConfigMovieStudio) + thisMap[mappedScraperConfigMovieTags] = parentMap[mappedScraperConfigMovieTags] + delete(parentMap, mappedScraperConfigMovieTags) + // re-unmarshal the sub-fields yml, err := yaml.Marshal(thisMap) if err != nil { @@ -1086,6 +1090,7 @@ func (s mappedScraper) scrapeMovie(ctx context.Context, q mappedQuery) (*models. movieMap := movieScraperConfig.mappedConfig movieStudioMap := movieScraperConfig.Studio + movieTagsMap := movieScraperConfig.Tags results := movieMap.process(ctx, q, s.Common) @@ -1100,7 +1105,19 @@ func (s mappedScraper) scrapeMovie(ctx context.Context, q mappedQuery) (*models. } } - if len(results) == 0 && ret.Studio == nil { + // now apply the tags + if movieTagsMap != nil { + logger.Debug(`Processing movie tags:`) + tagResults := movieTagsMap.process(ctx, q, s.Common) + + for _, p := range tagResults { + tag := &models.ScrapedTag{} + p.apply(tag) + ret.Tags = append(ret.Tags, tag) + } + } + + if len(results) == 0 && ret.Studio == nil && len(ret.Tags) == 0 { return nil, nil } diff --git a/pkg/scraper/postprocessing.go b/pkg/scraper/postprocessing.go index 0cf9b5a17..a375b5058 100644 --- a/pkg/scraper/postprocessing.go +++ b/pkg/scraper/postprocessing.go @@ -71,13 +71,24 @@ func (c Cache) postScrapePerformer(ctx context.Context, p models.ScrapedPerforme } func (c Cache) postScrapeMovie(ctx context.Context, m models.ScrapedMovie) (ScrapedContent, error) { - if m.Studio != nil { - r := c.repository - if err := r.WithReadTxn(ctx, func(ctx context.Context) error { - return match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, nil) - }); err != nil { - return nil, err + r := c.repository + if err := r.WithReadTxn(ctx, func(ctx context.Context) error { + tqb := r.TagFinder + tags, err := postProcessTags(ctx, tqb, m.Tags) + if err != nil { + return err } + m.Tags = tags + + if m.Studio != nil { + if err := match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, nil); err != nil { + return err + } + } + + return nil + }); err != nil { + return nil, err } // post-process - set the image if applicable diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 7303400a3..7cfcd2003 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -30,7 +30,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 60 +var appSchemaVersion uint = 61 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/61_movie_tags.up.sql b/pkg/sqlite/migrations/61_movie_tags.up.sql new file mode 100644 index 000000000..cf898e2c5 --- /dev/null +++ b/pkg/sqlite/migrations/61_movie_tags.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE `movies_tags` ( + `movie_id` integer NOT NULL, + `tag_id` integer NOT NULL, + foreign key(`movie_id`) references `movies`(`id`) on delete CASCADE, + foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE, + PRIMARY KEY(`movie_id`, `tag_id`) +); + +CREATE INDEX `index_movies_tags_on_tag_id` on `movies_tags` (`tag_id`); +CREATE INDEX `index_movies_tags_on_movie_id` on `movies_tags` (`movie_id`); diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index 6fc4ce5f0..e5c08c31f 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -23,6 +23,8 @@ const ( movieFrontImageBlobColumn = "front_image_blob" movieBackImageBlobColumn = "back_image_blob" + moviesTagsTable = "movies_tags" + movieURLsTable = "movie_urls" movieURLColumn = "url" ) @@ -98,6 +100,7 @@ func (r *movieRowRecord) fromPartial(o models.MoviePartial) { type movieRepositoryType struct { repository scenes repository + tags joinRepository } var ( @@ -110,11 +113,21 @@ var ( tableName: moviesScenesTable, idColumn: movieIDColumn, }, + tags: joinRepository{ + repository: repository{ + tableName: moviesTagsTable, + idColumn: movieIDColumn, + }, + fkColumn: tagIDColumn, + foreignTable: tagTable, + orderBy: "tags.name ASC", + }, } ) type MovieStore struct { blobJoinQueryBuilder + tagRelationshipStore tableMgr *table } @@ -125,6 +138,11 @@ func NewMovieStore(blobStore *BlobStore) *MovieStore { blobStore: blobStore, joinTable: movieTable, }, + tagRelationshipStore: tagRelationshipStore{ + idRelationshipStore: idRelationshipStore{ + joinTable: moviesTagsTableMgr, + }, + }, tableMgr: movieTableMgr, } @@ -154,6 +172,10 @@ func (qb *MovieStore) Create(ctx context.Context, newObject *models.Movie) error } } + if err := qb.tagRelationshipStore.createRelationships(ctx, id, newObject.TagIDs); err != nil { + return err + } + updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) @@ -185,6 +207,10 @@ func (qb *MovieStore) UpdatePartial(ctx context.Context, id int, partial models. } } + if err := qb.tagRelationshipStore.modifyRelationships(ctx, id, partial.TagIDs); err != nil { + return nil, err + } + return qb.find(ctx, id) } @@ -202,6 +228,10 @@ func (qb *MovieStore) Update(ctx context.Context, updatedObject *models.Movie) e } } + if err := qb.tagRelationshipStore.replaceRelationships(ctx, updatedObject.ID, updatedObject.TagIDs); err != nil { + return err + } + return nil } @@ -430,6 +460,7 @@ var movieSortOptions = sortOptions{ "random", "rating", "scenes_count", + "tag_count", "updated_at", } @@ -451,6 +482,8 @@ func (qb *MovieStore) getMovieSort(findFilter *models.FindFilterType) (string, e sortQuery := "" switch sort { + case "tag_count": + sortQuery += getCountSort(movieTable, moviesTagsTable, movieIDColumn, direction) case "scenes_count": // generic getSort won't work for this sortQuery += getCountSort(movieTable, moviesScenesTable, movieIDColumn, direction) default: diff --git a/pkg/sqlite/movies_filter.go b/pkg/sqlite/movies_filter.go index 8ef939592..0a2b3d674 100644 --- a/pkg/sqlite/movies_filter.go +++ b/pkg/sqlite/movies_filter.go @@ -63,6 +63,8 @@ func (qb *movieFilterHandler) criterionHandler() criterionHandler { qb.urlsCriterionHandler(movieFilter.URL), studioCriterionHandler(movieTable, movieFilter.Studios), qb.performersCriterionHandler(movieFilter.Performers), + qb.tagsCriterionHandler(movieFilter.Tags), + qb.tagCountCriterionHandler(movieFilter.TagCount), &dateCriterionHandler{movieFilter.Date, "movies.date", nil}, ×tampCriterionHandler{movieFilter.CreatedAt, "movies.created_at", nil}, ×tampCriterionHandler{movieFilter.UpdatedAt, "movies.updated_at", nil}, @@ -162,3 +164,28 @@ func (qb *movieFilterHandler) performersCriterionHandler(performers *models.Mult } } } + +func (qb *movieFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + h := joinedHierarchicalMultiCriterionHandlerBuilder{ + primaryTable: movieTable, + foreignTable: tagTable, + foreignFK: "tag_id", + + relationsTable: "tags_relations", + joinAs: "movie_tag", + joinTable: moviesTagsTable, + primaryFK: movieIDColumn, + } + + return h.handler(tags) +} + +func (qb *movieFilterHandler) tagCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: movieTable, + joinTable: moviesTagsTable, + primaryFK: movieIDColumn, + } + + return h.handler(count) +} diff --git a/pkg/sqlite/movies_test.go b/pkg/sqlite/movies_test.go index 9c4e0135f..3cfe05fe8 100644 --- a/pkg/sqlite/movies_test.go +++ b/pkg/sqlite/movies_test.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/stretchr/testify/assert" @@ -17,7 +18,12 @@ import ( func loadMovieRelationships(ctx context.Context, expected models.Movie, actual *models.Movie) error { if expected.URLs.Loaded() { - if err := actual.LoadURLs(ctx, db.Gallery); err != nil { + if err := actual.LoadURLs(ctx, db.Movie); err != nil { + return err + } + } + if expected.TagIDs.Loaded() { + if err := actual.LoadTagIDs(ctx, db.Movie); err != nil { return err } } @@ -25,6 +31,337 @@ func loadMovieRelationships(ctx context.Context, expected models.Movie, actual * return nil } +func Test_MovieStore_Create(t *testing.T) { + var ( + name = "name" + url = "url" + aliases = "alias1, alias2" + director = "director" + rating = 60 + duration = 34 + synopsis = "synopsis" + date, _ = models.ParseDate("2003-02-01") + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + ) + + tests := []struct { + name string + newObject models.Movie + wantErr bool + }{ + { + "full", + models.Movie{ + Name: name, + Duration: &duration, + Date: &date, + Rating: &rating, + StudioID: &studioIDs[studioIdxWithMovie], + Director: director, + Synopsis: synopsis, + URLs: models.NewRelatedStrings([]string{url}), + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithMovie]}), + Aliases: aliases, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + false, + }, + { + "invalid tag id", + models.Movie{ + Name: name, + TagIDs: models.NewRelatedIDs([]int{invalidID}), + }, + true, + }, + } + + qb := db.Movie + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + p := tt.newObject + if err := qb.Create(ctx, &p); (err != nil) != tt.wantErr { + t.Errorf("MovieStore.Create() error = %v, wantErr = %v", err, tt.wantErr) + } + + if tt.wantErr { + assert.Zero(p.ID) + return + } + + assert.NotZero(p.ID) + + copy := tt.newObject + copy.ID = p.ID + + // load relationships + if err := loadMovieRelationships(ctx, copy, &p); err != nil { + t.Errorf("loadMovieRelationships() error = %v", err) + return + } + + assert.Equal(copy, p) + + // ensure can find the movie + found, err := qb.Find(ctx, p.ID) + if err != nil { + t.Errorf("MovieStore.Find() error = %v", err) + } + + if !assert.NotNil(found) { + return + } + + // load relationships + if err := loadMovieRelationships(ctx, copy, found); err != nil { + t.Errorf("loadMovieRelationships() error = %v", err) + return + } + assert.Equal(copy, *found) + + return + }) + } +} + +func Test_movieQueryBuilder_Update(t *testing.T) { + var ( + name = "name" + url = "url" + aliases = "alias1, alias2" + director = "director" + rating = 60 + duration = 34 + synopsis = "synopsis" + date, _ = models.ParseDate("2003-02-01") + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + ) + + tests := []struct { + name string + updatedObject *models.Movie + wantErr bool + }{ + { + "full", + &models.Movie{ + ID: movieIDs[movieIdxWithTag], + Name: name, + Duration: &duration, + Date: &date, + Rating: &rating, + StudioID: &studioIDs[studioIdxWithMovie], + Director: director, + Synopsis: synopsis, + URLs: models.NewRelatedStrings([]string{url}), + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithMovie]}), + Aliases: aliases, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + false, + }, + { + "clear tag ids", + &models.Movie{ + ID: movieIDs[movieIdxWithTag], + Name: name, + TagIDs: models.NewRelatedIDs([]int{}), + }, + false, + }, + { + "invalid studio id", + &models.Movie{ + ID: movieIDs[movieIdxWithScene], + Name: name, + StudioID: &invalidID, + }, + true, + }, + { + "invalid tag id", + &models.Movie{ + ID: movieIDs[movieIdxWithScene], + Name: name, + TagIDs: models.NewRelatedIDs([]int{invalidID}), + }, + true, + }, + } + + qb := db.Movie + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + copy := *tt.updatedObject + + if err := qb.Update(ctx, tt.updatedObject); (err != nil) != tt.wantErr { + t.Errorf("movieQueryBuilder.Update() error = %v, wantErr %v", err, tt.wantErr) + } + + if tt.wantErr { + return + } + + s, err := qb.Find(ctx, tt.updatedObject.ID) + if err != nil { + t.Errorf("movieQueryBuilder.Find() error = %v", err) + } + + // load relationships + if err := loadMovieRelationships(ctx, copy, s); err != nil { + t.Errorf("loadMovieRelationships() error = %v", err) + return + } + + assert.Equal(copy, *s) + }) + } +} + +func clearMoviePartial() models.MoviePartial { + // leave mandatory fields + return models.MoviePartial{ + Aliases: models.OptionalString{Set: true, Null: true}, + Synopsis: models.OptionalString{Set: true, Null: true}, + Director: models.OptionalString{Set: true, Null: true}, + Duration: models.OptionalInt{Set: true, Null: true}, + URLs: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet}, + Date: models.OptionalDate{Set: true, Null: true}, + Rating: models.OptionalInt{Set: true, Null: true}, + StudioID: models.OptionalInt{Set: true, Null: true}, + TagIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet}, + } +} + +func Test_movieQueryBuilder_UpdatePartial(t *testing.T) { + var ( + name = "name" + url = "url" + aliases = "alias1, alias2" + director = "director" + rating = 60 + duration = 34 + synopsis = "synopsis" + date, _ = models.ParseDate("2003-02-01") + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + ) + + tests := []struct { + name string + id int + partial models.MoviePartial + want models.Movie + wantErr bool + }{ + { + "full", + movieIDs[movieIdxWithScene], + models.MoviePartial{ + Name: models.NewOptionalString(name), + Director: models.NewOptionalString(director), + Synopsis: models.NewOptionalString(synopsis), + Aliases: models.NewOptionalString(aliases), + URLs: &models.UpdateStrings{ + Values: []string{url}, + Mode: models.RelationshipUpdateModeSet, + }, + Date: models.NewOptionalDate(date), + Duration: models.NewOptionalInt(duration), + Rating: models.NewOptionalInt(rating), + StudioID: models.NewOptionalInt(studioIDs[studioIdxWithMovie]), + CreatedAt: models.NewOptionalTime(createdAt), + UpdatedAt: models.NewOptionalTime(updatedAt), + TagIDs: &models.UpdateIDs{ + IDs: []int{tagIDs[tagIdx1WithMovie], tagIDs[tagIdx1WithDupName]}, + Mode: models.RelationshipUpdateModeSet, + }, + }, + models.Movie{ + ID: movieIDs[movieIdxWithScene], + Name: name, + Director: director, + Synopsis: synopsis, + Aliases: aliases, + URLs: models.NewRelatedStrings([]string{url}), + Date: &date, + Duration: &duration, + Rating: &rating, + StudioID: &studioIDs[studioIdxWithMovie], + CreatedAt: createdAt, + UpdatedAt: updatedAt, + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithMovie]}), + }, + false, + }, + { + "clear all", + movieIDs[movieIdxWithScene], + clearMoviePartial(), + models.Movie{ + ID: movieIDs[movieIdxWithScene], + Name: movieNames[movieIdxWithScene], + TagIDs: models.NewRelatedIDs([]int{}), + }, + false, + }, + { + "invalid id", + invalidID, + models.MoviePartial{}, + models.Movie{}, + true, + }, + } + for _, tt := range tests { + qb := db.Movie + + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + got, err := qb.UpdatePartial(ctx, tt.id, tt.partial) + if (err != nil) != tt.wantErr { + t.Errorf("movieQueryBuilder.UpdatePartial() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr { + return + } + + // load relationships + if err := loadMovieRelationships(ctx, tt.want, got); err != nil { + t.Errorf("loadMovieRelationships() error = %v", err) + return + } + + assert.Equal(tt.want, *got) + + s, err := qb.Find(ctx, tt.id) + if err != nil { + t.Errorf("movieQueryBuilder.Find() error = %v", err) + } + + // load relationships + if err := loadMovieRelationships(ctx, tt.want, s); err != nil { + t.Errorf("loadMovieRelationships() error = %v", err) + return + } + + assert.Equal(tt.want, *s) + }) + } +} + func TestMovieFindByName(t *testing.T) { withTxn(func(ctx context.Context) error { mqb := db.Movie @@ -280,12 +617,12 @@ func TestMovieQueryURLExcludes(t *testing.T) { Name: &nameCriterion, } - movies := queryMovie(ctx, t, mqb, &filter, nil) + movies := queryMovies(ctx, t, &filter, nil) assert.Len(t, movies, 0, "Expected no movies to be found") // query for movies that exclude the URL "ccc" urlCriterion.Value = "ccc" - movies = queryMovie(ctx, t, mqb, &filter, nil) + movies = queryMovies(ctx, t, &filter, nil) if assert.Len(t, movies, 1, "Expected one movie to be found") { assert.Equal(t, movie.Name, movies[0].Name) @@ -300,7 +637,7 @@ func verifyMovieQuery(t *testing.T, filter models.MovieFilterType, verifyFn func t.Helper() sqb := db.Movie - movies := queryMovie(ctx, t, sqb, &filter, nil) + movies := queryMovies(ctx, t, &filter, nil) for _, movie := range movies { if err := movie.LoadURLs(ctx, sqb); err != nil { @@ -319,7 +656,8 @@ func verifyMovieQuery(t *testing.T, filter models.MovieFilterType, verifyFn func }) } -func queryMovie(ctx context.Context, t *testing.T, sqb models.MovieReader, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) []*models.Movie { +func queryMovies(ctx context.Context, t *testing.T, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) []*models.Movie { + sqb := db.Movie movies, _, err := sqb.Query(ctx, movieFilter, findFilter) if err != nil { t.Errorf("Error querying movie: %s", err.Error()) @@ -328,6 +666,102 @@ func queryMovie(ctx context.Context, t *testing.T, sqb models.MovieReader, movie return movies } +func TestMovieQueryTags(t *testing.T) { + withTxn(func(ctx context.Context) error { + tagCriterion := models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithMovie]), + strconv.Itoa(tagIDs[tagIdx1WithMovie]), + }, + Modifier: models.CriterionModifierIncludes, + } + + movieFilter := models.MovieFilterType{ + Tags: &tagCriterion, + } + + // ensure ids are correct + movies := queryMovies(ctx, t, &movieFilter, nil) + assert.Len(t, movies, 3) + for _, movie := range movies { + assert.True(t, movie.ID == movieIDs[movieIdxWithTag] || movie.ID == movieIDs[movieIdxWithTwoTags] || movie.ID == movieIDs[movieIdxWithThreeTags]) + } + + tagCriterion = models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithMovie]), + strconv.Itoa(tagIDs[tagIdx2WithMovie]), + }, + Modifier: models.CriterionModifierIncludesAll, + } + + movies = queryMovies(ctx, t, &movieFilter, nil) + + if assert.Len(t, movies, 2) { + assert.Equal(t, sceneIDs[movieIdxWithTwoTags], movies[0].ID) + assert.Equal(t, sceneIDs[movieIdxWithThreeTags], movies[1].ID) + } + + tagCriterion = models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithMovie]), + }, + Modifier: models.CriterionModifierExcludes, + } + + q := getSceneStringValue(movieIdxWithTwoTags, titleField) + findFilter := models.FindFilterType{ + Q: &q, + } + + movies = queryMovies(ctx, t, &movieFilter, &findFilter) + assert.Len(t, movies, 0) + + return nil + }) +} + +func TestMovieQueryTagCount(t *testing.T) { + const tagCount = 1 + tagCountCriterion := models.IntCriterionInput{ + Value: tagCount, + Modifier: models.CriterionModifierEquals, + } + + verifyMoviesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyMoviesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyMoviesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierLessThan + verifyMoviesTagCount(t, tagCountCriterion) +} + +func verifyMoviesTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { + withTxn(func(ctx context.Context) error { + sqb := db.Movie + movieFilter := models.MovieFilterType{ + TagCount: &tagCountCriterion, + } + + movies := queryMovies(ctx, t, &movieFilter, nil) + assert.Greater(t, len(movies), 0) + + for _, movie := range movies { + ids, err := sqb.GetTagIDs(ctx, movie.ID) + if err != nil { + return err + } + verifyInt(t, len(ids), tagCountCriterion) + } + + return nil + }) +} + func TestMovieQuerySorting(t *testing.T) { sort := "scenes_count" direction := models.SortDirectionEnumDesc @@ -337,8 +771,7 @@ func TestMovieQuerySorting(t *testing.T) { } withTxn(func(ctx context.Context) error { - sqb := db.Movie - movies := queryMovie(ctx, t, sqb, nil, &findFilter) + movies := queryMovies(ctx, t, nil, &findFilter) // scenes should be in same order as indexes firstMovie := movies[0] @@ -348,7 +781,7 @@ func TestMovieQuerySorting(t *testing.T) { // sort in descending order direction = models.SortDirectionEnumAsc - movies = queryMovie(ctx, t, sqb, nil, &findFilter) + movies = queryMovies(ctx, t, nil, &findFilter) lastMovie := movies[len(movies)-1] assert.Equal(t, movieIDs[movieIdxWithScene], lastMovie.ID) diff --git a/pkg/sqlite/relationships.go b/pkg/sqlite/relationships.go new file mode 100644 index 000000000..32c8fda64 --- /dev/null +++ b/pkg/sqlite/relationships.go @@ -0,0 +1,41 @@ +package sqlite + +import ( + "context" + + "github.com/stashapp/stash/pkg/models" +) + +type idRelationshipStore struct { + joinTable *joinTable +} + +func (s *idRelationshipStore) createRelationships(ctx context.Context, id int, fkIDs models.RelatedIDs) error { + if fkIDs.Loaded() { + if err := s.joinTable.insertJoins(ctx, id, fkIDs.List()); err != nil { + return err + } + } + + return nil +} + +func (s *idRelationshipStore) modifyRelationships(ctx context.Context, id int, fkIDs *models.UpdateIDs) error { + if fkIDs != nil { + if err := s.joinTable.modifyJoins(ctx, id, fkIDs.IDs, fkIDs.Mode); err != nil { + return err + } + } + + return nil +} + +func (s *idRelationshipStore) replaceRelationships(ctx context.Context, id int, fkIDs models.RelatedIDs) error { + if fkIDs.Loaded() { + if err := s.joinTable.replaceJoins(ctx, id, fkIDs.List()); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 1ccab4574..736eae6a6 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -150,9 +150,12 @@ const ( const ( movieIdxWithScene = iota movieIdxWithStudio + movieIdxWithTag + movieIdxWithTwoTags + movieIdxWithThreeTags // movies with dup names start from the end - // create 10 more basic movies (can remove this if we add more indexes) - movieIdxWithDupName = movieIdxWithStudio + 10 + // create 7 more basic movies (can remove this if we add more indexes) + movieIdxWithDupName = movieIdxWithStudio + 7 moviesNameCase = movieIdxWithDupName moviesNameNoCase = 1 @@ -214,6 +217,10 @@ const ( tagIdxWithParentAndChild tagIdxWithGrandParent tagIdx2WithMarkers + tagIdxWithMovie + tagIdx1WithMovie + tagIdx2WithMovie + tagIdx3WithMovie // new indexes above // tags with dup names start from the end tagIdx1WithDupName @@ -487,6 +494,12 @@ var ( movieStudioLinks = [][2]int{ {movieIdxWithStudio, studioIdxWithMovie}, } + + movieTags = linkMap{ + movieIdxWithTag: {tagIdxWithMovie}, + movieIdxWithTwoTags: {tagIdx1WithMovie, tagIdx2WithMovie}, + movieIdxWithThreeTags: {tagIdx1WithMovie, tagIdx2WithMovie, tagIdx3WithMovie}, + } ) var ( @@ -622,14 +635,14 @@ func populateDB() error { // TODO - link folders to zip files - if err := createMovies(ctx, db.Movie, moviesNameCase, moviesNameNoCase); err != nil { - return fmt.Errorf("error creating movies: %s", err.Error()) - } - if err := createTags(ctx, db.Tag, tagsNameCase, tagsNameNoCase); err != nil { return fmt.Errorf("error creating tags: %s", err.Error()) } + if err := createMovies(ctx, db.Movie, moviesNameCase, moviesNameNoCase); err != nil { + return fmt.Errorf("error creating movies: %s", err.Error()) + } + if err := createPerformers(ctx, performersNameCase, performersNameNoCase); err != nil { return fmt.Errorf("error creating performers: %s", err.Error()) } @@ -1321,6 +1334,8 @@ func createMovies(ctx context.Context, mqb models.MovieReaderWriter, n int, o in index := i name := namePlain + tids := indexesToIDs(tagIDs, movieTags[i]) + if i >= n { // i=n movies get dup names if case is not checked index = n + o - (i + 1) // for the name to be the same the number (index) must be the same also @@ -1333,6 +1348,7 @@ func createMovies(ctx context.Context, mqb models.MovieReaderWriter, n int, o in URLs: models.NewRelatedStrings([]string{ getMovieEmptyString(i, urlField), }), + TagIDs: models.NewRelatedIDs(tids), } err := mqb.Create(ctx, &movie) diff --git a/pkg/sqlite/table.go b/pkg/sqlite/table.go index 2aa5b77b6..6b6ed9417 100644 --- a/pkg/sqlite/table.go +++ b/pkg/sqlite/table.go @@ -155,6 +155,10 @@ func (t *table) join(j joiner, as string, parentIDCol string) { type joinTable struct { table fkColumn exp.IdentifierExpression + + // required for ordering + foreignTable *table + orderBy exp.OrderedExpression } func (t *joinTable) invert() *joinTable { @@ -170,6 +174,13 @@ func (t *joinTable) invert() *joinTable { func (t *joinTable) get(ctx context.Context, id int) ([]int, error) { q := dialect.Select(t.fkColumn).From(t.table.table).Where(t.idColumn.Eq(id)) + if t.orderBy != nil { + if t.foreignTable != nil { + q = q.InnerJoin(t.foreignTable.table, goqu.On(t.foreignTable.idColumn.Eq(t.fkColumn))) + } + q = q.Order(t.orderBy) + } + const single = false var ret []int if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 701c50330..d4425cfe3 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -36,6 +36,7 @@ var ( studiosStashIDsJoinTable = goqu.T("studio_stash_ids") moviesURLsJoinTable = goqu.T(movieURLsTable) + moviesTagsJoinTable = goqu.T(moviesTagsTable) tagsAliasesJoinTable = goqu.T(tagAliasesTable) tagRelationsJoinTable = goqu.T(tagRelationsTable) @@ -330,6 +331,16 @@ var ( }, valueColumn: moviesURLsJoinTable.Col(movieURLColumn), } + + moviesTagsTableMgr = &joinTable{ + table: table{ + table: moviesTagsJoinTable, + idColumn: moviesTagsJoinTable.Col(movieIDColumn), + }, + fkColumn: moviesTagsJoinTable.Col(tagIDColumn), + foreignTable: tagTableMgr, + orderBy: tagTableMgr.table.Col("name").Asc(), + } ) var ( diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 127ad3310..a4bf3793a 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -424,6 +424,18 @@ func (qb *TagStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*mode return qb.queryTags(ctx, query, args) } +func (qb *TagStore) FindByMovieID(ctx context.Context, movieID int) ([]*models.Tag, error) { + query := ` + SELECT tags.* FROM tags + LEFT JOIN movies_tags as movies_join on movies_join.tag_id = tags.id + WHERE movies_join.movie_id = ? + GROUP BY tags.id + ` + query += qb.getDefaultTagSort() + args := []interface{}{movieID} + return qb.queryTags(ctx, query, args) +} + func (qb *TagStore) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*models.Tag, error) { query := ` SELECT tags.* FROM tags @@ -615,6 +627,7 @@ var tagSortOptions = sortOptions{ "galleries_count", "id", "images_count", + "movies_count", "name", "performers_count", "random", @@ -655,6 +668,8 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte sortQuery += getCountSort(tagTable, galleriesTagsTable, tagIDColumn, direction) case "performers_count": sortQuery += getCountSort(tagTable, performersTagsTable, tagIDColumn, direction) + case "movies_count": + sortQuery += getCountSort(tagTable, moviesTagsTable, tagIDColumn, direction) default: sortQuery += getSort(sort, direction, "tags") } @@ -888,3 +903,17 @@ SELECT t.*, c.path FROM tags t INNER JOIN children c ON t.id = c.child_id return qb.queryTagPaths(ctx, query, args) } + +type tagRelationshipStore struct { + idRelationshipStore +} + +func (s *tagRelationshipStore) CountByTagID(ctx context.Context, tagID int) (int, error) { + joinTable := s.joinTable.table.table + q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col(tagIDColumn).Eq(tagID)) + return count(ctx, q) +} + +func (s *tagRelationshipStore) GetTagIDs(ctx context.Context, id int) ([]int, error) { + return s.joinTable.get(ctx, id) +} diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index 55321dbba..776a49fc4 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -66,6 +66,7 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler { qb.imageCountCriterionHandler(tagFilter.ImageCount), qb.galleryCountCriterionHandler(tagFilter.GalleryCount), qb.performerCountCriterionHandler(tagFilter.PerformerCount), + qb.movieCountCriterionHandler(tagFilter.MovieCount), qb.markerCountCriterionHandler(tagFilter.MarkerCount), qb.parentsCriterionHandler(tagFilter.Parents), qb.childrenCriterionHandler(tagFilter.Children), @@ -174,6 +175,17 @@ func (qb *tagFilterHandler) performerCountCriterionHandler(performerCount *model } } +func (qb *tagFilterHandler) movieCountCriterionHandler(movieCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if movieCount != nil { + f.addLeftJoin("movies_tags", "", "movies_tags.tag_id = tags.id") + clause, args := getIntCriterionWhereClause("count(distinct movies_tags.movie_id)", *movieCount) + + f.addHaving(clause, args...) + } + } +} + func (qb *tagFilterHandler) markerCountCriterionHandler(markerCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if markerCount != nil { diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index a44232720..d71316413 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -42,6 +42,33 @@ func TestMarkerFindBySceneMarkerID(t *testing.T) { }) } +func TestTagFindByMovieID(t *testing.T) { + withTxn(func(ctx context.Context) error { + tqb := db.Tag + + movieID := movieIDs[movieIdxWithTag] + + tags, err := tqb.FindByMovieID(ctx, movieID) + + if err != nil { + t.Errorf("Error finding tags: %s", err.Error()) + } + + assert.Len(t, tags, 1) + assert.Equal(t, tagIDs[tagIdxWithMovie], tags[0].ID) + + tags, err = tqb.FindByMovieID(ctx, 0) + + if err != nil { + t.Errorf("Error finding tags: %s", err.Error()) + } + + assert.Len(t, tags, 0) + + return nil + }) +} + func TestTagFindByName(t *testing.T) { withTxn(func(ctx context.Context) error { tqb := db.Tag @@ -203,6 +230,10 @@ func TestTagQuerySort(t *testing.T) { tags = queryTags(ctx, t, sqb, nil, findFilter) assert.Equal(tagIDs[tagIdx2WithPerformer], tags[0].ID) + sortBy = "movies_count" + tags = queryTags(ctx, t, sqb, nil, findFilter) + assert.Equal(tagIDs[tagIdx1WithMovie], tags[0].ID) + return nil }) } diff --git a/ui/v2.5/graphql/data/movie.graphql b/ui/v2.5/graphql/data/movie.graphql index a0ed1f67f..b94450f28 100644 --- a/ui/v2.5/graphql/data/movie.graphql +++ b/ui/v2.5/graphql/data/movie.graphql @@ -11,6 +11,10 @@ fragment MovieData on Movie { ...SlimStudioData } + tags { + ...SlimTagData + } + synopsis urls front_image_path diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index a59d74b09..087ba2efb 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -98,6 +98,9 @@ fragment ScrapedMovieData on ScrapedMovie { studio { ...ScrapedMovieStudioData } + tags { + ...ScrapedSceneTagData + } } fragment ScrapedSceneMovieData on ScrapedMovie { @@ -116,6 +119,9 @@ fragment ScrapedSceneMovieData on ScrapedMovie { studio { ...ScrapedMovieStudioData } + tags { + ...ScrapedSceneTagData + } } fragment ScrapedSceneStudioData on ScrapedStudio { diff --git a/ui/v2.5/graphql/data/tag.graphql b/ui/v2.5/graphql/data/tag.graphql index b71f487ab..d473bf8c6 100644 --- a/ui/v2.5/graphql/data/tag.graphql +++ b/ui/v2.5/graphql/data/tag.graphql @@ -16,6 +16,8 @@ fragment TagData on Tag { gallery_count_all: gallery_count(depth: -1) performer_count performer_count_all: performer_count(depth: -1) + movie_count + movie_count_all: movie_count(depth: -1) parents { ...SlimTagData diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index 4c12b0232..9f018e0d1 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -36,9 +36,9 @@ import { yupUniqueStringList, } from "src/utils/yup"; import { formikUtils } from "src/utils/form"; -import { Tag, TagSelect } from "src/components/Tags/TagSelect"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect"; +import { useTagsEdit } from "src/hooks/tagsEdit"; interface IProps { gallery: Partial; @@ -58,7 +58,6 @@ export const GalleryEditPanel: React.FC = ({ const [scenes, setScenes] = useState([]); const [performers, setPerformers] = useState([]); - const [tags, setTags] = useState([]); const [studio, setStudio] = useState(null); const isNew = gallery.id === undefined; @@ -110,6 +109,11 @@ export const GalleryEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); + const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( + gallery.tags, + (ids) => formik.setFieldValue("tag_ids", ids) + ); + function onSetScenes(items: Scene[]) { setScenes(items); formik.setFieldValue( @@ -126,14 +130,6 @@ export const GalleryEditPanel: React.FC = ({ ); } - function onSetTags(items: Tag[]) { - setTags(items); - formik.setFieldValue( - "tag_ids", - items.map((item) => item.id) - ); - } - function onSetStudio(item: Studio | null) { setStudio(item); formik.setFieldValue("studio_id", item ? item.id : null); @@ -143,10 +139,6 @@ export const GalleryEditPanel: React.FC = ({ setPerformers(gallery.performers ?? []); }, [gallery.performers]); - useEffect(() => { - setTags(gallery.tags ?? []); - }, [gallery.tags]); - useEffect(() => { setStudio(gallery.studio ?? null); }, [gallery.studio]); @@ -339,23 +331,7 @@ export const GalleryEditPanel: React.FC = ({ } } - if (galleryData?.tags?.length) { - const idTags = galleryData.tags.filter((t) => { - return t.stored_id !== undefined && t.stored_id !== null; - }); - - if (idTags.length > 0) { - onSetTags( - idTags.map((p) => { - return { - id: p.stored_id!, - name: p.name ?? "", - aliases: [], - }; - }) - ); - } - } + updateTagsStateFromScraper(galleryData.tags ?? undefined); } async function onScrapeGalleryURL(url: string) { @@ -437,16 +413,7 @@ export const GalleryEditPanel: React.FC = ({ function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); - const control = ( - - ); - - return renderField("tag_ids", title, control, fullWidthProps); + return renderField("tag_ids", title, tagsControl(), fullWidthProps); } function renderDetailsField() { diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx index 1daa2f5e7..dd3357fec 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx @@ -15,18 +15,17 @@ import { import { ScrapedPerformersRow, ScrapedStudioRow, - ScrapedTagsRow, } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow"; import { sortStoredIdObjects } from "src/utils/data"; import { Performer } from "src/components/Performers/PerformerSelect"; import { useCreateScrapedPerformer, useCreateScrapedStudio, - useCreateScrapedTag, } from "src/components/Shared/ScrapeDialog/createObjects"; import { uniq } from "lodash-es"; import { Tag } from "src/components/Tags/TagSelect"; import { Studio } from "src/components/Studios/StudioSelect"; +import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags"; interface IGalleryScrapeDialogProps { gallery: Partial; @@ -99,19 +98,9 @@ export const GalleryScrapeDialog: React.FC = ({ scraped.performers?.filter((t) => !t.stored_id) ?? [] ); - const [tags, setTags] = useState>( - new ObjectListScrapeResult( - sortStoredIdObjects( - galleryTags.map((t) => ({ - stored_id: t.id, - name: t.name, - })) - ), - sortStoredIdObjects(scraped.tags ?? undefined) - ) - ); - const [newTags, setNewTags] = useState( - scraped.tags?.filter((t) => !t.stored_id) ?? [] + const { tags, newTags, scrapedTagsRow } = useScrapedTags( + galleryTags, + scraped.tags ); const [details, setDetails] = useState>( @@ -131,13 +120,6 @@ export const GalleryScrapeDialog: React.FC = ({ setNewObjects: setNewPerformers, }); - const createNewTag = useCreateScrapedTag({ - scrapeResult: tags, - setScrapeResult: setTags, - newObjects: newTags, - setNewObjects: setNewTags, - }); - // don't show the dialog if nothing was scraped if ( [ @@ -218,13 +200,7 @@ export const GalleryScrapeDialog: React.FC = ({ newObjects={newPerformers} onCreateNew={createNewPerformer} /> - setTags(value)} - newObjects={newTags} - onCreateNew={createNewTag} - /> + {scrapedTagsRow} = ({ const [galleries, setGalleries] = useState([]); const [performers, setPerformers] = useState([]); - const [tags, setTags] = useState([]); const [studio, setStudio] = useState(null); useEffect(() => { @@ -98,6 +97,10 @@ export const ImageEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); + const { tagsControl } = useTagsEdit(image.tags, (ids) => + formik.setFieldValue("tag_ids", ids) + ); + function onSetGalleries(items: Gallery[]) { setGalleries(items); formik.setFieldValue( @@ -114,14 +117,6 @@ export const ImageEditPanel: React.FC = ({ ); } - function onSetTags(items: Tag[]) { - setTags(items); - formik.setFieldValue( - "tag_ids", - items.map((item) => item.id) - ); - } - function onSetStudio(item: Studio | null) { setStudio(item); formik.setFieldValue("studio_id", item ? item.id : null); @@ -131,10 +126,6 @@ export const ImageEditPanel: React.FC = ({ setPerformers(image.performers ?? []); }, [image.performers]); - useEffect(() => { - setTags(image.tags ?? []); - }, [image.tags]); - useEffect(() => { setStudio(image.studio ?? null); }, [image.studio]); @@ -233,16 +224,7 @@ export const ImageEditPanel: React.FC = ({ function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); - const control = ( - - ); - - return renderField("tag_ids", title, control, fullWidthProps); + return renderField("tag_ids", title, tagsControl(), fullWidthProps); } function renderDetailsField() { diff --git a/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx b/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx index ba46166c8..af48cbeaf 100644 --- a/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx +++ b/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx @@ -9,11 +9,15 @@ import { useToast } from "src/hooks/Toast"; import * as FormUtils from "src/utils/form"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { + getAggregateInputIDs, getAggregateInputValue, getAggregateRating, getAggregateStudioId, + getAggregateTagIds, } from "src/utils/bulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; +import { isEqual } from "lodash-es"; +import { MultiSet } from "../Shared/MultiSet"; interface IListOperationProps { selected: GQL.MovieDataFragment[]; @@ -29,6 +33,12 @@ export const EditMoviesDialog: React.FC = ( const [studioId, setStudioId] = useState(); const [director, setDirector] = useState(); + const [tagMode, setTagMode] = React.useState( + GQL.BulkUpdateIdMode.Add + ); + const [tagIds, setTagIds] = useState(); + const [existingTagIds, setExistingTagIds] = useState(); + const [updateMovies] = useBulkMovieUpdate(getMovieInput()); const [isUpdating, setIsUpdating] = useState(false); @@ -36,6 +46,7 @@ export const EditMoviesDialog: React.FC = ( function getMovieInput(): GQL.BulkMovieUpdateInput { const aggregateRating = getAggregateRating(props.selected); const aggregateStudioId = getAggregateStudioId(props.selected); + const aggregateTagIds = getAggregateTagIds(props.selected); const movieInput: GQL.BulkMovieUpdateInput = { ids: props.selected.map((movie) => movie.id), @@ -45,6 +56,7 @@ export const EditMoviesDialog: React.FC = ( // if rating is undefined movieInput.rating100 = getAggregateInputValue(rating100, aggregateRating); movieInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); + movieInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); return movieInput; } @@ -72,14 +84,18 @@ export const EditMoviesDialog: React.FC = ( const state = props.selected; let updateRating: number | undefined; let updateStudioId: string | undefined; + let updateTagIds: string[] = []; let updateDirector: string | undefined; let first = true; state.forEach((movie: GQL.MovieDataFragment) => { + const movieTagIDs = (movie.tags ?? []).map((p) => p.id).sort(); + if (first) { first = false; updateRating = movie.rating100 ?? undefined; updateStudioId = movie.studio?.id ?? undefined; + updateTagIds = movieTagIDs; updateDirector = movie.director ?? undefined; } else { if (movie.rating100 !== updateRating) { @@ -91,11 +107,15 @@ export const EditMoviesDialog: React.FC = ( if (movie.director !== updateDirector) { updateDirector = undefined; } + if (!isEqual(movieTagIDs, updateTagIds)) { + updateTagIds = []; + } } }); setRating(updateRating); setStudioId(updateStudioId); + setExistingTagIds(updateTagIds); setDirector(updateDirector); }, [props.selected]); @@ -158,6 +178,20 @@ export const EditMoviesDialog: React.FC = ( placeholder={intl.formatMessage({ id: "director" })} /> + + + + + setTagIds(itemIDs)} + onSetMode={(newMode) => setTagMode(newMode)} + existingIds={existingTagIds ?? []} + ids={tagIds ?? []} + mode={tagMode} + /> + ); diff --git a/ui/v2.5/src/components/Movies/MovieCard.tsx b/ui/v2.5/src/components/Movies/MovieCard.tsx index d51735926..1f763649e 100644 --- a/ui/v2.5/src/components/Movies/MovieCard.tsx +++ b/ui/v2.5/src/components/Movies/MovieCard.tsx @@ -4,11 +4,11 @@ import * as GQL from "src/core/generated-graphql"; import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard"; import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; -import { SceneLink } from "../Shared/TagLink"; +import { SceneLink, TagLink } from "../Shared/TagLink"; import { TruncatedText } from "../Shared/TruncatedText"; import { FormattedMessage } from "react-intl"; import { RatingBanner } from "../Shared/RatingBanner"; -import { faPlayCircle } from "@fortawesome/free-solid-svg-icons"; +import { faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons"; import ScreenUtils from "src/utils/screen"; interface IProps { @@ -20,37 +20,44 @@ interface IProps { onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; } -export const MovieCard: React.FC = (props: IProps) => { +export const MovieCard: React.FC = ({ + movie, + sceneIndex, + containerWidth, + selecting, + selected, + onSelectedChanged, +}) => { const [cardWidth, setCardWidth] = useState(); useEffect(() => { - if (!props.containerWidth || ScreenUtils.isMobile()) return; + if (!containerWidth || ScreenUtils.isMobile()) return; let preferredCardWidth = 250; let fittedCardWidth = calculateCardWidth( - props.containerWidth, + containerWidth, preferredCardWidth! ); setCardWidth(fittedCardWidth); - }, [props, props.containerWidth]); + }, [containerWidth]); function maybeRenderSceneNumber() { - if (!props.sceneIndex) return; + if (!sceneIndex) return; return ( <>
- #{props.sceneIndex} + #{sceneIndex} ); } function maybeRenderScenesPopoverButton() { - if (props.movie.scenes.length === 0) return; + if (movie.scenes.length === 0) return; - const popoverContent = props.movie.scenes.map((scene) => ( + const popoverContent = movie.scenes.map((scene) => ( )); @@ -62,20 +69,38 @@ export const MovieCard: React.FC = (props: IProps) => { > + + ); + } + + function maybeRenderTagPopoverButton() { + if (movie.tags.length <= 0) return; + + const popoverContent = movie.tags.map((tag) => ( + + )); + + return ( + + ); } function maybeRenderPopoverButtonGroup() { - if (props.sceneIndex || props.movie.scenes.length > 0) { + if (sceneIndex || movie.scenes.length > 0 || movie.tags.length > 0) { return ( <> {maybeRenderSceneNumber()}
{maybeRenderScenesPopoverButton()} + {maybeRenderTagPopoverButton()} ); @@ -85,34 +110,34 @@ export const MovieCard: React.FC = (props: IProps) => { return ( {props.movie.name - + } details={
- {props.movie.date} + {movie.date}
} - selected={props.selected} - selecting={props.selecting} - onSelectedChanged={props.onSelectedChanged} + selected={selected} + selecting={selecting} + onSelectedChanged={onSelectedChanged} popovers={maybeRenderPopoverButtonGroup()} /> ); diff --git a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx index 723b1a7ac..69aecd20d 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx @@ -305,6 +305,7 @@ const MoviePage: React.FC = ({ movie }) => { return ( ); diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx index 97957f7f8..7c5a9cf3a 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx @@ -5,19 +5,54 @@ import TextUtils from "src/utils/text"; import { DetailItem } from "src/components/Shared/DetailItem"; import { Link } from "react-router-dom"; import { DirectorLink } from "src/components/Shared/Link"; +import { TagLink } from "src/components/Shared/TagLink"; interface IMovieDetailsPanel { movie: GQL.MovieDataFragment; + collapsed?: boolean; fullWidth?: boolean; } export const MovieDetailsPanel: React.FC = ({ movie, + collapsed, fullWidth, }) => { // Network state const intl = useIntl(); + function renderTagsField() { + if (!movie.tags.length) { + return; + } + return ( +
    + {(movie.tags ?? []).map((tag) => ( + + ))} +
+ ); + } + + function maybeRenderExtraDetails() { + if (!collapsed) { + return ( + <> + + + + ); + } + } + return (
= ({ } fullWidth={fullWidth} /> - + {maybeRenderExtraDetails()}
); }; diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx index 5b9bac5f8..5cd4cda79 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx @@ -25,6 +25,7 @@ import { yupUniqueStringList, } from "src/utils/yup"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; +import { useTagsEdit } from "src/hooks/tagsEdit"; interface IMovieEditPanel { movie: Partial; @@ -66,6 +67,7 @@ export const MovieEditPanel: React.FC = ({ duration: yup.number().integer().min(0).nullable().defined(), date: yupDateString(intl), studio_id: yup.string().required().nullable(), + tag_ids: yup.array(yup.string().required()).defined(), director: yup.string().ensure(), urls: yupUniqueStringList(intl), synopsis: yup.string().ensure(), @@ -79,6 +81,7 @@ export const MovieEditPanel: React.FC = ({ duration: movie?.duration ?? null, date: movie?.date ?? "", studio_id: movie?.studio?.id ?? null, + tag_ids: (movie?.tags ?? []).map((t) => t.id), director: movie?.director ?? "", urls: movie?.urls ?? [], synopsis: movie?.synopsis ?? "", @@ -93,6 +96,11 @@ export const MovieEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); + const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( + movie.tags, + (ids) => formik.setFieldValue("tag_ids", ids) + ); + function onSetStudio(item: Studio | null) { setStudio(item); formik.setFieldValue("studio_id", item ? item.id : null); @@ -159,6 +167,7 @@ export const MovieEditPanel: React.FC = ({ if (state.urls) { formik.setFieldValue("urls", state.urls); } + updateTagsStateFromScraper(state.tags ?? undefined); if (state.front_image) { // image is a base64 string @@ -231,6 +240,7 @@ export const MovieEditPanel: React.FC = ({ { onScrapeDialogClosed(m); @@ -351,6 +361,11 @@ export const MovieEditPanel: React.FC = ({ return renderField("studio_id", title, control); } + function renderTagsField() { + const title = intl.formatMessage({ id: "tags" }); + return renderField("tag_ids", title, tagsControl()); + } + // TODO: CSS class return (
@@ -383,6 +398,7 @@ export const MovieEditPanel: React.FC = ({ {renderInputField("director")} {renderURLListField("urls", onScrapeMovieURL, urlScrapable)} {renderInputField("synopsis", "textarea")} + {renderTagsField()} ; movieStudio: Studio | null; + movieTags: Tag[]; scraped: GQL.ScrapedMovie; onClose: (scrapedMovie?: GQL.ScrapedMovie) => void; } -export const MovieScrapeDialog: React.FC = ( - props: IMovieScrapeDialogProps -) => { +export const MovieScrapeDialog: React.FC = ({ + movie, + movieStudio, + movieTags, + scraped, + onClose, +}) => { const intl = useIntl(); const [name, setName] = useState>( - new ScrapeResult(props.movie.name, props.scraped.name) + new ScrapeResult(movie.name, scraped.name) ); const [aliases, setAliases] = useState>( - new ScrapeResult(props.movie.aliases, props.scraped.aliases) + new ScrapeResult(movie.aliases, scraped.aliases) ); const [duration, setDuration] = useState>( new ScrapeResult( - TextUtils.secondsToTimestamp(props.movie.duration || 0), + TextUtils.secondsToTimestamp(movie.duration || 0), // convert seconds to string if it's a number - props.scraped.duration && !isNaN(+props.scraped.duration) - ? TextUtils.secondsToTimestamp(parseInt(props.scraped.duration, 10)) - : props.scraped.duration + scraped.duration && !isNaN(+scraped.duration) + ? TextUtils.secondsToTimestamp(parseInt(scraped.duration, 10)) + : scraped.duration ) ); const [date, setDate] = useState>( - new ScrapeResult(props.movie.date, props.scraped.date) + new ScrapeResult(movie.date, scraped.date) ); const [director, setDirector] = useState>( - new ScrapeResult(props.movie.director, props.scraped.director) + new ScrapeResult(movie.director, scraped.director) ); const [synopsis, setSynopsis] = useState>( - new ScrapeResult(props.movie.synopsis, props.scraped.synopsis) + new ScrapeResult(movie.synopsis, scraped.synopsis) ); const [studio, setStudio] = useState>( new ObjectScrapeResult( - props.movieStudio + movieStudio ? { - stored_id: props.movieStudio.id, - name: props.movieStudio.name, + stored_id: movieStudio.id, + name: movieStudio.name, } : undefined, - props.scraped.studio?.stored_id ? props.scraped.studio : undefined + scraped.studio?.stored_id ? scraped.studio : undefined ) ); const [urls, setURLs] = useState>( new ScrapeResult( - props.movie.urls, - props.scraped.urls - ? uniq((props.movie.urls ?? []).concat(props.scraped.urls ?? [])) + movie.urls, + scraped.urls + ? uniq((movie.urls ?? []).concat(scraped.urls ?? [])) : undefined ) ); const [frontImage, setFrontImage] = useState>( - new ScrapeResult(props.movie.front_image, props.scraped.front_image) + new ScrapeResult(movie.front_image, scraped.front_image) ); const [backImage, setBackImage] = useState>( - new ScrapeResult(props.movie.back_image, props.scraped.back_image) + new ScrapeResult(movie.back_image, scraped.back_image) ); const [newStudio, setNewStudio] = useState( - props.scraped.studio && !props.scraped.studio.stored_id - ? props.scraped.studio - : undefined + scraped.studio && !scraped.studio.stored_id ? scraped.studio : undefined ); const createNewStudio = useCreateScrapedStudio({ @@ -93,6 +98,11 @@ export const MovieScrapeDialog: React.FC = ( setNewObject: setNewStudio, }); + const { tags, newTags, scrapedTagsRow } = useScrapedTags( + movieTags, + scraped.tags + ); + const allFields = [ name, aliases, @@ -101,17 +111,21 @@ export const MovieScrapeDialog: React.FC = ( director, synopsis, studio, + tags, urls, frontImage, backImage, ]; // don't show the dialog if nothing was scraped - if (allFields.every((r) => !r.scraped) && !newStudio) { - props.onClose(); + if ( + allFields.every((r) => !r.scraped) && + !newStudio && + newTags.length === 0 + ) { + onClose(); return <>; } - // todo: reenable function makeNewScrapedItem(): GQL.ScrapedMovie { const newStudioValue = studio.getNewValue(); const durationString = duration.getNewValue(); @@ -124,6 +138,7 @@ export const MovieScrapeDialog: React.FC = ( director: director.getNewValue(), synopsis: synopsis.getNewValue(), studio: newStudioValue, + tags: tags.getNewValue(), urls: urls.getNewValue(), front_image: frontImage.getNewValue(), back_image: backImage.getNewValue(), @@ -176,6 +191,7 @@ export const MovieScrapeDialog: React.FC = ( result={urls} onChange={(value) => setURLs(value)} /> + {scrapedTagsRow} = ( )} renderScrapeRows={renderScrapeRows} onClose={(apply) => { - props.onClose(apply ? makeNewScrapedItem() : undefined); + onClose(apply ? makeNewScrapedItem() : undefined); }} /> ); diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index cef86ecd5..dc38e53ea 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { Button, Form, Badge, Dropdown } from "react-bootstrap"; +import { Button, Form, Dropdown } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; @@ -8,13 +8,11 @@ import { useListPerformerScrapers, queryScrapePerformer, mutateReloadScrapers, - useTagCreate, queryScrapePerformerURL, } from "src/core/StashService"; import { Icon } from "src/components/Shared/Icon"; import { ImageInput } from "src/components/Shared/ImageInput"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; -import { CollapseButton } from "src/components/Shared/CollapseButton"; import { CountrySelect } from "src/components/Shared/CountrySelect"; import { URLField } from "src/components/Shared/URLField"; import ImageUtils from "src/utils/image"; @@ -38,7 +36,7 @@ import { PerformerScrapeDialog } from "./PerformerScrapeDialog"; import PerformerScrapeModal from "./PerformerScrapeModal"; import PerformerStashBoxModal, { IStashBox } from "./PerformerStashBoxModal"; import cx from "classnames"; -import { faPlus, faSyncAlt } from "@fortawesome/free-solid-svg-icons"; +import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import isEqual from "lodash-es/isEqual"; import { formikUtils } from "src/utils/form"; import { @@ -48,7 +46,7 @@ import { yupDateString, yupUniqueAliases, } from "src/utils/yup"; -import { Tag, TagSelect } from "src/components/Tags/TagSelect"; +import { useTagsEdit } from "src/hooks/tagsEdit"; const isScraper = ( scraper: GQL.Scraper | GQL.StashBox @@ -77,14 +75,11 @@ export const PerformerEditPanel: React.FC = ({ // Editing state const [scraper, setScraper] = useState(); - const [newTags, setNewTags] = useState(); const [isScraperModalOpen, setIsScraperModalOpen] = useState(false); // Network state const [isLoading, setIsLoading] = useState(false); - const [tags, setTags] = useState([]); - const Scrapers = useListPerformerScrapers(); const [queryableScrapers, setQueryableScrapers] = useState([]); @@ -92,7 +87,6 @@ export const PerformerEditPanel: React.FC = ({ useState(); const { configuration: stashConfig } = React.useContext(ConfigurationContext); - const [createTag] = useTagCreate(); const intl = useIntl(); const schema = yup.object({ @@ -163,17 +157,10 @@ export const PerformerEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); - function onSetTags(items: Tag[]) { - setTags(items); - formik.setFieldValue( - "tag_ids", - items.map((item) => item.id) - ); - } - - useEffect(() => { - setTags(performer.tags ?? []); - }, [performer.tags]); + const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( + performer.tags, + (ids) => formik.setFieldValue("tag_ids", ids) + ); function translateScrapedGender(scrapedGender?: string) { if (!scrapedGender) { @@ -207,43 +194,6 @@ export const PerformerEditPanel: React.FC = ({ } } - async function createNewTag(toCreate: GQL.ScrapedTag) { - const tagInput: GQL.TagCreateInput = { name: toCreate.name ?? "" }; - try { - const result = await createTag({ - variables: { - input: tagInput, - }, - }); - - if (!result.data?.tagCreate) { - Toast.error(new Error("Failed to create tag")); - return; - } - - // add the new tag to the new tags value - const newTagIds = formik.values.tag_ids.concat([ - result.data.tagCreate.id, - ]); - formik.setFieldValue("tag_ids", newTagIds); - - // remove the tag from the list - const newTagsClone = newTags!.concat(); - const pIndex = newTagsClone.indexOf(toCreate); - newTagsClone.splice(pIndex, 1); - - setNewTags(newTagsClone); - - Toast.success( - - Created tag: {toCreate.name} - - ); - } catch (e) { - Toast.error(e); - } - } - function updatePerformerEditStateFromScraper( state: Partial ) { @@ -312,20 +262,7 @@ export const PerformerEditPanel: React.FC = ({ formik.setFieldValue("circumcised", newCircumcised); } } - if (state.tags) { - // map tags to their ids and filter out those not found - onSetTags( - state.tags.map((p) => { - return { - id: p.stored_id!, - name: p.name ?? "", - aliases: [], - }; - }) - ); - - setNewTags(state.tags.filter((t) => !t.stored_id)); - } + updateTagsStateFromScraper(state.tags ?? undefined); // image is a base64 string // #404: don't overwrite image if it has been modified by the user @@ -702,59 +639,10 @@ export const PerformerEditPanel: React.FC = ({ return renderField("url", title, control); } - - function renderNewTags() { - if (!newTags || newTags.length === 0) { - return; - } - - const ret = ( - <> - {newTags.map((t) => ( - createNewTag(t)} - > - {t.name} - - - ))} - - ); - - const minCollapseLength = 10; - - if (newTags.length >= minCollapseLength) { - return ( - - {ret} - - ); - } - - return ret; - } - function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); - const control = ( - <> - - {renderNewTags()} - - ); - - return renderField("tag_ids", title, control); + return renderField("tag_ids", title, tagsControl()); } return ( diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index 00d1d2a0e..dbc4c5108 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -21,14 +21,9 @@ import { stringToCircumcised, } from "src/utils/circumcised"; import { IStashBox } from "./PerformerStashBoxModal"; -import { - ObjectListScrapeResult, - ScrapeResult, -} from "src/components/Shared/ScrapeDialog/scrapeResult"; -import { ScrapedTagsRow } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow"; -import { sortStoredIdObjects } from "src/utils/data"; +import { ScrapeResult } from "src/components/Shared/ScrapeDialog/scrapeResult"; import { Tag } from "src/components/Tags/TagSelect"; -import { useCreateScrapedTag } from "src/components/Shared/ScrapeDialog/createObjects"; +import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags"; function renderScrapedGender( result: ScrapeResult, @@ -304,29 +299,11 @@ export const PerformerScrapeDialog: React.FC = ( ) ); - const [tags, setTags] = useState>( - new ObjectListScrapeResult( - sortStoredIdObjects( - props.performerTags.map((t) => ({ - stored_id: t.id, - name: t.name, - })) - ), - sortStoredIdObjects(props.scraped.tags ?? undefined) - ) + const { tags, newTags, scrapedTagsRow } = useScrapedTags( + props.performerTags, + props.scraped.tags ); - const [newTags, setNewTags] = useState( - props.scraped.tags?.filter((t) => !t.stored_id) ?? [] - ); - - const createNewTag = useCreateScrapedTag({ - scrapeResult: tags, - setScrapeResult: setTags, - newObjects: newTags, - setNewObjects: setNewTags, - }); - const [image, setImage] = useState>( new ScrapeResult( props.performer.image, @@ -525,13 +502,7 @@ export const PerformerScrapeDialog: React.FC = ( result={details} onChange={(value) => setDetails(value)} /> - setTags(value)} - newObjects={newTags} - onCreateNew={createNewTag} - /> + {scrapedTagsRow} import("./SceneScrapeDialog")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); @@ -76,7 +76,6 @@ export const SceneEditPanel: React.FC = ({ const [galleries, setGalleries] = useState([]); const [performers, setPerformers] = useState([]); const [movies, setMovies] = useState([]); - const [tags, setTags] = useState([]); const [studio, setStudio] = useState(null); const Scrapers = useListSceneScrapers(); @@ -108,10 +107,6 @@ export const SceneEditPanel: React.FC = ({ setMovies(scene.movies?.map((m) => m.movie) ?? []); }, [scene.movies]); - useEffect(() => { - setTags(scene.tags ?? []); - }, [scene.tags]); - useEffect(() => { setStudio(scene.studio ?? null); }, [scene.studio]); @@ -174,6 +169,11 @@ export const SceneEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); + const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( + scene.tags, + (ids) => formik.setFieldValue("tag_ids", ids) + ); + const coverImagePreview = useMemo(() => { const sceneImage = scene.paths?.screenshot; const formImage = formik.values.cover_image; @@ -214,14 +214,6 @@ export const SceneEditPanel: React.FC = ({ ); } - function onSetTags(items: Tag[]) { - setTags(items); - formik.setFieldValue( - "tag_ids", - items.map((item) => item.id) - ); - } - function onSetStudio(item: Studio | null) { setStudio(item); formik.setFieldValue("studio_id", item ? item.id : null); @@ -593,23 +585,7 @@ export const SceneEditPanel: React.FC = ({ } } - if (updatedScene?.tags?.length) { - const idTags = updatedScene.tags.filter((p) => { - return p.stored_id !== undefined && p.stored_id !== null; - }); - - if (idTags.length > 0) { - onSetTags( - idTags.map((p) => { - return { - id: p.stored_id!, - name: p.name ?? "", - aliases: [], - }; - }) - ); - } - } + updateTagsStateFromScraper(updatedScene.tags ?? undefined); if (updatedScene.image) { // image is a base64 string @@ -771,16 +747,7 @@ export const SceneEditPanel: React.FC = ({ function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); - const control = ( - - ); - - return renderField("tag_ids", title, control, fullWidthProps); + return renderField("tag_ids", title, tagsControl(), fullWidthProps); } function renderDetailsField() { diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx index 91bc9457c..80ad9850f 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx @@ -20,17 +20,16 @@ import { ScrapedMoviesRow, ScrapedPerformersRow, ScrapedStudioRow, - ScrapedTagsRow, } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow"; import { useCreateScrapedMovie, useCreateScrapedPerformer, useCreateScrapedStudio, - useCreateScrapedTag, } from "src/components/Shared/ScrapeDialog/createObjects"; import { Tag } from "src/components/Tags/TagSelect"; import { Studio } from "src/components/Studios/StudioSelect"; import { Movie } from "src/components/Movies/MovieSelect"; +import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags"; interface ISceneScrapeDialogProps { scene: Partial; @@ -132,19 +131,9 @@ export const SceneScrapeDialog: React.FC = ({ scraped.movies?.filter((t) => !t.stored_id) ?? [] ); - const [tags, setTags] = useState>( - new ObjectListScrapeResult( - sortStoredIdObjects( - sceneTags.map((t) => ({ - stored_id: t.id, - name: t.name, - })) - ), - sortStoredIdObjects(scraped.tags ?? undefined) - ) - ); - const [newTags, setNewTags] = useState( - scraped.tags?.filter((t) => !t.stored_id) ?? [] + const { tags, newTags, scrapedTagsRow } = useScrapedTags( + sceneTags, + scraped.tags ); const [details, setDetails] = useState>( @@ -175,13 +164,6 @@ export const SceneScrapeDialog: React.FC = ({ setNewObjects: setNewMovies, }); - const createNewTag = useCreateScrapedTag({ - scrapeResult: tags, - setScrapeResult: setTags, - newObjects: newTags, - setNewObjects: setNewTags, - }); - const intl = useIntl(); // don't show the dialog if nothing was scraped @@ -278,13 +260,7 @@ export const SceneScrapeDialog: React.FC = ({ newObjects={newMovies} onCreateNew={createNewMovie} /> - setTags(value)} - newObjects={newTags} - onCreateNew={createNewTag} - /> + {scrapedTagsRow} +) { + const intl = useIntl(); + const [tags, setTags] = useState>( + new ObjectListScrapeResult( + sortStoredIdObjects( + existingTags.map((t) => ({ + stored_id: t.id, + name: t.name, + })) + ), + sortStoredIdObjects(scrapedTags ?? undefined) + ) + ); + + const [newTags, setNewTags] = useState( + scrapedTags?.filter((t) => !t.stored_id) ?? [] + ); + + const createNewTag = useCreateScrapedTag({ + scrapeResult: tags, + setScrapeResult: setTags, + newObjects: newTags, + setNewObjects: setNewTags, + }); + + const scrapedTagsRow = ( + setTags(value)} + newObjects={newTags} + onCreateNew={createNewTag} + /> + ); + + return { + tags, + newTags, + scrapedTagsRow, + }; +} diff --git a/ui/v2.5/src/components/Shared/TagLink.tsx b/ui/v2.5/src/components/Shared/TagLink.tsx index e97d0a957..9c2ed1cb3 100644 --- a/ui/v2.5/src/components/Shared/TagLink.tsx +++ b/ui/v2.5/src/components/Shared/TagLink.tsx @@ -191,7 +191,7 @@ export const GalleryLink: React.FC = ({ interface ITagLinkProps { tag: INamedObject; - linkType?: "scene" | "gallery" | "image" | "details" | "performer"; + linkType?: "scene" | "gallery" | "image" | "details" | "performer" | "movie"; className?: string; hoverPlacement?: Placement; showHierarchyIcon?: boolean; @@ -216,6 +216,8 @@ export const TagLink: React.FC = ({ return NavUtils.makeTagGalleriesUrl(tag); case "image": return NavUtils.makeTagImagesUrl(tag); + case "movie": + return NavUtils.makeTagMoviesUrl(tag); case "details": return NavUtils.makeTagUrl(tag.id ?? ""); } diff --git a/ui/v2.5/src/components/Tags/TagCard.tsx b/ui/v2.5/src/components/Tags/TagCard.tsx index cff2326b6..51444f999 100644 --- a/ui/v2.5/src/components/Tags/TagCard.tsx +++ b/ui/v2.5/src/components/Tags/TagCard.tsx @@ -223,6 +223,19 @@ export const TagCard: React.FC = ({ ); } + function maybeRenderMoviesPopoverButton() { + if (!tag.movie_count) return; + + return ( + + ); + } + function maybeRenderPopoverButtonGroup() { if (tag) { return ( @@ -232,6 +245,7 @@ export const TagCard: React.FC = ({ {maybeRenderScenesPopoverButton()} {maybeRenderImagesPopoverButton()} {maybeRenderGalleriesPopoverButton()} + {maybeRenderMoviesPopoverButton()} {maybeRenderSceneMarkersPopoverButton()} {maybeRenderPerformersPopoverButton()} diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index 81a60c0f2..aa10275b6 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -41,6 +41,7 @@ import { import { DetailImage } from "src/components/Shared/DetailImage"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; +import { TagMoviesPanel } from "./TagMoviesPanel"; interface IProps { tag: GQL.TagDataFragment; @@ -57,6 +58,7 @@ const validTabs = [ "scenes", "images", "galleries", + "movies", "markers", "performers", ] as const; @@ -101,6 +103,8 @@ const TagPage: React.FC = ({ tag, tabKey }) => { (showAllCounts ? tag.image_count_all : tag.image_count) ?? 0; const galleryCount = (showAllCounts ? tag.gallery_count_all : tag.gallery_count) ?? 0; + const movieCount = + (showAllCounts ? tag.movie_count_all : tag.movie_count) ?? 0; const sceneMarkerCount = (showAllCounts ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0; const performerCount = @@ -113,6 +117,8 @@ const TagPage: React.FC = ({ tag, tabKey }) => { ret = "images"; } else if (galleryCount != 0) { ret = "galleries"; + } else if (movieCount != 0) { + ret = "movies"; } else if (sceneMarkerCount != 0) { ret = "markers"; } else if (performerCount != 0) { @@ -121,7 +127,14 @@ const TagPage: React.FC = ({ tag, tabKey }) => { } return ret; - }, [sceneCount, imageCount, galleryCount, sceneMarkerCount, performerCount]); + }, [ + sceneCount, + imageCount, + galleryCount, + sceneMarkerCount, + performerCount, + movieCount, + ]); const setTabKey = useCallback( (newTabKey: string | null) => { @@ -463,6 +476,21 @@ const TagPage: React.FC = ({ tag, tabKey }) => { > + + {intl.formatMessage({ id: "movies" })} + + + } + > + + = ({ active, tag }) => { + const filterHook = useTagFilterHook(tag); + return ; +}; diff --git a/ui/v2.5/src/hooks/tagsEdit.tsx b/ui/v2.5/src/hooks/tagsEdit.tsx new file mode 100644 index 000000000..e44582911 --- /dev/null +++ b/ui/v2.5/src/hooks/tagsEdit.tsx @@ -0,0 +1,148 @@ +import * as GQL from "src/core/generated-graphql"; +import { useTagCreate } from "src/core/StashService"; +import { useEffect, useState } from "react"; +import { Tag, TagSelect } from "src/components/Tags/TagSelect"; +import { useToast } from "src/hooks/Toast"; +import { useIntl } from "react-intl"; +import { Badge, Button } from "react-bootstrap"; +import { Icon } from "src/components/Shared/Icon"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { CollapseButton } from "src/components/Shared/CollapseButton"; + +export function useTagsEdit( + srcTags: Tag[] | undefined, + setFieldValue: (ids: string[]) => void +) { + const intl = useIntl(); + const Toast = useToast(); + const [createTag] = useTagCreate(); + + const [tags, setTags] = useState([]); + const [newTags, setNewTags] = useState(); + + function onSetTags(items: Tag[]) { + setTags(items); + setFieldValue(items.map((item) => item.id)); + } + + useEffect(() => { + setTags(srcTags ?? []); + }, [srcTags]); + + async function createNewTag(toCreate: GQL.ScrapedTag) { + const tagInput: GQL.TagCreateInput = { name: toCreate.name ?? "" }; + try { + const result = await createTag({ + variables: { + input: tagInput, + }, + }); + + if (!result.data?.tagCreate) { + Toast.error(new Error("Failed to create tag")); + return; + } + + // add the new tag to the new tags value + const newTagIds = tags + .map((t) => t.id) + .concat([result.data.tagCreate.id]); + setFieldValue(newTagIds); + + // remove the tag from the list + const newTagsClone = newTags!.concat(); + const pIndex = newTagsClone.indexOf(toCreate); + newTagsClone.splice(pIndex, 1); + + setNewTags(newTagsClone); + + Toast.success( + intl.formatMessage( + { id: "toast.created_entity" }, + { + entity: intl.formatMessage({ id: "tag" }).toLocaleLowerCase(), + entity_name: toCreate.name, + } + ) + ); + } catch (e) { + Toast.error(e); + } + } + + function updateTagsStateFromScraper( + scrapedTags?: Pick[] + ) { + if (scrapedTags) { + // map tags to their ids and filter out those not found + onSetTags( + scrapedTags.map((p) => { + return { + id: p.stored_id!, + name: p.name ?? "", + aliases: [], + }; + }) + ); + + setNewTags(scrapedTags.filter((t) => !t.stored_id)); + } + } + + function renderNewTags() { + if (!newTags || newTags.length === 0) { + return; + } + + const ret = ( + <> + {newTags.map((t) => ( + createNewTag(t)} + > + {t.name} + + + ))} + + ); + + const minCollapseLength = 10; + + if (newTags.length >= minCollapseLength) { + return ( + + {ret} + + ); + } + + return ret; + } + + function tagsControl() { + return ( + <> + + {renderNewTags()} + + ); + } + + return { + tags, + onSetTags, + tagsControl, + updateTagsStateFromScraper, + }; +} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index d1072183a..61daff120 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1118,6 +1118,7 @@ "megabits_per_second": "{value} mbps", "metadata": "Metadata", "movie": "Movie", + "movie_count": "Movie Count", "movie_scene_number": "Scene Number", "movies": "Movies", "name": "Name", diff --git a/ui/v2.5/src/models/list-filter/movies.ts b/ui/v2.5/src/models/list-filter/movies.ts index 9a769e024..35e4a24e2 100644 --- a/ui/v2.5/src/models/list-filter/movies.ts +++ b/ui/v2.5/src/models/list-filter/movies.ts @@ -3,6 +3,7 @@ import { createDateCriterionOption, createMandatoryTimestampCriterionOption, createDurationCriterionOption, + createMandatoryNumberCriterionOption, } from "./criteria/criterion"; import { MovieIsMissingCriterionOption } from "./criteria/is-missing"; import { StudiosCriterionOption } from "./criteria/studios"; @@ -10,10 +11,18 @@ import { PerformersCriterionOption } from "./criteria/performers"; import { ListFilterOptions } from "./filter-options"; import { DisplayMode } from "./types"; import { RatingCriterionOption } from "./criteria/rating"; +import { TagsCriterionOption } from "./criteria/tags"; const defaultSortBy = "name"; -const sortByOptions = ["name", "random", "date", "duration", "rating"] +const sortByOptions = [ + "name", + "random", + "date", + "duration", + "rating", + "tag_count", +] .map(ListFilterOptions.createSortBy) .concat([ { @@ -33,6 +42,8 @@ const criterionOptions = [ RatingCriterionOption, PerformersCriterionOption, createDateCriterionOption("date"), + TagsCriterionOption, + createMandatoryNumberCriterionOption("tag_count"), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), ]; diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index fe1a906f0..9a9b71680 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -35,6 +35,10 @@ const sortByOptions = ["name", "random"] messageID: "scene_count", value: "scenes_count", }, + { + messageID: "movie_count", + value: "movies_count", + }, { messageID: "marker_count", value: "scene_markers_count", @@ -53,6 +57,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("image_count"), createMandatoryNumberCriterionOption("gallery_count"), createMandatoryNumberCriterionOption("performer_count"), + createMandatoryNumberCriterionOption("movie_count"), createMandatoryNumberCriterionOption("marker_count"), ParentTagsCriterionOption, new MandatoryNumberCriterionOption("parent_tag_count", "parent_count"), diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 4a0c49e17..9638c7e94 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -172,6 +172,7 @@ export type CriterionType = | "image_count" | "gallery_count" | "performer_count" + | "movie_count" | "death_year" | "url" | "interactive" diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index 1aece914c..e77f40a38 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -78,7 +78,7 @@ const makePerformerImagesUrl = ( }; export interface INamedObject { - id?: string; + id: string; name?: string; } @@ -262,8 +262,7 @@ const makeChildTagsUrl = (tag: Partial) => { return `/tags?${filter.makeQueryParameters()}`; }; -const makeTagScenesUrl = (tag: Partial) => { - if (!tag.id) return "#"; +function makeTagFilter(mode: GQL.FilterMode, tag: INamedObject) { const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { @@ -272,59 +271,31 @@ const makeTagScenesUrl = (tag: Partial) => { depth: 0, }; filter.criteria.push(criterion); - return `/scenes?${filter.makeQueryParameters()}`; + return filter.makeQueryParameters(); +} + +const makeTagScenesUrl = (tag: INamedObject) => { + return `/scenes?${makeTagFilter(GQL.FilterMode.Scenes, tag)}`; }; -const makeTagPerformersUrl = (tag: Partial) => { - if (!tag.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Performers, undefined); - const criterion = new TagsCriterion(TagsCriterionOption); - criterion.value = { - items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], - excluded: [], - depth: 0, - }; - filter.criteria.push(criterion); - return `/performers?${filter.makeQueryParameters()}`; +const makeTagPerformersUrl = (tag: INamedObject) => { + return `/performers?${makeTagFilter(GQL.FilterMode.Performers, tag)}`; }; -const makeTagSceneMarkersUrl = (tag: Partial) => { - if (!tag.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.SceneMarkers, undefined); - const criterion = new TagsCriterion(TagsCriterionOption); - criterion.value = { - items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], - excluded: [], - depth: 0, - }; - filter.criteria.push(criterion); - return `/scenes/markers?${filter.makeQueryParameters()}`; +const makeTagSceneMarkersUrl = (tag: INamedObject) => { + return `/scenes/markers?${makeTagFilter(GQL.FilterMode.SceneMarkers, tag)}`; }; -const makeTagGalleriesUrl = (tag: Partial) => { - if (!tag.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Galleries, undefined); - const criterion = new TagsCriterion(TagsCriterionOption); - criterion.value = { - items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], - excluded: [], - depth: 0, - }; - filter.criteria.push(criterion); - return `/galleries?${filter.makeQueryParameters()}`; +const makeTagGalleriesUrl = (tag: INamedObject) => { + return `/galleries?${makeTagFilter(GQL.FilterMode.Galleries, tag)}`; }; -const makeTagImagesUrl = (tag: Partial) => { - if (!tag.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Images, undefined); - const criterion = new TagsCriterion(TagsCriterionOption); - criterion.value = { - items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], - excluded: [], - depth: 0, - }; - filter.criteria.push(criterion); - return `/images?${filter.makeQueryParameters()}`; +const makeTagImagesUrl = (tag: INamedObject) => { + return `/images?${makeTagFilter(GQL.FilterMode.Images, tag)}`; +}; + +const makeTagMoviesUrl = (tag: INamedObject) => { + return `/movies?${makeTagFilter(GQL.FilterMode.Movies, tag)}`; }; type SceneMarkerDataFragment = Pick & { @@ -441,6 +412,7 @@ const NavUtils = { makeTagPerformersUrl, makeTagGalleriesUrl, makeTagImagesUrl, + makeTagMoviesUrl, makeScenesPHashMatchUrl, makeSceneMarkerUrl, makeMovieScenesUrl, From f26766033e03fba06f2b4bd9d74ea2f0f469b57e Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 18 Jun 2024 13:41:05 +1000 Subject: [PATCH 03/28] Performer urls (#4958) * Populate URLs from legacy fields * Return nil properly in xpath/json scrapers * Improve migration logging --- graphql/schema/types/performer.graphql | 28 +-- .../schema/types/scraped-performer.graphql | 14 +- internal/api/resolver_model_performer.go | 73 ++++++++ internal/api/resolver_mutation_performer.go | 159 +++++++++++++++++- internal/manager/task/migrate.go | 31 ++-- pkg/models/jsonschema/performer.go | 23 +-- pkg/models/mocks/PerformerReaderWriter.go | 23 +++ pkg/models/model_performer.go | 14 +- pkg/models/model_scraped_item.go | 72 +++++--- pkg/models/model_scraped_item_test.go | 6 +- pkg/models/performer.go | 14 +- pkg/models/repository_performer.go | 1 + pkg/performer/export.go | 9 +- pkg/performer/export_test.go | 10 +- pkg/performer/import.go | 22 ++- pkg/performer/url.go | 18 ++ pkg/scraper/json.go | 26 ++- pkg/scraper/performer.go | 51 +++--- pkg/scraper/postprocessing.go | 26 +++ pkg/scraper/scraper.go | 6 + pkg/scraper/stashbox/stash_box.go | 34 ++-- pkg/scraper/xpath.go | 26 ++- pkg/sqlite/anonymise.go | 16 +- pkg/sqlite/database.go | 2 +- .../migrations/62_performer_urls.up.sql | 155 +++++++++++++++++ pkg/sqlite/performer.go | 38 +++-- pkg/sqlite/performer_filter.go | 19 ++- pkg/sqlite/performer_test.go | 72 +++++--- pkg/sqlite/setup_test.go | 33 ++-- pkg/sqlite/tables.go | 9 + pkg/utils/url.go | 15 ++ pkg/utils/url_test.go | 47 ++++++ ui/v2.5/graphql/data/performer-slim.graphql | 4 +- ui/v2.5/graphql/data/performer.graphql | 4 +- ui/v2.5/graphql/data/scrapers.graphql | 8 +- .../Performers/EditPerformersDialog.tsx | 12 -- .../Performers/PerformerDetails/Performer.tsx | 84 ++++----- .../PerformerDetails/PerformerEditPanel.tsx | 40 +---- .../PerformerScrapeDialog.tsx | 43 ++--- .../PerformerDetails/PerformerScrapeModal.tsx | 4 +- .../components/Shared/ExternalLinksButton.tsx | 7 +- .../src/components/Tagger/PerformerModal.tsx | 50 +++++- ui/v2.5/src/components/Tagger/constants.ts | 6 +- ui/v2.5/src/components/Tagger/styles.scss | 6 + ui/v2.5/src/core/performers.ts | 4 +- .../models/list-filter/criteria/is-missing.ts | 2 - ui/v2.5/src/utils/text.ts | 5 - 47 files changed, 992 insertions(+), 379 deletions(-) create mode 100644 pkg/performer/url.go create mode 100644 pkg/sqlite/migrations/62_performer_urls.up.sql create mode 100644 pkg/utils/url.go create mode 100644 pkg/utils/url_test.go diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index c5d328425..d6d6b2696 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -16,10 +16,11 @@ type Performer { id: ID! name: String! disambiguation: String - url: String + url: String @deprecated(reason: "Use urls") + urls: [String!] gender: GenderEnum - twitter: String - instagram: String + twitter: String @deprecated(reason: "Use urls") + instagram: String @deprecated(reason: "Use urls") birthdate: String ethnicity: String country: String @@ -60,7 +61,8 @@ type Performer { input PerformerCreateInput { name: String! disambiguation: String - url: String + url: String @deprecated(reason: "Use urls") + urls: [String!] gender: GenderEnum birthdate: String ethnicity: String @@ -75,8 +77,8 @@ input PerformerCreateInput { tattoos: String piercings: String alias_list: [String!] - twitter: String - instagram: String + twitter: String @deprecated(reason: "Use urls") + instagram: String @deprecated(reason: "Use urls") favorite: Boolean tag_ids: [ID!] "This should be a URL or a base64 encoded data URL" @@ -95,7 +97,8 @@ input PerformerUpdateInput { id: ID! name: String disambiguation: String - url: String + url: String @deprecated(reason: "Use urls") + urls: [String!] gender: GenderEnum birthdate: String ethnicity: String @@ -110,8 +113,8 @@ input PerformerUpdateInput { tattoos: String piercings: String alias_list: [String!] - twitter: String - instagram: String + twitter: String @deprecated(reason: "Use urls") + instagram: String @deprecated(reason: "Use urls") favorite: Boolean tag_ids: [ID!] "This should be a URL or a base64 encoded data URL" @@ -135,7 +138,8 @@ input BulkPerformerUpdateInput { clientMutationId: String ids: [ID!] disambiguation: String - url: String + url: String @deprecated(reason: "Use urls") + urls: BulkUpdateStrings gender: GenderEnum birthdate: String ethnicity: String @@ -150,8 +154,8 @@ input BulkPerformerUpdateInput { tattoos: String piercings: String alias_list: BulkUpdateStrings - twitter: String - instagram: String + twitter: String @deprecated(reason: "Use urls") + instagram: String @deprecated(reason: "Use urls") favorite: Boolean tag_ids: BulkUpdateIds # rating expressed as 1-100 diff --git a/graphql/schema/types/scraped-performer.graphql b/graphql/schema/types/scraped-performer.graphql index 92ba94d32..487c89516 100644 --- a/graphql/schema/types/scraped-performer.graphql +++ b/graphql/schema/types/scraped-performer.graphql @@ -5,9 +5,10 @@ type ScrapedPerformer { name: String disambiguation: String gender: String - url: String - twitter: String - instagram: String + url: String @deprecated(reason: "use urls") + urls: [String!] + twitter: String @deprecated(reason: "use urls") + instagram: String @deprecated(reason: "use urls") birthdate: String ethnicity: String country: String @@ -40,9 +41,10 @@ input ScrapedPerformerInput { name: String disambiguation: String gender: String - url: String - twitter: String - instagram: String + url: String @deprecated(reason: "use urls") + urls: [String!] + twitter: String @deprecated(reason: "use urls") + instagram: String @deprecated(reason: "use urls") birthdate: String ethnicity: String country: String diff --git a/internal/api/resolver_model_performer.go b/internal/api/resolver_model_performer.go index 6164ff297..58fac77ff 100644 --- a/internal/api/resolver_model_performer.go +++ b/internal/api/resolver_model_performer.go @@ -24,6 +24,79 @@ func (r *performerResolver) AliasList(ctx context.Context, obj *models.Performer return obj.Aliases.List(), nil } +func (r *performerResolver) URL(ctx context.Context, obj *models.Performer) (*string, error) { + if !obj.URLs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadURLs(ctx, r.repository.Performer) + }); err != nil { + return nil, err + } + } + + urls := obj.URLs.List() + if len(urls) == 0 { + return nil, nil + } + + return &urls[0], nil +} + +func (r *performerResolver) Twitter(ctx context.Context, obj *models.Performer) (*string, error) { + if !obj.URLs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadURLs(ctx, r.repository.Performer) + }); err != nil { + return nil, err + } + } + + urls := obj.URLs.List() + + // find the first twitter url + for _, url := range urls { + if performer.IsTwitterURL(url) { + u := url + return &u, nil + } + } + + return nil, nil +} + +func (r *performerResolver) Instagram(ctx context.Context, obj *models.Performer) (*string, error) { + if !obj.URLs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadURLs(ctx, r.repository.Performer) + }); err != nil { + return nil, err + } + } + + urls := obj.URLs.List() + + // find the first instagram url + for _, url := range urls { + if performer.IsInstagramURL(url) { + u := url + return &u, nil + } + } + + return nil, nil +} + +func (r *performerResolver) Urls(ctx context.Context, obj *models.Performer) ([]string, error) { + if !obj.URLs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadURLs(ctx, r.repository.Performer) + }); err != nil { + return nil, err + } + } + + return obj.URLs.List(), nil +} + func (r *performerResolver) Height(ctx context.Context, obj *models.Performer) (*string, error) { if obj.Height != nil { ret := strconv.Itoa(*obj.Height) diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go index 202778e74..7263cc709 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -12,6 +12,11 @@ import ( "github.com/stashapp/stash/pkg/utils" ) +const ( + twitterURL = "https://twitter.com" + instagramURL = "https://instagram.com" +) + // used to refetch performer after hooks run func (r *mutationResolver) getPerformer(ctx context.Context, id int) (ret *models.Performer, err error) { if err := r.withTxn(ctx, func(ctx context.Context) error { @@ -35,7 +40,6 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per newPerformer.Name = input.Name newPerformer.Disambiguation = translator.string(input.Disambiguation) newPerformer.Aliases = models.NewRelatedStrings(input.AliasList) - newPerformer.URL = translator.string(input.URL) newPerformer.Gender = input.Gender newPerformer.Ethnicity = translator.string(input.Ethnicity) newPerformer.Country = translator.string(input.Country) @@ -47,8 +51,6 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per newPerformer.CareerLength = translator.string(input.CareerLength) newPerformer.Tattoos = translator.string(input.Tattoos) newPerformer.Piercings = translator.string(input.Piercings) - newPerformer.Twitter = translator.string(input.Twitter) - newPerformer.Instagram = translator.string(input.Instagram) newPerformer.Favorite = translator.bool(input.Favorite) newPerformer.Rating = input.Rating100 newPerformer.Details = translator.string(input.Details) @@ -58,6 +60,21 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per newPerformer.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag) newPerformer.StashIDs = models.NewRelatedStashIDs(input.StashIds) + newPerformer.URLs = models.NewRelatedStrings([]string{}) + if input.URL != nil { + newPerformer.URLs.Add(*input.URL) + } + if input.Twitter != nil { + newPerformer.URLs.Add(utils.URLFromHandle(*input.Twitter, twitterURL)) + } + if input.Instagram != nil { + newPerformer.URLs.Add(utils.URLFromHandle(*input.Instagram, instagramURL)) + } + + if input.Urls != nil { + newPerformer.URLs.Add(input.Urls...) + } + var err error newPerformer.Birthdate, err = translator.datePtr(input.Birthdate) @@ -112,6 +129,96 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per return r.getPerformer(ctx, newPerformer.ID) } +func (r *mutationResolver) validateNoLegacyURLs(translator changesetTranslator) error { + // ensure url/twitter/instagram are not included in the input + if translator.hasField("url") { + return fmt.Errorf("url field must not be included if urls is included") + } + if translator.hasField("twitter") { + return fmt.Errorf("twitter field must not be included if urls is included") + } + if translator.hasField("instagram") { + return fmt.Errorf("instagram field must not be included if urls is included") + } + + return nil +} + +func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURL, legacyTwitter, legacyInstagram models.OptionalString, updatedPerformer *models.PerformerPartial) error { + qb := r.repository.Performer + + // we need to be careful with URL/Twitter/Instagram + // treat URL as replacing the first non-Twitter/Instagram URL in the list + // twitter should replace any existing twitter URL + // instagram should replace any existing instagram URL + p, err := qb.Find(ctx, performerID) + if err != nil { + return err + } + + if err := p.LoadURLs(ctx, qb); err != nil { + return fmt.Errorf("loading performer URLs: %w", err) + } + + existingURLs := p.URLs.List() + + // performer partial URLs should be empty + if legacyURL.Set { + replaced := false + for i, url := range existingURLs { + if !performer.IsTwitterURL(url) && !performer.IsInstagramURL(url) { + existingURLs[i] = legacyURL.Value + replaced = true + break + } + } + + if !replaced { + existingURLs = append(existingURLs, legacyURL.Value) + } + } + + if legacyTwitter.Set { + value := utils.URLFromHandle(legacyTwitter.Value, twitterURL) + found := false + // find and replace the first twitter URL + for i, url := range existingURLs { + if performer.IsTwitterURL(url) { + existingURLs[i] = value + found = true + break + } + } + + if !found { + existingURLs = append(existingURLs, value) + } + } + if legacyInstagram.Set { + found := false + value := utils.URLFromHandle(legacyInstagram.Value, instagramURL) + // find and replace the first instagram URL + for i, url := range existingURLs { + if performer.IsInstagramURL(url) { + existingURLs[i] = value + found = true + break + } + } + + if !found { + existingURLs = append(existingURLs, value) + } + } + + updatedPerformer.URLs = &models.UpdateStrings{ + Values: existingURLs, + Mode: models.RelationshipUpdateModeSet, + } + + return nil +} + func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) { performerID, err := strconv.Atoi(input.ID) if err != nil { @@ -127,7 +234,6 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per updatedPerformer.Name = translator.optionalString(input.Name, "name") updatedPerformer.Disambiguation = translator.optionalString(input.Disambiguation, "disambiguation") - updatedPerformer.URL = translator.optionalString(input.URL, "url") updatedPerformer.Gender = translator.optionalString((*string)(input.Gender), "gender") updatedPerformer.Ethnicity = translator.optionalString(input.Ethnicity, "ethnicity") updatedPerformer.Country = translator.optionalString(input.Country, "country") @@ -139,8 +245,6 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") - updatedPerformer.Twitter = translator.optionalString(input.Twitter, "twitter") - updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram") updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite") updatedPerformer.Rating = translator.optionalInt(input.Rating100, "rating100") updatedPerformer.Details = translator.optionalString(input.Details, "details") @@ -149,6 +253,19 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") updatedPerformer.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids") + if translator.hasField("urls") { + // ensure url/twitter/instagram are not included in the input + if err := r.validateNoLegacyURLs(translator); err != nil { + return nil, err + } + + updatedPerformer.URLs = translator.updateStrings(input.Urls, "urls") + } + + legacyURL := translator.optionalString(input.URL, "url") + legacyTwitter := translator.optionalString(input.Twitter, "twitter") + legacyInstagram := translator.optionalString(input.Instagram, "instagram") + updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate") if err != nil { return nil, fmt.Errorf("converting birthdate: %w", err) @@ -186,6 +303,12 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Performer + if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set { + if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &updatedPerformer); err != nil { + return err + } + } + if err := performer.ValidateUpdate(ctx, performerID, updatedPerformer, qb); err != nil { return err } @@ -225,7 +348,7 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe updatedPerformer := models.NewPerformerPartial() updatedPerformer.Disambiguation = translator.optionalString(input.Disambiguation, "disambiguation") - updatedPerformer.URL = translator.optionalString(input.URL, "url") + updatedPerformer.Gender = translator.optionalString((*string)(input.Gender), "gender") updatedPerformer.Ethnicity = translator.optionalString(input.Ethnicity, "ethnicity") updatedPerformer.Country = translator.optionalString(input.Country, "country") @@ -237,8 +360,7 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") - updatedPerformer.Twitter = translator.optionalString(input.Twitter, "twitter") - updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram") + updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite") updatedPerformer.Rating = translator.optionalInt(input.Rating100, "rating100") updatedPerformer.Details = translator.optionalString(input.Details, "details") @@ -246,6 +368,19 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe updatedPerformer.Weight = translator.optionalInt(input.Weight, "weight") updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") + if translator.hasField("urls") { + // ensure url/twitter/instagram are not included in the input + if err := r.validateNoLegacyURLs(translator); err != nil { + return nil, err + } + + updatedPerformer.URLs = translator.updateStringsBulk(input.Urls, "urls") + } + + legacyURL := translator.optionalString(input.URL, "url") + legacyTwitter := translator.optionalString(input.Twitter, "twitter") + legacyInstagram := translator.optionalString(input.Instagram, "instagram") + updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate") if err != nil { return nil, fmt.Errorf("converting birthdate: %w", err) @@ -277,6 +412,12 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe qb := r.repository.Performer for _, performerID := range performerIDs { + if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set { + if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &updatedPerformer); err != nil { + return err + } + } + if err := performer.ValidateUpdate(ctx, performerID, updatedPerformer, qb); err != nil { return err } diff --git a/internal/manager/task/migrate.go b/internal/manager/task/migrate.go index 48ba15a26..37062329e 100644 --- a/internal/manager/task/migrate.go +++ b/internal/manager/task/migrate.go @@ -23,19 +23,27 @@ type MigrateJob struct { Database *sqlite.Database } +type databaseSchemaInfo struct { + CurrentSchemaVersion uint + RequiredSchemaVersion uint + StepsRequired uint +} + func (s *MigrateJob) Execute(ctx context.Context, progress *job.Progress) error { - required, err := s.required() + schemaInfo, err := s.required() if err != nil { return err } - if required == 0 { + if schemaInfo.StepsRequired == 0 { logger.Infof("database is already at the latest schema version") return nil } + logger.Infof("Migrating database from %d to %d", schemaInfo.CurrentSchemaVersion, schemaInfo.RequiredSchemaVersion) + // set the number of tasks = required steps + optimise - progress.SetTotal(int(required + 1)) + progress.SetTotal(int(schemaInfo.StepsRequired + 1)) database := s.Database @@ -79,28 +87,31 @@ func (s *MigrateJob) Execute(ctx context.Context, progress *job.Progress) error } } + logger.Infof("Database migration complete") + return nil } -func (s *MigrateJob) required() (uint, error) { +func (s *MigrateJob) required() (ret databaseSchemaInfo, err error) { database := s.Database m, err := sqlite.NewMigrator(database) if err != nil { - return 0, err + return } defer m.Close() - currentSchemaVersion := m.CurrentSchemaVersion() - targetSchemaVersion := m.RequiredSchemaVersion() + ret.CurrentSchemaVersion = m.CurrentSchemaVersion() + ret.RequiredSchemaVersion = m.RequiredSchemaVersion() - if targetSchemaVersion < currentSchemaVersion { + if ret.RequiredSchemaVersion < ret.CurrentSchemaVersion { // shouldn't happen - return 0, nil + return } - return targetSchemaVersion - currentSchemaVersion, nil + ret.StepsRequired = ret.RequiredSchemaVersion - ret.CurrentSchemaVersion + return } func (s *MigrateJob) runMigrations(ctx context.Context, progress *job.Progress) error { diff --git a/pkg/models/jsonschema/performer.go b/pkg/models/jsonschema/performer.go index 248cf9557..7ffa69983 100644 --- a/pkg/models/jsonschema/performer.go +++ b/pkg/models/jsonschema/performer.go @@ -34,16 +34,14 @@ func (s *StringOrStringList) UnmarshalJSON(data []byte) error { } type Performer struct { - Name string `json:"name,omitempty"` - Disambiguation string `json:"disambiguation,omitempty"` - Gender string `json:"gender,omitempty"` - URL string `json:"url,omitempty"` - Twitter string `json:"twitter,omitempty"` - Instagram string `json:"instagram,omitempty"` - Birthdate string `json:"birthdate,omitempty"` - Ethnicity string `json:"ethnicity,omitempty"` - Country string `json:"country,omitempty"` - EyeColor string `json:"eye_color,omitempty"` + Name string `json:"name,omitempty"` + Disambiguation string `json:"disambiguation,omitempty"` + Gender string `json:"gender,omitempty"` + URLs []string `json:"urls,omitempty"` + Birthdate string `json:"birthdate,omitempty"` + Ethnicity string `json:"ethnicity,omitempty"` + Country string `json:"country,omitempty"` + EyeColor string `json:"eye_color,omitempty"` // this should be int, but keeping string for backwards compatibility Height string `json:"height,omitempty"` Measurements string `json:"measurements,omitempty"` @@ -66,6 +64,11 @@ type Performer struct { Weight int `json:"weight,omitempty"` StashIDs []models.StashID `json:"stash_ids,omitempty"` IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` + + // deprecated - for import only + URL string `json:"url,omitempty"` + Twitter string `json:"twitter,omitempty"` + Instagram string `json:"instagram,omitempty"` } func (s Performer) Filename() string { diff --git a/pkg/models/mocks/PerformerReaderWriter.go b/pkg/models/mocks/PerformerReaderWriter.go index 7bbc6ef79..0f3e2be02 100644 --- a/pkg/models/mocks/PerformerReaderWriter.go +++ b/pkg/models/mocks/PerformerReaderWriter.go @@ -383,6 +383,29 @@ func (_m *PerformerReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ( return r0, r1 } +// GetURLs provides a mock function with given fields: ctx, relatedID +func (_m *PerformerReaderWriter) 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, performerID func (_m *PerformerReaderWriter) HasImage(ctx context.Context, performerID int) (bool, error) { ret := _m.Called(ctx, performerID) diff --git a/pkg/models/model_performer.go b/pkg/models/model_performer.go index 09f92e13c..85257ba38 100644 --- a/pkg/models/model_performer.go +++ b/pkg/models/model_performer.go @@ -10,9 +10,6 @@ type Performer struct { Name string `json:"name"` Disambiguation string `json:"disambiguation"` Gender *GenderEnum `json:"gender"` - URL string `json:"url"` - Twitter string `json:"twitter"` - Instagram string `json:"instagram"` Birthdate *Date `json:"birthdate"` Ethnicity string `json:"ethnicity"` Country string `json:"country"` @@ -37,6 +34,7 @@ type Performer 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"` } @@ -55,9 +53,7 @@ type PerformerPartial struct { Name OptionalString Disambiguation OptionalString Gender OptionalString - URL OptionalString - Twitter OptionalString - Instagram OptionalString + URLs *UpdateStrings Birthdate OptionalDate Ethnicity OptionalString Country OptionalString @@ -99,6 +95,12 @@ func (s *Performer) LoadAliases(ctx context.Context, l AliasLoader) error { }) } +func (s *Performer) LoadURLs(ctx context.Context, l URLLoader) error { + return s.URLs.load(func() ([]string, error) { + return l.GetURLs(ctx, s.ID) + }) +} + func (s *Performer) 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/model_scraped_item.go b/pkg/models/model_scraped_item.go index 5cc5c679c..206f1109b 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -107,9 +107,10 @@ type ScrapedPerformer struct { Name *string `json:"name"` Disambiguation *string `json:"disambiguation"` Gender *string `json:"gender"` - URL *string `json:"url"` - Twitter *string `json:"twitter"` - Instagram *string `json:"instagram"` + URLs []string `json:"urls"` + URL *string `json:"url"` // deprecated + Twitter *string `json:"twitter"` // deprecated + Instagram *string `json:"instagram"` // deprecated Birthdate *string `json:"birthdate"` Ethnicity *string `json:"ethnicity"` Country *string `json:"country"` @@ -191,9 +192,7 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool ret.Weight = &w } } - if p.Instagram != nil && !excluded["instagram"] { - ret.Instagram = *p.Instagram - } + if p.Measurements != nil && !excluded["measurements"] { ret.Measurements = *p.Measurements } @@ -221,11 +220,27 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool ret.Circumcised = &v } } - if p.Twitter != nil && !excluded["twitter"] { - ret.Twitter = *p.Twitter - } - if p.URL != nil && !excluded["url"] { - ret.URL = *p.URL + + // if URLs are provided, only use those + if len(p.URLs) > 0 { + if !excluded["urls"] { + ret.URLs = NewRelatedStrings(p.URLs) + } + } else { + urls := []string{} + if p.URL != nil && !excluded["url"] { + urls = append(urls, *p.URL) + } + if p.Twitter != nil && !excluded["twitter"] { + urls = append(urls, *p.Twitter) + } + if p.Instagram != nil && !excluded["instagram"] { + urls = append(urls, *p.Instagram) + } + + if len(urls) > 0 { + ret.URLs = NewRelatedStrings(urls) + } } if p.RemoteSiteID != nil && endpoint != "" { @@ -309,9 +324,6 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, ret.Weight = NewOptionalInt(w) } } - if p.Instagram != nil && !excluded["instagram"] { - ret.Instagram = NewOptionalString(*p.Instagram) - } if p.Measurements != nil && !excluded["measurements"] { ret.Measurements = NewOptionalString(*p.Measurements) } @@ -330,11 +342,33 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, if p.Tattoos != nil && !excluded["tattoos"] { ret.Tattoos = NewOptionalString(*p.Tattoos) } - if p.Twitter != nil && !excluded["twitter"] { - ret.Twitter = NewOptionalString(*p.Twitter) - } - if p.URL != nil && !excluded["url"] { - ret.URL = NewOptionalString(*p.URL) + + // if URLs are provided, only use those + if len(p.URLs) > 0 { + if !excluded["urls"] { + ret.URLs = &UpdateStrings{ + Values: p.URLs, + Mode: RelationshipUpdateModeSet, + } + } + } else { + urls := []string{} + if p.URL != nil && !excluded["url"] { + urls = append(urls, *p.URL) + } + if p.Twitter != nil && !excluded["twitter"] { + urls = append(urls, *p.Twitter) + } + if p.Instagram != nil && !excluded["instagram"] { + urls = append(urls, *p.Instagram) + } + + if len(urls) > 0 { + ret.URLs = &UpdateStrings{ + Values: urls, + Mode: RelationshipUpdateModeSet, + } + } } if p.RemoteSiteID != nil && endpoint != "" { diff --git a/pkg/models/model_scraped_item_test.go b/pkg/models/model_scraped_item_test.go index a6e42f2fd..50657188d 100644 --- a/pkg/models/model_scraped_item_test.go +++ b/pkg/models/model_scraped_item_test.go @@ -161,9 +161,9 @@ func Test_scrapedToPerformerInput(t *testing.T) { Tattoos: nextVal(), Piercings: nextVal(), Aliases: nextVal(), + URL: nextVal(), Twitter: nextVal(), Instagram: nextVal(), - URL: nextVal(), Details: nextVal(), RemoteSiteID: &remoteSiteID, }, @@ -186,9 +186,7 @@ func Test_scrapedToPerformerInput(t *testing.T) { Tattoos: *nextVal(), Piercings: *nextVal(), Aliases: NewRelatedStrings([]string{*nextVal()}), - Twitter: *nextVal(), - Instagram: *nextVal(), - URL: *nextVal(), + URLs: NewRelatedStrings([]string{*nextVal(), *nextVal(), *nextVal()}), Details: *nextVal(), StashIDs: NewRelatedStashIDs([]StashID{ { diff --git a/pkg/models/performer.go b/pkg/models/performer.go index 75b0f85af..b14f60044 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -203,7 +203,8 @@ type PerformerFilterType struct { type PerformerCreateInput struct { Name string `json:"name"` Disambiguation *string `json:"disambiguation"` - URL *string `json:"url"` + URL *string `json:"url"` // deprecated + Urls []string `json:"urls"` Gender *GenderEnum `json:"gender"` Birthdate *string `json:"birthdate"` Ethnicity *string `json:"ethnicity"` @@ -220,8 +221,8 @@ type PerformerCreateInput struct { Piercings *string `json:"piercings"` Aliases *string `json:"aliases"` AliasList []string `json:"alias_list"` - Twitter *string `json:"twitter"` - Instagram *string `json:"instagram"` + Twitter *string `json:"twitter"` // deprecated + Instagram *string `json:"instagram"` // deprecated Favorite *bool `json:"favorite"` TagIds []string `json:"tag_ids"` // This should be a URL or a base64 encoded data URL @@ -239,7 +240,8 @@ type PerformerUpdateInput struct { ID string `json:"id"` Name *string `json:"name"` Disambiguation *string `json:"disambiguation"` - URL *string `json:"url"` + URL *string `json:"url"` // deprecated + Urls []string `json:"urls"` Gender *GenderEnum `json:"gender"` Birthdate *string `json:"birthdate"` Ethnicity *string `json:"ethnicity"` @@ -256,8 +258,8 @@ type PerformerUpdateInput struct { Piercings *string `json:"piercings"` Aliases *string `json:"aliases"` AliasList []string `json:"alias_list"` - Twitter *string `json:"twitter"` - Instagram *string `json:"instagram"` + Twitter *string `json:"twitter"` // deprecated + Instagram *string `json:"instagram"` // deprecated Favorite *bool `json:"favorite"` TagIds []string `json:"tag_ids"` // This should be a URL or a base64 encoded data URL diff --git a/pkg/models/repository_performer.go b/pkg/models/repository_performer.go index 22ade1d1d..3fd936190 100644 --- a/pkg/models/repository_performer.go +++ b/pkg/models/repository_performer.go @@ -78,6 +78,7 @@ type PerformerReader interface { AliasLoader StashIDLoader TagIDLoader + URLLoader All(ctx context.Context) ([]*Performer, error) GetImage(ctx context.Context, performerID int) ([]byte, error) diff --git a/pkg/performer/export.go b/pkg/performer/export.go index 9aec8b34e..8f720338f 100644 --- a/pkg/performer/export.go +++ b/pkg/performer/export.go @@ -16,6 +16,7 @@ type ImageAliasStashIDGetter interface { GetImage(ctx context.Context, performerID int) ([]byte, error) models.AliasLoader models.StashIDLoader + models.URLLoader } // ToJSON converts a Performer object into its JSON equivalent. @@ -23,7 +24,6 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode newPerformerJSON := jsonschema.Performer{ Name: performer.Name, Disambiguation: performer.Disambiguation, - URL: performer.URL, Ethnicity: performer.Ethnicity, Country: performer.Country, EyeColor: performer.EyeColor, @@ -32,8 +32,6 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode CareerLength: performer.CareerLength, Tattoos: performer.Tattoos, Piercings: performer.Piercings, - Twitter: performer.Twitter, - Instagram: performer.Instagram, Favorite: performer.Favorite, Details: performer.Details, HairColor: performer.HairColor, @@ -78,6 +76,11 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode newPerformerJSON.Aliases = performer.Aliases.List() + if err := performer.LoadURLs(ctx, reader); err != nil { + return nil, fmt.Errorf("loading performer urls: %w", err) + } + newPerformerJSON.URLs = performer.URLs.List() + if err := performer.LoadStashIDs(ctx, reader); err != nil { return nil, fmt.Errorf("loading performer stash ids: %w", err) } diff --git a/pkg/performer/export_test.go b/pkg/performer/export_test.go index 572634aa6..36353b17d 100644 --- a/pkg/performer/export_test.go +++ b/pkg/performer/export_test.go @@ -77,7 +77,7 @@ func createFullPerformer(id int, name string) *models.Performer { ID: id, Name: name, Disambiguation: disambiguation, - URL: url, + URLs: models.NewRelatedStrings([]string{url, twitter, instagram}), Aliases: models.NewRelatedStrings(aliases), Birthdate: &birthDate, CareerLength: careerLength, @@ -90,11 +90,9 @@ func createFullPerformer(id int, name string) *models.Performer { Favorite: true, Gender: &genderEnum, Height: &height, - Instagram: instagram, Measurements: measurements, Piercings: piercings, Tattoos: tattoos, - Twitter: twitter, CreatedAt: createTime, UpdatedAt: updateTime, Rating: &rating, @@ -114,6 +112,7 @@ func createEmptyPerformer(id int) models.Performer { CreatedAt: createTime, UpdatedAt: updateTime, Aliases: models.NewRelatedStrings([]string{}), + URLs: models.NewRelatedStrings([]string{}), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), } @@ -123,7 +122,7 @@ func createFullJSONPerformer(name string, image string) *jsonschema.Performer { return &jsonschema.Performer{ Name: name, Disambiguation: disambiguation, - URL: url, + URLs: []string{url, twitter, instagram}, Aliases: aliases, Birthdate: birthDate.String(), CareerLength: careerLength, @@ -136,11 +135,9 @@ func createFullJSONPerformer(name string, image string) *jsonschema.Performer { Favorite: true, Gender: gender, Height: strconv.Itoa(height), - Instagram: instagram, Measurements: measurements, Piercings: piercings, Tattoos: tattoos, - Twitter: twitter, CreatedAt: json.JSONTime{ Time: createTime, }, @@ -161,6 +158,7 @@ func createFullJSONPerformer(name string, image string) *jsonschema.Performer { func createEmptyJSONPerformer() *jsonschema.Performer { return &jsonschema.Performer{ Aliases: []string{}, + URLs: []string{}, StashIDs: []models.StashID{}, CreatedAt: json.JSONTime{ Time: createTime, diff --git a/pkg/performer/import.go b/pkg/performer/import.go index afa6cd4bc..d50384fa3 100644 --- a/pkg/performer/import.go +++ b/pkg/performer/import.go @@ -188,7 +188,6 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform newPerformer := models.Performer{ Name: performerJSON.Name, Disambiguation: performerJSON.Disambiguation, - URL: performerJSON.URL, Ethnicity: performerJSON.Ethnicity, Country: performerJSON.Country, EyeColor: performerJSON.EyeColor, @@ -198,8 +197,6 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform Tattoos: performerJSON.Tattoos, Piercings: performerJSON.Piercings, Aliases: models.NewRelatedStrings(performerJSON.Aliases), - Twitter: performerJSON.Twitter, - Instagram: performerJSON.Instagram, Details: performerJSON.Details, HairColor: performerJSON.HairColor, Favorite: performerJSON.Favorite, @@ -211,6 +208,25 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform StashIDs: models.NewRelatedStashIDs(performerJSON.StashIDs), } + if len(performerJSON.URLs) > 0 { + newPerformer.URLs = models.NewRelatedStrings(performerJSON.URLs) + } else { + urls := []string{} + if performerJSON.URL != "" { + urls = append(urls, performerJSON.URL) + } + if performerJSON.Twitter != "" { + urls = append(urls, performerJSON.Twitter) + } + if performerJSON.Instagram != "" { + urls = append(urls, performerJSON.Instagram) + } + + if len(urls) > 0 { + newPerformer.URLs = models.NewRelatedStrings([]string{performerJSON.URL}) + } + } + if performerJSON.Gender != "" { v := models.GenderEnum(performerJSON.Gender) newPerformer.Gender = &v diff --git a/pkg/performer/url.go b/pkg/performer/url.go new file mode 100644 index 000000000..4b52adad5 --- /dev/null +++ b/pkg/performer/url.go @@ -0,0 +1,18 @@ +package performer + +import ( + "regexp" +) + +var ( + twitterURLRE = regexp.MustCompile(`^https?:\/\/(?:www\.)?twitter\.com\/`) + instagramURLRE = regexp.MustCompile(`^https?:\/\/(?:www\.)?instagram\.com\/`) +) + +func IsTwitterURL(url string) bool { + return twitterURLRE.MatchString(url) +} + +func IsInstagramURL(url string) bool { + return instagramURLRE.MatchString(url) +} diff --git a/pkg/scraper/json.go b/pkg/scraper/json.go index 98e853785..ae96ecb06 100644 --- a/pkg/scraper/json.go +++ b/pkg/scraper/json.go @@ -81,15 +81,33 @@ func (s *jsonScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeCont } q := s.getJsonQuery(doc) + // if these just return the return values from scraper.scrape* functions then + // it ends up returning ScrapedContent(nil) rather than nil switch ty { case ScrapeContentTypePerformer: - return scraper.scrapePerformer(ctx, q) + ret, err := scraper.scrapePerformer(ctx, q) + if err != nil || ret == nil { + return nil, err + } + return ret, nil case ScrapeContentTypeScene: - return scraper.scrapeScene(ctx, q) + ret, err := scraper.scrapeScene(ctx, q) + if err != nil || ret == nil { + return nil, err + } + return ret, nil case ScrapeContentTypeGallery: - return scraper.scrapeGallery(ctx, q) + ret, err := scraper.scrapeGallery(ctx, q) + if err != nil || ret == nil { + return nil, err + } + return ret, nil case ScrapeContentTypeMovie: - return scraper.scrapeMovie(ctx, q) + ret, err := scraper.scrapeMovie(ctx, q) + if err != nil || ret == nil { + return nil, err + } + return ret, nil } return nil, ErrNotSupported diff --git a/pkg/scraper/performer.go b/pkg/scraper/performer.go index 269368823..98e931762 100644 --- a/pkg/scraper/performer.go +++ b/pkg/scraper/performer.go @@ -2,29 +2,30 @@ package scraper type ScrapedPerformerInput struct { // Set if performer matched - StoredID *string `json:"stored_id"` - Name *string `json:"name"` - Disambiguation *string `json:"disambiguation"` - Gender *string `json:"gender"` - URL *string `json:"url"` - Twitter *string `json:"twitter"` - Instagram *string `json:"instagram"` - Birthdate *string `json:"birthdate"` - Ethnicity *string `json:"ethnicity"` - Country *string `json:"country"` - EyeColor *string `json:"eye_color"` - Height *string `json:"height"` - Measurements *string `json:"measurements"` - FakeTits *string `json:"fake_tits"` - PenisLength *string `json:"penis_length"` - Circumcised *string `json:"circumcised"` - CareerLength *string `json:"career_length"` - Tattoos *string `json:"tattoos"` - Piercings *string `json:"piercings"` - Aliases *string `json:"aliases"` - Details *string `json:"details"` - DeathDate *string `json:"death_date"` - HairColor *string `json:"hair_color"` - Weight *string `json:"weight"` - RemoteSiteID *string `json:"remote_site_id"` + StoredID *string `json:"stored_id"` + Name *string `json:"name"` + Disambiguation *string `json:"disambiguation"` + Gender *string `json:"gender"` + URLs []string `json:"urls"` + URL *string `json:"url"` // deprecated + Twitter *string `json:"twitter"` // deprecated + Instagram *string `json:"instagram"` // deprecated + Birthdate *string `json:"birthdate"` + Ethnicity *string `json:"ethnicity"` + Country *string `json:"country"` + EyeColor *string `json:"eye_color"` + Height *string `json:"height"` + Measurements *string `json:"measurements"` + FakeTits *string `json:"fake_tits"` + PenisLength *string `json:"penis_length"` + Circumcised *string `json:"circumcised"` + CareerLength *string `json:"career_length"` + Tattoos *string `json:"tattoos"` + Piercings *string `json:"piercings"` + Aliases *string `json:"aliases"` + Details *string `json:"details"` + DeathDate *string `json:"death_date"` + HairColor *string `json:"hair_color"` + Weight *string `json:"weight"` + RemoteSiteID *string `json:"remote_site_id"` } diff --git a/pkg/scraper/postprocessing.go b/pkg/scraper/postprocessing.go index a375b5058..e153c5616 100644 --- a/pkg/scraper/postprocessing.go +++ b/pkg/scraper/postprocessing.go @@ -6,6 +6,7 @@ import ( "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" ) // postScrape handles post-processing of scraped content. If the content @@ -67,6 +68,31 @@ func (c Cache) postScrapePerformer(ctx context.Context, p models.ScrapedPerforme p.Country = resolveCountryName(p.Country) + // populate URL/URLs + // if URLs are provided, only use those + if len(p.URLs) > 0 { + p.URL = &p.URLs[0] + } else { + urls := []string{} + if p.URL != nil { + urls = append(urls, *p.URL) + } + if p.Twitter != nil && *p.Twitter != "" { + // handle twitter profile names + u := utils.URLFromHandle(*p.Twitter, "https://twitter.com") + urls = append(urls, u) + } + if p.Instagram != nil && *p.Instagram != "" { + // handle instagram profile names + u := utils.URLFromHandle(*p.Instagram, "https://instagram.com") + urls = append(urls, u) + } + + if len(urls) > 0 { + p.URLs = urls + } + } + return p, nil } diff --git a/pkg/scraper/scraper.go b/pkg/scraper/scraper.go index 23ad411bd..4eb67dcf4 100644 --- a/pkg/scraper/scraper.go +++ b/pkg/scraper/scraper.go @@ -163,6 +163,12 @@ func (i *Input) populateURL() { if i.Scene != nil && i.Scene.URL == nil && len(i.Scene.URLs) > 0 { i.Scene.URL = &i.Scene.URLs[0] } + if i.Gallery != nil && i.Gallery.URL == nil && len(i.Gallery.URLs) > 0 { + i.Gallery.URL = &i.Gallery.URLs[0] + } + if i.Performer != nil && i.Performer.URL == nil && len(i.Performer.URLs) > 0 { + i.Performer.URL = &i.Performer.URLs[0] + } } // simple type definitions that can help customize diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index 407238dae..350bac5c4 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -9,7 +9,6 @@ import ( "io" "mime/multipart" "net/http" - "regexp" "strconv" "strings" @@ -41,6 +40,7 @@ type PerformerReader interface { match.PerformerFinder models.AliasLoader models.StashIDLoader + models.URLLoader FindBySceneID(ctx context.Context, sceneID int) ([]*models.Performer, error) GetImage(ctx context.Context, performerID int) ([]byte, error) } @@ -685,6 +685,10 @@ func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.Sc sp.Aliases = &alias } + for _, u := range p.Urls { + sp.URLs = append(sp.URLs, u.URL) + } + return sp } @@ -1128,6 +1132,10 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf return nil, err } + if err := performer.LoadURLs(ctx, pqb); err != nil { + return nil, err + } + if err := performer.LoadStashIDs(ctx, pqb); err != nil { return nil, err } @@ -1195,28 +1203,8 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf } } - var urls []string - if len(strings.TrimSpace(performer.Twitter)) > 0 { - reg := regexp.MustCompile(`https?:\/\/(?:www\.)?twitter\.com`) - if reg.MatchString(performer.Twitter) { - urls = append(urls, strings.TrimSpace(performer.Twitter)) - } else { - urls = append(urls, "https://twitter.com/"+strings.TrimSpace(performer.Twitter)) - } - } - if len(strings.TrimSpace(performer.Instagram)) > 0 { - reg := regexp.MustCompile(`https?:\/\/(?:www\.)?instagram\.com`) - if reg.MatchString(performer.Instagram) { - urls = append(urls, strings.TrimSpace(performer.Instagram)) - } else { - urls = append(urls, "https://instagram.com/"+strings.TrimSpace(performer.Instagram)) - } - } - if len(strings.TrimSpace(performer.URL)) > 0 { - urls = append(urls, strings.TrimSpace(performer.URL)) - } - if len(urls) > 0 { - draft.Urls = urls + if len(performer.URLs.List()) > 0 { + draft.Urls = performer.URLs.List() } stashIDs, err := pqb.GetStashIDs(ctx, performer.ID) diff --git a/pkg/scraper/xpath.go b/pkg/scraper/xpath.go index 29a4b0a19..d13c8e4c0 100644 --- a/pkg/scraper/xpath.go +++ b/pkg/scraper/xpath.go @@ -62,15 +62,33 @@ func (s *xpathScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeCon } q := s.getXPathQuery(doc) + // if these just return the return values from scraper.scrape* functions then + // it ends up returning ScrapedContent(nil) rather than nil switch ty { case ScrapeContentTypePerformer: - return scraper.scrapePerformer(ctx, q) + ret, err := scraper.scrapePerformer(ctx, q) + if err != nil || ret == nil { + return nil, err + } + return ret, nil case ScrapeContentTypeScene: - return scraper.scrapeScene(ctx, q) + ret, err := scraper.scrapeScene(ctx, q) + if err != nil || ret == nil { + return nil, err + } + return ret, nil case ScrapeContentTypeGallery: - return scraper.scrapeGallery(ctx, q) + ret, err := scraper.scrapeGallery(ctx, q) + if err != nil || ret == nil { + return nil, err + } + return ret, nil case ScrapeContentTypeMovie: - return scraper.scrapeMovie(ctx, q) + ret, err := scraper.scrapeMovie(ctx, q) + if err != nil || ret == nil { + return nil, err + } + return ret, nil } return nil, ErrNotSupported diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index 44381c070..465e6cad5 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -495,9 +495,6 @@ func (db *Anonymiser) anonymisePerformers(ctx context.Context) error { table.Col(idColumn), table.Col("name"), table.Col("details"), - table.Col("url"), - table.Col("twitter"), - table.Col("instagram"), table.Col("tattoos"), table.Col("piercings"), ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) @@ -510,9 +507,6 @@ func (db *Anonymiser) anonymisePerformers(ctx context.Context) error { id int name sql.NullString details sql.NullString - url sql.NullString - twitter sql.NullString - instagram sql.NullString tattoos sql.NullString piercings sql.NullString ) @@ -521,9 +515,6 @@ func (db *Anonymiser) anonymisePerformers(ctx context.Context) error { &id, &name, &details, - &url, - &twitter, - &instagram, &tattoos, &piercings, ); err != nil { @@ -533,9 +524,6 @@ func (db *Anonymiser) anonymisePerformers(ctx context.Context) error { set := goqu.Record{} db.obfuscateNullString(set, "name", name) db.obfuscateNullString(set, "details", details) - db.obfuscateNullString(set, "url", url) - db.obfuscateNullString(set, "twitter", twitter) - db.obfuscateNullString(set, "instagram", instagram) db.obfuscateNullString(set, "tattoos", tattoos) db.obfuscateNullString(set, "piercings", piercings) @@ -566,6 +554,10 @@ func (db *Anonymiser) anonymisePerformers(ctx context.Context) error { return err } + if err := db.anonymiseURLs(ctx, goqu.T(performerURLsTable), "performer_id"); err != nil { + return err + } + return nil } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 7cfcd2003..cf502392f 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -30,7 +30,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 61 +var appSchemaVersion uint = 62 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/62_performer_urls.up.sql b/pkg/sqlite/migrations/62_performer_urls.up.sql new file mode 100644 index 000000000..cebfa86d6 --- /dev/null +++ b/pkg/sqlite/migrations/62_performer_urls.up.sql @@ -0,0 +1,155 @@ +PRAGMA foreign_keys=OFF; + +CREATE TABLE `performer_urls` ( + `performer_id` integer NOT NULL, + `position` integer NOT NULL, + `url` varchar(255) NOT NULL, + foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE, + PRIMARY KEY(`performer_id`, `position`, `url`) +); + +CREATE INDEX `performers_urls_url` on `performer_urls` (`url`); + +-- drop url, twitter and instagram +-- make name not null +CREATE TABLE `performers_new` ( + `id` integer not null primary key autoincrement, + `name` varchar(255) not null, + `disambiguation` varchar(255), + `gender` varchar(20), + `birthdate` date, + `ethnicity` varchar(255), + `country` varchar(255), + `eye_color` varchar(255), + `height` int, + `measurements` varchar(255), + `fake_tits` varchar(255), + `career_length` varchar(255), + `tattoos` varchar(255), + `piercings` varchar(255), + `favorite` boolean not null default '0', + `created_at` datetime not null, + `updated_at` datetime not null, + `details` text, + `death_date` date, + `hair_color` varchar(255), + `weight` integer, + `rating` tinyint, + `ignore_auto_tag` boolean not null default '0', + `image_blob` varchar(255) REFERENCES `blobs`(`checksum`), + `penis_length` float, + `circumcised` varchar[10] +); + +INSERT INTO `performers_new` + ( + `id`, + `name`, + `disambiguation`, + `gender`, + `birthdate`, + `ethnicity`, + `country`, + `eye_color`, + `height`, + `measurements`, + `fake_tits`, + `career_length`, + `tattoos`, + `piercings`, + `favorite`, + `created_at`, + `updated_at`, + `details`, + `death_date`, + `hair_color`, + `weight`, + `rating`, + `ignore_auto_tag`, + `image_blob`, + `penis_length`, + `circumcised` + ) + SELECT + `id`, + `name`, + `disambiguation`, + `gender`, + `birthdate`, + `ethnicity`, + `country`, + `eye_color`, + `height`, + `measurements`, + `fake_tits`, + `career_length`, + `tattoos`, + `piercings`, + `favorite`, + `created_at`, + `updated_at`, + `details`, + `death_date`, + `hair_color`, + `weight`, + `rating`, + `ignore_auto_tag`, + `image_blob`, + `penis_length`, + `circumcised` + FROM `performers`; + +INSERT INTO `performer_urls` + ( + `performer_id`, + `position`, + `url` + ) + SELECT + `id`, + '0', + `url` + FROM `performers` + WHERE `performers`.`url` IS NOT NULL AND `performers`.`url` != ''; + +INSERT INTO `performer_urls` + ( + `performer_id`, + `position`, + `url` + ) + SELECT + `id`, + (SELECT count(*) FROM `performer_urls` WHERE `performer_id` = `performers`.`id`)+1, + CASE + WHEN `twitter` LIKE 'http%://%' THEN `twitter` + ELSE 'https://www.twitter.com/' || `twitter` + END + FROM `performers` + WHERE `performers`.`twitter` IS NOT NULL AND `performers`.`twitter` != ''; + +INSERT INTO `performer_urls` + ( + `performer_id`, + `position`, + `url` + ) + SELECT + `id`, + (SELECT count(*) FROM `performer_urls` WHERE `performer_id` = `performers`.`id`)+1, + CASE + WHEN `instagram` LIKE 'http%://%' THEN `instagram` + ELSE 'https://www.instagram.com/' || `instagram` + END + FROM `performers` + WHERE `performers`.`instagram` IS NOT NULL AND `performers`.`instagram` != ''; + +DROP INDEX `performers_name_disambiguation_unique`; +DROP INDEX `performers_name_unique`; +DROP TABLE `performers`; +ALTER TABLE `performers_new` rename to `performers`; + +CREATE UNIQUE INDEX `performers_name_disambiguation_unique` on `performers` (`name`, `disambiguation`) WHERE `disambiguation` IS NOT NULL; +CREATE UNIQUE INDEX `performers_name_unique` on `performers` (`name`) WHERE `disambiguation` IS NULL; + +PRAGMA foreign_keys=ON; diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 4ba05168d..0c2f1d78f 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -23,6 +23,9 @@ const ( performerAliasColumn = "alias" performersTagsTable = "performers_tags" + performerURLsTable = "performer_urls" + performerURLColumn = "url" + performerImageBlobColumn = "image_blob" ) @@ -31,9 +34,6 @@ type performerRow struct { Name null.String `db:"name"` // TODO: make schema non-nullable Disambigation zero.String `db:"disambiguation"` Gender zero.String `db:"gender"` - URL zero.String `db:"url"` - Twitter zero.String `db:"twitter"` - Instagram zero.String `db:"instagram"` Birthdate NullDate `db:"birthdate"` Ethnicity zero.String `db:"ethnicity"` Country zero.String `db:"country"` @@ -68,9 +68,6 @@ func (r *performerRow) fromPerformer(o models.Performer) { if o.Gender != nil && o.Gender.IsValid() { r.Gender = zero.StringFrom(o.Gender.String()) } - r.URL = zero.StringFrom(o.URL) - r.Twitter = zero.StringFrom(o.Twitter) - r.Instagram = zero.StringFrom(o.Instagram) r.Birthdate = NullDateFromDatePtr(o.Birthdate) r.Ethnicity = zero.StringFrom(o.Ethnicity) r.Country = zero.StringFrom(o.Country) @@ -101,9 +98,6 @@ func (r *performerRow) resolve() *models.Performer { ID: r.ID, Name: r.Name.String, Disambiguation: r.Disambigation.String, - URL: r.URL.String, - Twitter: r.Twitter.String, - Instagram: r.Instagram.String, Birthdate: r.Birthdate.DatePtr(), Ethnicity: r.Ethnicity.String, Country: r.Country.String, @@ -148,9 +142,6 @@ func (r *performerRowRecord) fromPartial(o models.PerformerPartial) { r.setString("name", o.Name) r.setNullString("disambiguation", o.Disambiguation) r.setNullString("gender", o.Gender) - r.setNullString("url", o.URL) - r.setNullString("twitter", o.Twitter) - r.setNullString("instagram", o.Instagram) r.setNullDate("birthdate", o.Birthdate) r.setNullString("ethnicity", o.Ethnicity) r.setNullString("country", o.Country) @@ -272,6 +263,13 @@ func (qb *PerformerStore) Create(ctx context.Context, newObject *models.Performe } } + if newObject.URLs.Loaded() { + const startPos = 0 + if err := performersURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { + return err + } + } + if newObject.TagIDs.Loaded() { if err := performersTagsTableMgr.insertJoins(ctx, id, newObject.TagIDs.List()); err != nil { return err @@ -315,6 +313,12 @@ func (qb *PerformerStore) UpdatePartial(ctx context.Context, id int, partial mod } } + if partial.URLs != nil { + if err := performersURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { + return nil, err + } + } + if partial.TagIDs != nil { if err := performersTagsTableMgr.modifyJoins(ctx, id, partial.TagIDs.IDs, partial.TagIDs.Mode); err != nil { return nil, err @@ -343,6 +347,12 @@ func (qb *PerformerStore) Update(ctx context.Context, updatedObject *models.Perf } } + if updatedObject.URLs.Loaded() { + if err := performersURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { + return err + } + } + if updatedObject.TagIDs.Loaded() { if err := performersTagsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.TagIDs.List()); err != nil { return err @@ -785,6 +795,10 @@ func (qb *PerformerStore) GetAliases(ctx context.Context, performerID int) ([]st return performersAliasesTableMgr.get(ctx, performerID) } +func (qb *PerformerStore) GetURLs(ctx context.Context, performerID int) ([]string, error) { + return performersURLsTableMgr.get(ctx, performerID) +} + func (qb *PerformerStore) GetStashIDs(ctx context.Context, performerID int) ([]models.StashID, error) { return performersStashIDsTableMgr.get(ctx, performerID) } diff --git a/pkg/sqlite/performer_filter.go b/pkg/sqlite/performer_filter.go index 13c2ec5a2..72990a7fe 100644 --- a/pkg/sqlite/performer_filter.go +++ b/pkg/sqlite/performer_filter.go @@ -134,7 +134,7 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler { stringCriterionHandler(filter.Piercings, tableName+".piercings"), intCriterionHandler(filter.Rating100, tableName+".rating", nil), stringCriterionHandler(filter.HairColor, tableName+".hair_color"), - stringCriterionHandler(filter.URL, tableName+".url"), + qb.urlsCriterionHandler(filter.URL), intCriterionHandler(filter.Weight, tableName+".weight", nil), criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if filter.StashID != nil { @@ -211,6 +211,9 @@ func (qb *performerFilterHandler) performerIsMissingCriterionHandler(isMissing * return func(ctx context.Context, f *filterBuilder) { if isMissing != nil && *isMissing != "" { switch *isMissing { + case "url": + performersURLsTableMgr.join(f, "", "performers.id") + f.addWhere("performer_urls.url IS NULL") case "scenes": // Deprecated: use `scene_count == 0` filter instead f.addLeftJoin(performersScenesTable, "scenes_join", "scenes_join.performer_id = performers.id") f.addWhere("scenes_join.scene_id IS NULL") @@ -241,6 +244,20 @@ func (qb *performerFilterHandler) performerAgeFilterCriterionHandler(age *models } } +func (qb *performerFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + primaryTable: performerTable, + primaryFK: performerIDColumn, + joinTable: performerURLsTable, + stringColumn: performerURLColumn, + addJoinTable: func(f *filterBuilder) { + performersURLsTableMgr.join(f, "", "performers.id") + }, + } + + return h.handler(url) +} + func (qb *performerFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ primaryTable: performerTable, diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index d333913d2..c0124d09d 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -22,6 +22,11 @@ func loadPerformerRelationships(ctx context.Context, expected models.Performer, return err } } + if expected.URLs.Loaded() { + if err := actual.LoadURLs(ctx, db.Performer); err != nil { + return err + } + } if expected.TagIDs.Loaded() { if err := actual.LoadTagIDs(ctx, db.Performer); err != nil { return err @@ -45,6 +50,7 @@ func Test_PerformerStore_Create(t *testing.T) { url = "url" twitter = "twitter" instagram = "instagram" + urls = []string{url, twitter, instagram} rating = 3 ethnicity = "ethnicity" country = "country" @@ -84,9 +90,7 @@ func Test_PerformerStore_Create(t *testing.T) { Name: name, Disambiguation: disambiguation, Gender: &gender, - URL: url, - Twitter: twitter, - Instagram: instagram, + URLs: models.NewRelatedStrings(urls), Birthdate: &birthdate, Ethnicity: ethnicity, Country: country, @@ -193,6 +197,7 @@ func Test_PerformerStore_Update(t *testing.T) { url = "url" twitter = "twitter" instagram = "instagram" + urls = []string{url, twitter, instagram} rating = 3 ethnicity = "ethnicity" country = "country" @@ -233,9 +238,7 @@ func Test_PerformerStore_Update(t *testing.T) { Name: name, Disambiguation: disambiguation, Gender: &gender, - URL: url, - Twitter: twitter, - Instagram: instagram, + URLs: models.NewRelatedStrings(urls), Birthdate: &birthdate, Ethnicity: ethnicity, Country: country, @@ -277,6 +280,7 @@ func Test_PerformerStore_Update(t *testing.T) { &models.Performer{ ID: performerIDs[performerIdxWithGallery], Aliases: models.NewRelatedStrings([]string{}), + URLs: models.NewRelatedStrings([]string{}), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), }, @@ -341,9 +345,7 @@ func clearPerformerPartial() models.PerformerPartial { return models.PerformerPartial{ Disambiguation: nullString, Gender: nullString, - URL: nullString, - Twitter: nullString, - Instagram: nullString, + URLs: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet}, Birthdate: nullDate, Ethnicity: nullString, Country: nullString, @@ -376,6 +378,7 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { url = "url" twitter = "twitter" instagram = "instagram" + urls = []string{url, twitter, instagram} rating = 3 ethnicity = "ethnicity" country = "country" @@ -418,21 +421,22 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { Name: models.NewOptionalString(name), Disambiguation: models.NewOptionalString(disambiguation), Gender: models.NewOptionalString(gender.String()), - URL: models.NewOptionalString(url), - Twitter: models.NewOptionalString(twitter), - Instagram: models.NewOptionalString(instagram), - Birthdate: models.NewOptionalDate(birthdate), - Ethnicity: models.NewOptionalString(ethnicity), - Country: models.NewOptionalString(country), - EyeColor: models.NewOptionalString(eyeColor), - Height: models.NewOptionalInt(height), - Measurements: models.NewOptionalString(measurements), - FakeTits: models.NewOptionalString(fakeTits), - PenisLength: models.NewOptionalFloat64(penisLength), - Circumcised: models.NewOptionalString(circumcised.String()), - CareerLength: models.NewOptionalString(careerLength), - Tattoos: models.NewOptionalString(tattoos), - Piercings: models.NewOptionalString(piercings), + URLs: &models.UpdateStrings{ + Values: urls, + Mode: models.RelationshipUpdateModeSet, + }, + Birthdate: models.NewOptionalDate(birthdate), + Ethnicity: models.NewOptionalString(ethnicity), + Country: models.NewOptionalString(country), + EyeColor: models.NewOptionalString(eyeColor), + Height: models.NewOptionalInt(height), + Measurements: models.NewOptionalString(measurements), + FakeTits: models.NewOptionalString(fakeTits), + PenisLength: models.NewOptionalFloat64(penisLength), + Circumcised: models.NewOptionalString(circumcised.String()), + CareerLength: models.NewOptionalString(careerLength), + Tattoos: models.NewOptionalString(tattoos), + Piercings: models.NewOptionalString(piercings), Aliases: &models.UpdateStrings{ Values: aliases, Mode: models.RelationshipUpdateModeSet, @@ -469,9 +473,7 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { Name: name, Disambiguation: disambiguation, Gender: &gender, - URL: url, - Twitter: twitter, - Instagram: instagram, + URLs: models.NewRelatedStrings(urls), Birthdate: &birthdate, Ethnicity: ethnicity, Country: country, @@ -516,6 +518,7 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { ID: performerIDs[performerIdxWithTwoTags], Name: getPerformerStringValue(performerIdxWithTwoTags, "Name"), Favorite: getPerformerBoolValue(performerIdxWithTwoTags), + URLs: models.NewRelatedStrings([]string{}), Aliases: models.NewRelatedStrings([]string{}), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), @@ -1290,7 +1293,14 @@ func TestPerformerQueryURL(t *testing.T) { verifyFn := func(g *models.Performer) { t.Helper() - verifyString(t, g.URL, urlCriterion) + + urls := g.URLs.List() + var url string + if len(urls) > 0 { + url = urls[0] + } + + verifyString(t, url, urlCriterion) } verifyPerformerQuery(t, filter, verifyFn) @@ -1318,6 +1328,12 @@ func verifyPerformerQuery(t *testing.T, filter models.PerformerFilterType, verif t.Helper() performers := queryPerformers(ctx, t, &filter, nil) + for _, performer := range performers { + if err := performer.LoadURLs(ctx, db.Performer); err != nil { + t.Errorf("Error loading movie relationships: %v", err) + } + } + // assume it should find at least one assert.Greater(t, len(performers), 0) diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 736eae6a6..ab5a46c61 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1374,6 +1374,15 @@ func getPerformerNullStringValue(index int, field string) string { return ret.String } +func getPerformerEmptyString(index int, field string) string { + v := getPrefixedNullStringValue("performer", index, field) + if !v.Valid { + return "" + } + + return v.String +} + func getPerformerBoolValue(index int) bool { index = index % 2 return index == 1 @@ -1479,17 +1488,19 @@ func createPerformers(ctx context.Context, n int, o int) error { Name: getPerformerStringValue(index, name), Disambiguation: getPerformerStringValue(index, "disambiguation"), Aliases: models.NewRelatedStrings(performerAliases(index)), - URL: getPerformerNullStringValue(i, urlField), - Favorite: getPerformerBoolValue(i), - Birthdate: getPerformerBirthdate(i), - DeathDate: getPerformerDeathDate(i), - Details: getPerformerStringValue(i, "Details"), - Ethnicity: getPerformerStringValue(i, "Ethnicity"), - PenisLength: getPerformerPenisLength(i), - Circumcised: getPerformerCircumcised(i), - Rating: getIntPtr(getRating(i)), - IgnoreAutoTag: getIgnoreAutoTag(i), - TagIDs: models.NewRelatedIDs(tids), + URLs: models.NewRelatedStrings([]string{ + getPerformerEmptyString(i, urlField), + }), + Favorite: getPerformerBoolValue(i), + Birthdate: getPerformerBirthdate(i), + DeathDate: getPerformerDeathDate(i), + Details: getPerformerStringValue(i, "Details"), + Ethnicity: getPerformerStringValue(i, "Ethnicity"), + PenisLength: getPerformerPenisLength(i), + Circumcised: getPerformerCircumcised(i), + Rating: getIntPtr(getRating(i)), + IgnoreAutoTag: getIgnoreAutoTag(i), + TagIDs: models.NewRelatedIDs(tids), } careerLength := getPerformerCareerLength(i) diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index d4425cfe3..ba86d3b7f 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -29,6 +29,7 @@ var ( scenesURLsJoinTable = goqu.T(scenesURLsTable) performersAliasesJoinTable = goqu.T(performersAliasesTable) + performersURLsJoinTable = goqu.T(performerURLsTable) performersTagsJoinTable = goqu.T(performersTagsTable) performersStashIDsJoinTable = goqu.T("performer_stash_ids") @@ -255,6 +256,14 @@ var ( stringColumn: performersAliasesJoinTable.Col(performerAliasColumn), } + performersURLsTableMgr = &orderedValueTable[string]{ + table: table{ + table: performersURLsJoinTable, + idColumn: performersURLsJoinTable.Col(performerIDColumn), + }, + valueColumn: performersURLsJoinTable.Col(performerURLColumn), + } + performersTagsTableMgr = &joinTable{ table: table{ table: performersTagsJoinTable, diff --git a/pkg/utils/url.go b/pkg/utils/url.go new file mode 100644 index 000000000..e4d2df237 --- /dev/null +++ b/pkg/utils/url.go @@ -0,0 +1,15 @@ +package utils + +import "regexp" + +// URLFromHandle adds the site URL to the input if the input is not already a URL +// siteURL must not end with a slash +func URLFromHandle(input string, siteURL string) string { + // if the input is already a URL, return it + re := regexp.MustCompile(`^https?://`) + if re.MatchString(input) { + return input + } + + return siteURL + "/" + input +} diff --git a/pkg/utils/url_test.go b/pkg/utils/url_test.go new file mode 100644 index 000000000..3076314a7 --- /dev/null +++ b/pkg/utils/url_test.go @@ -0,0 +1,47 @@ +package utils + +import "testing" + +func TestURLFromHandle(t *testing.T) { + type args struct { + input string + siteURL string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "input is already a URL https", + args: args{ + input: "https://foo.com", + siteURL: "https://bar.com", + }, + want: "https://foo.com", + }, + { + name: "input is already a URL http", + args: args{ + input: "http://foo.com", + siteURL: "https://bar.com", + }, + want: "http://foo.com", + }, + { + name: "input is not a URL", + args: args{ + input: "foo", + siteURL: "https://foo.com", + }, + want: "https://foo.com/foo", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := URLFromHandle(tt.args.input, tt.args.siteURL); got != tt.want { + t.Errorf("URLFromHandle() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/ui/v2.5/graphql/data/performer-slim.graphql b/ui/v2.5/graphql/data/performer-slim.graphql index 0018c9700..d9f5f4233 100644 --- a/ui/v2.5/graphql/data/performer-slim.graphql +++ b/ui/v2.5/graphql/data/performer-slim.graphql @@ -3,9 +3,7 @@ fragment SlimPerformerData on Performer { name disambiguation gender - url - twitter - instagram + urls image_path favorite ignore_auto_tag diff --git a/ui/v2.5/graphql/data/performer.graphql b/ui/v2.5/graphql/data/performer.graphql index cd43ca4a5..91393f39e 100644 --- a/ui/v2.5/graphql/data/performer.graphql +++ b/ui/v2.5/graphql/data/performer.graphql @@ -2,10 +2,8 @@ fragment PerformerData on Performer { id name disambiguation - url + urls gender - twitter - instagram birthdate ethnicity country diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index 087ba2efb..a68bb5c70 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -18,9 +18,7 @@ fragment ScrapedPerformerData on ScrapedPerformer { name disambiguation gender - url - twitter - instagram + urls birthdate ethnicity country @@ -50,9 +48,7 @@ fragment ScrapedScenePerformerData on ScrapedPerformer { name disambiguation gender - url - twitter - instagram + urls birthdate ethnicity country diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx index 2df34bbd8..bd3f6acd1 100644 --- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -36,9 +36,6 @@ interface IListOperationProps { const performerFields = [ "favorite", "disambiguation", - "url", - "instagram", - "twitter", "rating100", "gender", "birthdate", @@ -359,15 +356,6 @@ export const EditPerformersDialog: React.FC = ( {renderTextField("career_length", updateInput.career_length, (v) => setUpdateField({ career_length: v }) )} - {renderTextField("url", updateInput.url, (v) => - setUpdateField({ url: v }) - )} - {renderTextField("twitter", updateInput.twitter, (v) => - setUpdateField({ twitter: v }) - )} - {renderTextField("instagram", updateInput.instagram, (v) => - setUpdateField({ instagram: v }) - )} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index b0712f489..85674e023 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -20,7 +20,6 @@ import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useLightbox } from "src/hooks/Lightbox/hooks"; import { useToast } from "src/hooks/Toast"; import { ConfigurationContext } from "src/hooks/Config"; -import TextUtils from "src/utils/text"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { CompressedPerformerDetailsPanel, @@ -44,7 +43,7 @@ import { useRatingKeybinds } from "src/hooks/keybinds"; import { DetailImage } from "src/components/Shared/DetailImage"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; -import { ExternalLink } from "src/components/Shared/ExternalLink"; +import { ExternalLinksButton } from "src/components/Shared/ExternalLinksButton"; interface IProps { performer: GQL.PerformerDataFragment; @@ -90,6 +89,29 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { const [encodingImage, setEncodingImage] = useState(false); const loadStickyHeader = useLoadStickyHeader(); + // a list of urls to display in the performer details + const urls = useMemo(() => { + if (!performer.urls?.length) { + return []; + } + + const twitter = performer.urls.filter((u) => + u.match(/https?:\/\/(?:www\.)?twitter.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 [ + { icon: faLink, className: "", urls: others }, + { icon: faTwitter, className: "twitter", urls: twitter }, + { icon: faInstagram, className: "instagram", urls: instagram }, + ]; + }, [performer.urls]); + const activeImage = useMemo(() => { const performerImage = performer.image_path; if (isEditing) { @@ -478,11 +500,6 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { } function renderClickableIcons() { - /* Collect urls adding into details */ - /* This code can be removed once multple urls are supported for performers */ - const detailURLsRegex = /\[((?:http|www\.)[^\n\]]+)\]/gm; - let urls = performer?.details?.match(detailURLsRegex); - return ( - {performer.url && ( - - )} - {(urls ?? []).map((url, index) => ( - + {urls.map((url) => ( + ))} - {performer.twitter && ( - - )} - {performer.instagram && ( - - )} ); } diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index dc38e53ea..e7d7a8b41 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -14,7 +14,6 @@ import { Icon } from "src/components/Shared/Icon"; import { ImageInput } from "src/components/Shared/ImageInput"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { CountrySelect } from "src/components/Shared/CountrySelect"; -import { URLField } from "src/components/Shared/URLField"; import ImageUtils from "src/utils/image"; import { getStashIDs } from "src/utils/stashIds"; import { stashboxDisplayName } from "src/utils/stashbox"; @@ -45,6 +44,7 @@ import { yupInputEnum, yupDateString, yupUniqueAliases, + yupUniqueStringList, } from "src/utils/yup"; import { useTagsEdit } from "src/hooks/tagsEdit"; @@ -109,9 +109,7 @@ export const PerformerEditPanel: React.FC = ({ tattoos: yup.string().ensure(), piercings: yup.string().ensure(), career_length: yup.string().ensure(), - url: yup.string().ensure(), - twitter: yup.string().ensure(), - instagram: yup.string().ensure(), + urls: yupUniqueStringList(intl), details: yup.string().ensure(), tag_ids: yup.array(yup.string().required()).defined(), ignore_auto_tag: yup.boolean().defined(), @@ -139,9 +137,7 @@ export const PerformerEditPanel: React.FC = ({ tattoos: performer.tattoos ?? "", piercings: performer.piercings ?? "", career_length: performer.career_length ?? "", - url: performer.url ?? "", - twitter: performer.twitter ?? "", - instagram: performer.instagram ?? "", + urls: performer.urls ?? [], details: performer.details ?? "", tag_ids: (performer.tags ?? []).map((t) => t.id), ignore_auto_tag: performer.ignore_auto_tag ?? false, @@ -239,14 +235,8 @@ export const PerformerEditPanel: React.FC = ({ if (state.piercings) { formik.setFieldValue("piercings", state.piercings); } - if (state.url) { - formik.setFieldValue("url", state.url); - } - if (state.twitter) { - formik.setFieldValue("twitter", state.twitter); - } - if (state.instagram) { - formik.setFieldValue("instagram", state.instagram); + if (state.urls) { + formik.setFieldValue("urls", state.urls); } if (state.gender) { // gender is a string in the scraper data @@ -411,8 +401,7 @@ export const PerformerEditPanel: React.FC = ({ } } - async function onScrapePerformerURL() { - const { url } = formik.values; + async function onScrapePerformerURL(url: string) { if (!url) return; setIsLoading(true); try { @@ -613,6 +602,7 @@ export const PerformerEditPanel: React.FC = ({ renderDateField, renderStringListField, renderStashIDsField, + renderURLListField, } = formikUtils(intl, formik); function renderCountryField() { @@ -627,18 +617,6 @@ export const PerformerEditPanel: React.FC = ({ return renderField("country", title, control); } - function renderUrlField() { - const title = intl.formatMessage({ id: "url" }); - const control = ( - - ); - - return renderField("url", title, control); - } function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); @@ -686,10 +664,8 @@ export const PerformerEditPanel: React.FC = ({ {renderInputField("career_length")} - {renderUrlField()} + {renderURLListField("urls", onScrapePerformerURL, urlScrapable)} - {renderInputField("twitter")} - {renderInputField("instagram")} {renderInputField("details", "textarea")} {renderTagsField()} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index dbc4c5108..eb5f26a83 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -8,6 +8,7 @@ import { ScrapeDialogRow, ScrapedTextAreaRow, ScrapedCountryRow, + ScrapedStringListRow, } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; import { Form } from "react-bootstrap"; import { @@ -23,6 +24,7 @@ import { import { IStashBox } from "./PerformerStashBoxModal"; import { ScrapeResult } from "src/components/Shared/ScrapeDialog/scrapeResult"; import { Tag } from "src/components/Tags/TagSelect"; +import { uniq } from "lodash-es"; import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags"; function renderScrapedGender( @@ -268,14 +270,13 @@ export const PerformerScrapeDialog: React.FC = ( const [piercings, setPiercings] = useState>( new ScrapeResult(props.performer.piercings, props.scraped.piercings) ); - const [url, setURL] = useState>( - new ScrapeResult(props.performer.url, props.scraped.url) - ); - const [twitter, setTwitter] = useState>( - new ScrapeResult(props.performer.twitter, props.scraped.twitter) - ); - const [instagram, setInstagram] = useState>( - new ScrapeResult(props.performer.instagram, props.scraped.instagram) + const [urls, setURLs] = useState>( + new ScrapeResult( + props.performer.urls, + props.scraped.urls + ? uniq((props.performer.urls ?? []).concat(props.scraped.urls ?? [])) + : undefined + ) ); const [gender, setGender] = useState>( new ScrapeResult( @@ -334,9 +335,7 @@ export const PerformerScrapeDialog: React.FC = ( careerLength, tattoos, piercings, - url, - twitter, - instagram, + urls, gender, image, tags, @@ -368,9 +367,7 @@ export const PerformerScrapeDialog: React.FC = ( career_length: careerLength.getNewValue(), tattoos: tattoos.getNewValue(), piercings: piercings.getNewValue(), - url: url.getNewValue(), - twitter: twitter.getNewValue(), - instagram: instagram.getNewValue(), + urls: urls.getNewValue(), gender: gender.getNewValue(), tags: tags.getNewValue(), images: newImage ? [newImage] : undefined, @@ -482,20 +479,10 @@ export const PerformerScrapeDialog: React.FC = ( result={piercings} onChange={(value) => setPiercings(value)} /> - setURL(value)} - /> - setTwitter(value)} - /> - setInstagram(value)} + setURLs(value)} /> = ({
) : (
    - {performers.map((p) => ( -
  • + {performers.map((p, i) => ( +
  • + )} + + : + + +
    +
      + {text.map((t, i) => ( +
    • + + {truncate ? : t} + +
    • + ))} +
    +
    + + ); + } + function maybeRenderImage() { if (!images.length) return; @@ -205,9 +243,7 @@ const PerformerModal: React.FC = ({ career_length: performer.career_length, tattoos: performer.tattoos, piercings: performer.piercings, - url: performer.url, - twitter: performer.twitter, - instagram: performer.instagram, + urls: performer.urls, image: images.length > imageIndex ? images[imageIndex] : undefined, details: performer.details, death_date: performer.death_date, @@ -290,9 +326,7 @@ const PerformerModal: React.FC = ({ {maybeRenderField("piercings", performer.piercings, false)} {maybeRenderField("weight", performer.weight, false)} {maybeRenderField("details", performer.details)} - {maybeRenderField("url", performer.url)} - {maybeRenderField("twitter", performer.twitter)} - {maybeRenderField("instagram", performer.instagram)} + {maybeRenderURLListField("urls", performer.urls)} {maybeRenderStashBoxLink()} {maybeRenderImage()} diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts index cbfacc76d..cecbdeb1b 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -78,10 +78,8 @@ export const PERFORMER_FIELDS = [ "tattoos", "piercings", "career_length", - "url", - "twitter", - "instagram", + "urls", "details", ]; -export const STUDIO_FIELDS = ["name", "image", "url", "parent_studio"]; +export const STUDIO_FIELDS = ["name", "image", "urls", "parent_studio"]; diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 5a5bc3904..5fcff5baf 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -165,6 +165,12 @@ width: 12px; } } + + &-value ul { + font-size: 0.8em; + list-style-type: none; + padding-inline-start: 0; + } } .PerformerTagger { diff --git a/ui/v2.5/src/core/performers.ts b/ui/v2.5/src/core/performers.ts index 39a5daa88..83a62eac3 100644 --- a/ui/v2.5/src/core/performers.ts +++ b/ui/v2.5/src/core/performers.ts @@ -90,7 +90,6 @@ export const scrapedPerformerToCreateInput = ( const input: GQL.PerformerCreateInput = { name: toCreate.name ?? "", - url: toCreate.url, gender: stringToGender(toCreate.gender), birthdate: toCreate.birthdate, ethnicity: toCreate.ethnicity, @@ -103,8 +102,7 @@ export const scrapedPerformerToCreateInput = ( tattoos: toCreate.tattoos, piercings: toCreate.piercings, alias_list: aliases, - twitter: toCreate.twitter, - instagram: toCreate.instagram, + urls: toCreate.urls, tag_ids: filterData((toCreate.tags ?? []).map((t) => t.stored_id)), image: (toCreate.images ?? []).length > 0 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 d3ecd2e8e..15272d756 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 @@ -50,8 +50,6 @@ export const PerformerIsMissingCriterionOption = new IsMissingCriterionOption( "is_missing", [ "url", - "twitter", - "instagram", "ethnicity", "country", "hair_color", diff --git a/ui/v2.5/src/utils/text.ts b/ui/v2.5/src/utils/text.ts index b604f1aa8..627822f21 100644 --- a/ui/v2.5/src/utils/text.ts +++ b/ui/v2.5/src/utils/text.ts @@ -369,9 +369,6 @@ const resolution = (width: number, height: number) => { } }; -const twitterURL = new URL("https://www.twitter.com"); -const instagramURL = new URL("https://www.instagram.com"); - const sanitiseURL = (url?: string, siteURL?: URL) => { if (!url) { return url; @@ -485,8 +482,6 @@ const TextUtils = { resolution, sanitiseURL, domainFromURL, - twitterURL, - instagramURL, formatDate, formatDateTime, secondsAsTimeString, From b3d35dfae448dbed5f19640ac9495d5865656ec7 Mon Sep 17 00:00:00 2001 From: bob123491234 <54259225+bob123491234@users.noreply.github.com> Date: Tue, 18 Jun 2024 00:55:20 -0500 Subject: [PATCH 04/28] Add tags to studios (#4858) * Fix makeTagFilter mode * Remove studio_tags filter criterion This is handled by studios_filter. The support for this still needs to be added in the UI, so I have removed the criterion options in the short-term. --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/schema/types/filters.graphql | 7 ++ graphql/schema/types/studio.graphql | 3 + graphql/schema/types/tag.graphql | 1 + internal/api/resolver_model_studio.go | 14 +++ internal/api/resolver_model_tag.go | 12 ++ internal/api/resolver_mutation_studio.go | 10 ++ internal/manager/task_export.go | 13 +++ internal/manager/task_import.go | 3 + pkg/models/jsonschema/studio.go | 1 + pkg/models/mocks/StudioReaderWriter.go | 65 +++++++++++ pkg/models/mocks/TagReaderWriter.go | 23 ++++ pkg/models/model_studio.go | 12 ++ pkg/models/repository_studio.go | 3 + pkg/models/repository_tag.go | 1 + pkg/models/studio.go | 6 + pkg/models/tag.go | 2 + pkg/sqlite/database.go | 2 +- pkg/sqlite/migrations/63_studio_tags.up.sql | 9 ++ pkg/sqlite/setup_test.go | 23 ++++ pkg/sqlite/studio.go | 40 +++++++ pkg/sqlite/studio_filter.go | 27 +++++ pkg/sqlite/studio_test.go | 104 ++++++++++++++++++ pkg/sqlite/tables.go | 9 ++ pkg/sqlite/tag.go | 16 +++ pkg/sqlite/tag_filter.go | 12 ++ pkg/sqlite/tag_test.go | 54 +++++++++ pkg/studio/export_test.go | 2 + pkg/studio/import.go | 76 +++++++++++++ pkg/studio/import_test.go | 103 ++++++++++++++++- pkg/studio/query.go | 13 +++ ui/v2.5/graphql/data/studio-slim.graphql | 4 + ui/v2.5/graphql/data/studio.graphql | 3 + ui/v2.5/graphql/data/tag.graphql | 2 + .../components/Shared/PopoverCountButton.tsx | 11 +- ui/v2.5/src/components/Shared/TagLink.tsx | 11 +- ui/v2.5/src/components/Studios/StudioCard.tsx | 27 ++++- .../StudioDetails/StudioDetailsPanel.tsx | 31 +++++- .../Studios/StudioDetails/StudioEditPanel.tsx | 13 +++ ui/v2.5/src/components/Tags/TagCard.tsx | 14 +++ .../src/components/Tags/TagDetails/Tag.tsx | 22 ++++ .../Tags/TagDetails/TagStudiosPanel.tsx | 17 +++ ui/v2.5/src/locales/en-GB.json | 2 + .../src/models/list-filter/criteria/tags.ts | 7 ++ ui/v2.5/src/models/list-filter/galleries.ts | 2 + ui/v2.5/src/models/list-filter/images.ts | 2 + ui/v2.5/src/models/list-filter/movies.ts | 2 + ui/v2.5/src/models/list-filter/scenes.ts | 2 + ui/v2.5/src/models/list-filter/studios.ts | 5 +- ui/v2.5/src/models/list-filter/tags.ts | 5 + ui/v2.5/src/models/list-filter/types.ts | 2 + ui/v2.5/src/utils/navigation.ts | 7 +- 51 files changed, 844 insertions(+), 13 deletions(-) create mode 100644 pkg/sqlite/migrations/63_studio_tags.up.sql create mode 100644 ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 1df9d2fba..98b790d4f 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -362,6 +362,8 @@ input StudioFilterType { parents: MultiCriterionInput "Filter by StashID" stash_id_endpoint: StashIDCriterionInput + "Filter to only include studios with these tags" + tags: HierarchicalMultiCriterionInput "Filter to only include studios missing this property" is_missing: String # rating expressed as 1-100 @@ -374,6 +376,8 @@ input StudioFilterType { image_count: IntCriterionInput "Filter by gallery count" gallery_count: IntCriterionInput + "Filter by tag count" + tag_count: IntCriterionInput "Filter by url" url: StringCriterionInput "Filter by studio aliases" @@ -498,6 +502,9 @@ input TagFilterType { "Filter by number of performers with this tag" performer_count: IntCriterionInput + "Filter by number of studios with this tag" + studio_count: IntCriterionInput + "Filter by number of movies with this tag" movie_count: IntCriterionInput diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index ff4eb5011..f90183ed0 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -5,6 +5,7 @@ type Studio { parent_studio: Studio child_studios: [Studio!]! aliases: [String!]! + tags: [Tag!]! ignore_auto_tag: Boolean! image_path: String # Resolver @@ -35,6 +36,7 @@ input StudioCreateInput { favorite: Boolean details: String aliases: [String!] + tag_ids: [ID!] ignore_auto_tag: Boolean } @@ -51,6 +53,7 @@ input StudioUpdateInput { favorite: Boolean details: String aliases: [String!] + tag_ids: [ID!] ignore_auto_tag: Boolean } diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index 35229c5cb..6263b64a8 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -13,6 +13,7 @@ type Tag { image_count(depth: Int): Int! # Resolver gallery_count(depth: Int): Int! # Resolver performer_count(depth: Int): Int! # Resolver + studio_count(depth: Int): Int! # Resolver movie_count(depth: Int): Int! # Resolver parents: [Tag!]! children: [Tag!]! diff --git a/internal/api/resolver_model_studio.go b/internal/api/resolver_model_studio.go index f7bc3a00d..011ab343e 100644 --- a/internal/api/resolver_model_studio.go +++ b/internal/api/resolver_model_studio.go @@ -40,6 +40,20 @@ func (r *studioResolver) Aliases(ctx context.Context, obj *models.Studio) ([]str return obj.Aliases.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 { + return obj.LoadTagIDs(ctx, r.repository.Studio) + }); err != nil { + return nil, err + } + } + + var errs []error + ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List()) + return ret, firstError(errs) +} + func (r *studioResolver) SceneCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = scene.CountByStudioID(ctx, r.repository.Scene, obj.ID, depth) diff --git a/internal/api/resolver_model_tag.go b/internal/api/resolver_model_tag.go index 7c32667d2..a9930fb23 100644 --- a/internal/api/resolver_model_tag.go +++ b/internal/api/resolver_model_tag.go @@ -11,6 +11,7 @@ import ( "github.com/stashapp/stash/pkg/movie" "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/scene" + "github.com/stashapp/stash/pkg/studio" ) func (r *tagResolver) Parents(ctx context.Context, obj *models.Tag) (ret []*models.Tag, err error) { @@ -108,6 +109,17 @@ func (r *tagResolver) PerformerCount(ctx context.Context, obj *models.Tag, depth return ret, nil } +func (r *tagResolver) StudioCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = studio.CountByTagID(ctx, r.repository.Studio, obj.ID, depth) + return err + }); err != nil { + return 0, err + } + + return ret, nil +} + func (r *tagResolver) MovieCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = movie.CountByTagID(ctx, r.repository.Movie, obj.ID, depth) diff --git a/internal/api/resolver_mutation_studio.go b/internal/api/resolver_mutation_studio.go index 05d84a979..a33e5d9b6 100644 --- a/internal/api/resolver_mutation_studio.go +++ b/internal/api/resolver_mutation_studio.go @@ -48,6 +48,11 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio return nil, fmt.Errorf("converting parent id: %w", err) } + newStudio.TagIDs, err = translator.relatedIds(input.TagIds) + if err != nil { + return nil, fmt.Errorf("converting tag ids: %w", err) + } + // Process the base 64 encoded image string var imageData []byte if input.Image != nil { @@ -114,6 +119,11 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio return nil, fmt.Errorf("converting parent id: %w", err) } + updatedStudio.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids") + if err != nil { + return nil, fmt.Errorf("converting tag ids: %w", err) + } + // Process the base 64 encoded image string var imageData []byte imageIncluded := translator.hasField("image") diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index 2daac2008..0a294e70e 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -982,6 +982,7 @@ func (t *ExportTask) ExportStudios(ctx context.Context, workers int) { func (t *ExportTask) exportStudio(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.Studio) { defer wg.Done() + r := t.repository studioReader := t.repository.Studio for s := range jobChan { @@ -992,6 +993,18 @@ func (t *ExportTask) exportStudio(ctx context.Context, wg *sync.WaitGroup, jobCh continue } + tags, err := r.Tag.FindByStudioID(ctx, s.ID) + if err != nil { + logger.Errorf("[studios] <%s> error getting studio tags: %s", s.Name, err.Error()) + continue + } + + newStudioJSON.Tags = tag.GetNames(tags) + + if t.includeDependencies { + t.tags.IDs = sliceutil.AppendUniques(t.tags.IDs, tag.GetIDs(tags)) + } + fn := newStudioJSON.Filename() if err := t.json.saveStudio(fn, newStudioJSON); err != nil { diff --git a/internal/manager/task_import.go b/internal/manager/task_import.go index c9d5b54ba..47fbf0cd1 100644 --- a/internal/manager/task_import.go +++ b/internal/manager/task_import.go @@ -292,8 +292,11 @@ func (t *ImportTask) ImportStudios(ctx context.Context) { } func (t *ImportTask) importStudio(ctx context.Context, studioJSON *jsonschema.Studio, pendingParent map[string][]*jsonschema.Studio) error { + r := t.repository + importer := &studio.Importer{ ReaderWriter: t.repository.Studio, + TagWriter: r.Tag, Input: *studioJSON, MissingRefBehaviour: t.MissingRefBehaviour, } diff --git a/pkg/models/jsonschema/studio.go b/pkg/models/jsonschema/studio.go index 84842fa14..80ed97d92 100644 --- a/pkg/models/jsonschema/studio.go +++ b/pkg/models/jsonschema/studio.go @@ -22,6 +22,7 @@ type Studio struct { Details string `json:"details,omitempty"` Aliases []string `json:"aliases,omitempty"` StashIDs []models.StashID `json:"stash_ids,omitempty"` + Tags []string `json:"tags,omitempty"` IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` } diff --git a/pkg/models/mocks/StudioReaderWriter.go b/pkg/models/mocks/StudioReaderWriter.go index c46e45d4c..d4932ca71 100644 --- a/pkg/models/mocks/StudioReaderWriter.go +++ b/pkg/models/mocks/StudioReaderWriter.go @@ -58,6 +58,27 @@ func (_m *StudioReaderWriter) Count(ctx context.Context) (int, error) { return r0, r1 } +// CountByTagID provides a mock function with given fields: ctx, tagID +func (_m *StudioReaderWriter) CountByTagID(ctx context.Context, tagID int) (int, error) { + ret := _m.Called(ctx, tagID) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, tagID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, tagID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Create provides a mock function with given fields: ctx, newStudio func (_m *StudioReaderWriter) Create(ctx context.Context, newStudio *models.Studio) error { ret := _m.Called(ctx, newStudio) @@ -316,6 +337,29 @@ func (_m *StudioReaderWriter) GetStashIDs(ctx context.Context, relatedID int) ([ return r0, r1 } +// GetTagIDs provides a mock function with given fields: ctx, relatedID +func (_m *StudioReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) { + ret := _m.Called(ctx, relatedID) + + var r0 []int + if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { + r0 = rf(ctx, relatedID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]int) + } + } + + 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) @@ -367,6 +411,27 @@ func (_m *StudioReaderWriter) Query(ctx context.Context, studioFilter *models.St return r0, r1, r2 } +// QueryCount provides a mock function with given fields: ctx, studioFilter, findFilter +func (_m *StudioReaderWriter) QueryCount(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) (int, error) { + ret := _m.Called(ctx, studioFilter, findFilter) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, *models.StudioFilterType, *models.FindFilterType) int); ok { + r0 = rf(ctx, studioFilter, findFilter) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *models.StudioFilterType, *models.FindFilterType) error); ok { + r1 = rf(ctx, studioFilter, findFilter) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // QueryForAutoTag provides a mock function with given fields: ctx, words func (_m *StudioReaderWriter) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Studio, error) { ret := _m.Called(ctx, words) diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index d18f6a66b..c3dfe7bd2 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 } +// 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) + + var r0 []*models.Tag + if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok { + r0 = rf(ctx, studioID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Tag) + } + } + + 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 +} + // FindMany provides a mock function with given fields: ctx, ids func (_m *TagReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Tag, error) { ret := _m.Called(ctx, ids) diff --git a/pkg/models/model_studio.go b/pkg/models/model_studio.go index e6e8b7b20..0f4a09bc2 100644 --- a/pkg/models/model_studio.go +++ b/pkg/models/model_studio.go @@ -19,6 +19,7 @@ type Studio struct { IgnoreAutoTag bool `json:"ignore_auto_tag"` Aliases RelatedStrings `json:"aliases"` + TagIDs RelatedIDs `json:"tag_ids"` StashIDs RelatedStashIDs `json:"stash_ids"` } @@ -45,6 +46,7 @@ type StudioPartial struct { IgnoreAutoTag OptionalBool Aliases *UpdateStrings + TagIDs *UpdateIDs StashIDs *UpdateStashIDs } @@ -61,6 +63,12 @@ func (s *Studio) LoadAliases(ctx context.Context, l AliasLoader) error { }) } +func (s *Studio) LoadTagIDs(ctx context.Context, l TagIDLoader) error { + return s.TagIDs.load(func() ([]int, error) { + return l.GetTagIDs(ctx, s.ID) + }) +} + func (s *Studio) LoadStashIDs(ctx context.Context, l StashIDLoader) error { return s.StashIDs.load(func() ([]StashID, error) { return l.GetStashIDs(ctx, s.ID) @@ -72,6 +80,10 @@ func (s *Studio) LoadRelationships(ctx context.Context, l PerformerReader) error return err } + if err := s.LoadTagIDs(ctx, l); err != nil { + return err + } + if err := s.LoadStashIDs(ctx, l); err != nil { return err } diff --git a/pkg/models/repository_studio.go b/pkg/models/repository_studio.go index 272bf8fed..a2b9202f3 100644 --- a/pkg/models/repository_studio.go +++ b/pkg/models/repository_studio.go @@ -22,6 +22,7 @@ type StudioFinder interface { // StudioQueryer provides methods to query studios. type StudioQueryer interface { Query(ctx context.Context, studioFilter *StudioFilterType, findFilter *FindFilterType) ([]*Studio, int, error) + QueryCount(ctx context.Context, studioFilter *StudioFilterType, findFilter *FindFilterType) (int, error) } type StudioAutoTagQueryer interface { @@ -36,6 +37,7 @@ type StudioAutoTagQueryer interface { // StudioCounter provides methods to count studios. type StudioCounter interface { Count(ctx context.Context) (int, error) + CountByTagID(ctx context.Context, tagID int) (int, error) } // StudioCreator provides methods to create studios. @@ -74,6 +76,7 @@ type StudioReader interface { AliasLoader StashIDLoader + TagIDLoader All(ctx context.Context) ([]*Studio, error) GetImage(ctx context.Context, studioID int) ([]byte, error) diff --git a/pkg/models/repository_tag.go b/pkg/models/repository_tag.go index 287aeb211..00f35abc4 100644 --- a/pkg/models/repository_tag.go +++ b/pkg/models/repository_tag.go @@ -22,6 +22,7 @@ type TagFinder interface { FindByPerformerID(ctx context.Context, performerID int) ([]*Tag, error) FindByMovieID(ctx context.Context, movieID int) ([]*Tag, error) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*Tag, error) + FindByStudioID(ctx context.Context, studioID int) ([]*Tag, error) FindByName(ctx context.Context, name string, nocase bool) (*Tag, error) FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error) } diff --git a/pkg/models/studio.go b/pkg/models/studio.go index 0f8b5d153..d5575b7ad 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -14,6 +14,10 @@ type StudioFilterType struct { IsMissing *string `json:"is_missing"` // Filter by rating expressed as 1-100 Rating100 *IntCriterionInput `json:"rating100"` + // Filter to only include studios with these tags + Tags *HierarchicalMultiCriterionInput `json:"tags"` + // Filter by tag count + TagCount *IntCriterionInput `json:"tag_count"` // Filter by favorite Favorite *bool `json:"favorite"` // Filter by scene count @@ -53,6 +57,7 @@ type StudioCreateInput struct { Favorite *bool `json:"favorite"` Details *string `json:"details"` Aliases []string `json:"aliases"` + TagIds []string `json:"tag_ids"` IgnoreAutoTag *bool `json:"ignore_auto_tag"` } @@ -68,5 +73,6 @@ type StudioUpdateInput struct { Favorite *bool `json:"favorite"` Details *string `json:"details"` Aliases []string `json:"aliases"` + TagIds []string `json:"tag_ids"` IgnoreAutoTag *bool `json:"ignore_auto_tag"` } diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 7ee0705a4..cc32a6ce2 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -20,6 +20,8 @@ type TagFilterType struct { GalleryCount *IntCriterionInput `json:"gallery_count"` // Filter by number of performers with this tag PerformerCount *IntCriterionInput `json:"performer_count"` + // Filter by number of studios with this tag + StudioCount *IntCriterionInput `json:"studio_count"` // Filter by number of movies with this tag MovieCount *IntCriterionInput `json:"movie_count"` // Filter by number of markers with this tag diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index cf502392f..6436efee8 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -30,7 +30,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 62 +var appSchemaVersion uint = 63 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/63_studio_tags.up.sql b/pkg/sqlite/migrations/63_studio_tags.up.sql new file mode 100644 index 000000000..ea652f18c --- /dev/null +++ b/pkg/sqlite/migrations/63_studio_tags.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE `studios_tags` ( + `studio_id` integer NOT NULL, + `tag_id` integer NOT NULL, + foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE, + foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE, + PRIMARY KEY(`studio_id`, `tag_id`) +); + +CREATE INDEX `index_studios_tags_on_tag_id` on `studios_tags` (`tag_id`); \ No newline at end of file diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index ab5a46c61..4a6e3edb4 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -207,6 +207,9 @@ const ( tagIdxWithPerformer tagIdx1WithPerformer tagIdx2WithPerformer + tagIdxWithStudio + tagIdx1WithStudio + tagIdx2WithStudio tagIdxWithGallery tagIdx1WithGallery tagIdx2WithGallery @@ -245,6 +248,10 @@ const ( studioIdxWithScenePerformer studioIdxWithImagePerformer studioIdxWithGalleryPerformer + studioIdxWithTag + studioIdx2WithTag + studioIdxWithTwoTags + studioIdxWithParentTag studioIdxWithGrandChild studioIdxWithParentAndChild studioIdxWithGrandParent @@ -510,6 +517,15 @@ var ( } ) +var ( + studioTags = linkMap{ + studioIdxWithTag: {tagIdxWithStudio}, + studioIdx2WithTag: {tagIdx2WithStudio}, + studioIdxWithTwoTags: {tagIdx1WithStudio, tagIdx2WithStudio}, + studioIdxWithParentTag: {tagIdxWithParentAndChild}, + } +) + var ( performerTags = linkMap{ performerIdxWithTag: {tagIdxWithPerformer}, @@ -1566,6 +1582,11 @@ func getTagPerformerCount(id int) int { return len(performerTags.reverseLookup(idx)) } +func getTagStudioCount(id int) int { + idx := indexFromID(tagIDs, id) + return len(studioTags.reverseLookup(idx)) +} + func getTagParentCount(id int) int { if id == tagIDs[tagIdxWithParentTag] || id == tagIDs[tagIdxWithGrandParent] || id == tagIDs[tagIdxWithParentAndChild] { return 1 @@ -1681,11 +1702,13 @@ func createStudios(ctx context.Context, n int, o int) error { // studios [ i ] and [ n + o - i - 1 ] should have similar names with only the Name!=NaMe part different name = getStudioStringValue(index, name) + tids := indexesToIDs(tagIDs, studioTags[i]) studio := models.Studio{ Name: name, URL: getStudioStringValue(index, urlField), Favorite: getStudioBoolValue(index), IgnoreAutoTag: getIgnoreAutoTag(i), + TagIDs: models.NewRelatedIDs(tids), } // only add aliases for some scenes if i == studioIdxWithMovie || i%5 == 0 { diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index ac6a4a4d9..95edf4173 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -25,6 +25,7 @@ const ( studioParentIDColumn = "parent_id" studioNameColumn = "name" studioImageBlobColumn = "image_blob" + studiosTagsTable = "studios_tags" ) type studioRow struct { @@ -94,6 +95,7 @@ type studioRepositoryType struct { repository stashIDs stashIDRepository + tags joinRepository scenes repository images repository @@ -124,11 +126,21 @@ var ( tableName: galleryTable, idColumn: studioIDColumn, }, + tags: joinRepository{ + repository: repository{ + tableName: studiosTagsTable, + idColumn: studioIDColumn, + }, + fkColumn: tagIDColumn, + foreignTable: tagTable, + orderBy: "tags.name ASC", + }, } ) type StudioStore struct { blobJoinQueryBuilder + tagRelationshipStore tableMgr *table } @@ -139,6 +151,11 @@ func NewStudioStore(blobStore *BlobStore) *StudioStore { blobStore: blobStore, joinTable: studioTable, }, + tagRelationshipStore: tagRelationshipStore{ + idRelationshipStore: idRelationshipStore{ + joinTable: studiosTagsTableMgr, + }, + }, tableMgr: studioTableMgr, } @@ -173,6 +190,10 @@ func (qb *StudioStore) Create(ctx context.Context, newObject *models.Studio) err } } + if err := qb.tagRelationshipStore.createRelationships(ctx, id, newObject.TagIDs); err != nil { + return err + } + if newObject.StashIDs.Loaded() { if err := studiosStashIDsTableMgr.insertJoins(ctx, id, newObject.StashIDs.List()); err != nil { return err @@ -213,6 +234,10 @@ func (qb *StudioStore) UpdatePartial(ctx context.Context, input models.StudioPar } } + if err := qb.tagRelationshipStore.modifyRelationships(ctx, input.ID, input.TagIDs); err != nil { + return nil, err + } + if input.StashIDs != nil { if err := studiosStashIDsTableMgr.modifyJoins(ctx, input.ID, input.StashIDs.StashIDs, input.StashIDs.Mode); err != nil { return nil, err @@ -237,6 +262,10 @@ func (qb *StudioStore) Update(ctx context.Context, updatedObject *models.Studio) } } + if err := qb.tagRelationshipStore.replaceRelationships(ctx, updatedObject.ID, updatedObject.TagIDs); err != nil { + return err + } + if updatedObject.StashIDs.Loaded() { if err := studiosStashIDsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.StashIDs.List()); err != nil { return err @@ -538,6 +567,15 @@ func (qb *StudioStore) Query(ctx context.Context, studioFilter *models.StudioFil return studios, countResult, nil } +func (qb *StudioStore) QueryCount(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) (int, error) { + query, err := qb.makeQuery(ctx, studioFilter, findFilter) + if err != nil { + return 0, err + } + + return query.executeCount(ctx) +} + var studioSortOptions = sortOptions{ "child_count", "created_at", @@ -569,6 +607,8 @@ func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) (string, sortQuery := "" switch sort { + case "tag_count": + sortQuery += getCountSort(studioTable, studiosTagsTable, studioIDColumn, direction) case "scenes_count": sortQuery += getCountSort(studioTable, sceneTable, studioIDColumn, direction) case "images_count": diff --git a/pkg/sqlite/studio_filter.go b/pkg/sqlite/studio_filter.go index 45745c471..040fc1858 100644 --- a/pkg/sqlite/studio_filter.go +++ b/pkg/sqlite/studio_filter.go @@ -74,11 +74,13 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler { }, qb.isMissingCriterionHandler(studioFilter.IsMissing), + qb.tagCountCriterionHandler(studioFilter.TagCount), qb.sceneCountCriterionHandler(studioFilter.SceneCount), qb.imageCountCriterionHandler(studioFilter.ImageCount), qb.galleryCountCriterionHandler(studioFilter.GalleryCount), qb.parentCriterionHandler(studioFilter.Parents), qb.aliasCriterionHandler(studioFilter.Aliases), + qb.tagsCriterionHandler(studioFilter.Tags), qb.childCountCriterionHandler(studioFilter.ChildCount), ×tampCriterionHandler{studioFilter.CreatedAt, studioTable + ".created_at", nil}, ×tampCriterionHandler{studioFilter.UpdatedAt, studioTable + ".updated_at", nil}, @@ -161,6 +163,16 @@ func (qb *studioFilterHandler) galleryCountCriterionHandler(galleryCount *models } } +func (qb *studioFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: studioTable, + joinTable: studiosTagsTable, + primaryFK: studioIDColumn, + } + + return h.handler(tagCount) +} + func (qb *studioFilterHandler) parentCriterionHandler(parents *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { f.addLeftJoin("studios", "parent_studio", "parent_studio.id = studios.parent_id") @@ -200,3 +212,18 @@ func (qb *studioFilterHandler) childCountCriterionHandler(childCount *models.Int } } } + +func (qb *studioFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + h := joinedHierarchicalMultiCriterionHandlerBuilder{ + primaryTable: studioTable, + foreignTable: tagTable, + foreignFK: "tag_id", + + relationsTable: "tags_relations", + joinTable: studiosTagsTable, + joinAs: "studio_tag", + primaryFK: studioIDColumn, + } + + return h.handler(tags) +} diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index c75c2a61f..627129f0d 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -704,6 +704,110 @@ func TestStudioQueryRating(t *testing.T) { verifyStudiosRating(t, ratingCriterion) } +func queryStudios(ctx context.Context, t *testing.T, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) []*models.Studio { + t.Helper() + studios, _, err := db.Studio.Query(ctx, studioFilter, findFilter) + if err != nil { + t.Errorf("Error querying studio: %s", err.Error()) + } + + return studios +} + +func TestStudioQueryTags(t *testing.T) { + withTxn(func(ctx context.Context) error { + tagCriterion := models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithStudio]), + strconv.Itoa(tagIDs[tagIdx1WithStudio]), + }, + Modifier: models.CriterionModifierIncludes, + } + + studioFilter := models.StudioFilterType{ + Tags: &tagCriterion, + } + + // ensure ids are correct + studios := queryStudios(ctx, t, &studioFilter, nil) + assert.Len(t, studios, 2) + for _, studio := range studios { + assert.True(t, studio.ID == studioIDs[studioIdxWithTag] || studio.ID == studioIDs[studioIdxWithTwoTags]) + } + + tagCriterion = models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithStudio]), + strconv.Itoa(tagIDs[tagIdx2WithStudio]), + }, + Modifier: models.CriterionModifierIncludesAll, + } + + studios = queryStudios(ctx, t, &studioFilter, nil) + + assert.Len(t, studios, 1) + assert.Equal(t, sceneIDs[studioIdxWithTwoTags], studios[0].ID) + + tagCriterion = models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithStudio]), + }, + Modifier: models.CriterionModifierExcludes, + } + + q := getSceneStringValue(studioIdxWithTwoTags, titleField) + findFilter := models.FindFilterType{ + Q: &q, + } + + studios = queryStudios(ctx, t, &studioFilter, &findFilter) + assert.Len(t, studios, 0) + + return nil + }) +} + +func TestStudioQueryTagCount(t *testing.T) { + const tagCount = 1 + tagCountCriterion := models.IntCriterionInput{ + Value: tagCount, + Modifier: models.CriterionModifierEquals, + } + + verifyStudiosTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyStudiosTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyStudiosTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierLessThan + verifyStudiosTagCount(t, tagCountCriterion) +} + +func verifyStudiosTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { + withTxn(func(ctx context.Context) error { + sqb := db.Studio + studioFilter := models.StudioFilterType{ + TagCount: &tagCountCriterion, + } + + studios := queryStudios(ctx, t, &studioFilter, nil) + assert.Greater(t, len(studios), 0) + + for _, studio := range studios { + ids, err := sqb.GetTagIDs(ctx, studio.ID) + if err != nil { + return err + } + verifyInt(t, len(ids), tagCountCriterion) + } + + return nil + }) +} + func verifyStudioQuery(t *testing.T, filter models.StudioFilterType, verifyFn func(ctx context.Context, s *models.Studio)) { withTxn(func(ctx context.Context) error { t.Helper() diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index ba86d3b7f..2f500639e 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -34,6 +34,7 @@ var ( performersStashIDsJoinTable = goqu.T("performer_stash_ids") studiosAliasesJoinTable = goqu.T(studioAliasesTable) + studiosTagsJoinTable = goqu.T(studiosTagsTable) studiosStashIDsJoinTable = goqu.T("studio_stash_ids") moviesURLsJoinTable = goqu.T(movieURLsTable) @@ -294,6 +295,14 @@ var ( stringColumn: studiosAliasesJoinTable.Col(studioAliasColumn), } + studiosTagsTableMgr = &joinTable{ + table: table{ + table: studiosTagsJoinTable, + idColumn: studiosTagsJoinTable.Col(studioIDColumn), + }, + fkColumn: studiosTagsJoinTable.Col(tagIDColumn), + } + studiosStashIDsTableMgr = &stashIDTable{ table: table{ table: studiosStashIDsJoinTable, diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index a4bf3793a..c6494f38b 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -448,6 +448,18 @@ func (qb *TagStore) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) return qb.queryTags(ctx, query, args) } +func (qb *TagStore) FindByStudioID(ctx context.Context, studioID int) ([]*models.Tag, error) { + query := ` + SELECT tags.* FROM tags + LEFT JOIN studios_tags as studios_join on studios_join.tag_id = tags.id + WHERE studios_join.studio_id = ? + GROUP BY tags.id + ` + query += qb.getDefaultTagSort() + args := []interface{}{studioID} + return qb.queryTags(ctx, query, args) +} + func (qb *TagStore) FindByName(ctx context.Context, name string, nocase bool) (*models.Tag, error) { // query := "SELECT * FROM tags WHERE name = ?" // if nocase { @@ -628,6 +640,7 @@ var tagSortOptions = sortOptions{ "id", "images_count", "movies_count", + "studios_count", "name", "performers_count", "random", @@ -668,6 +681,8 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte sortQuery += getCountSort(tagTable, galleriesTagsTable, tagIDColumn, direction) case "performers_count": sortQuery += getCountSort(tagTable, performersTagsTable, tagIDColumn, direction) + case "studios_count": + sortQuery += getCountSort(tagTable, studiosTagsTable, tagIDColumn, direction) case "movies_count": sortQuery += getCountSort(tagTable, moviesTagsTable, tagIDColumn, direction) default: @@ -767,6 +782,7 @@ func (qb *TagStore) Merge(ctx context.Context, source []int, destination int) er galleriesTagsTable: galleryIDColumn, imagesTagsTable: imageIDColumn, "performers_tags": "performer_id", + "studios_tags": "studio_id", } args = append(args, destination) diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index 776a49fc4..5bae18c00 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -66,6 +66,7 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler { qb.imageCountCriterionHandler(tagFilter.ImageCount), qb.galleryCountCriterionHandler(tagFilter.GalleryCount), qb.performerCountCriterionHandler(tagFilter.PerformerCount), + qb.studioCountCriterionHandler(tagFilter.StudioCount), qb.movieCountCriterionHandler(tagFilter.MovieCount), qb.markerCountCriterionHandler(tagFilter.MarkerCount), qb.parentsCriterionHandler(tagFilter.Parents), @@ -175,6 +176,17 @@ func (qb *tagFilterHandler) performerCountCriterionHandler(performerCount *model } } +func (qb *tagFilterHandler) studioCountCriterionHandler(studioCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if studioCount != nil { + f.addLeftJoin("studios_tags", "", "studios_tags.tag_id = tags.id") + clause, args := getIntCriterionWhereClause("count(distinct studios_tags.studio_id)", *studioCount) + + f.addHaving(clause, args...) + } + } +} + func (qb *tagFilterHandler) movieCountCriterionHandler(movieCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if movieCount != nil { diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index d71316413..099f8b912 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -230,6 +230,10 @@ func TestTagQuerySort(t *testing.T) { tags = queryTags(ctx, t, sqb, nil, findFilter) assert.Equal(tagIDs[tagIdx2WithPerformer], tags[0].ID) + sortBy = "studios_count" + tags = queryTags(ctx, t, sqb, nil, findFilter) + assert.Equal(tagIDs[tagIdx2WithStudio], tags[0].ID) + sortBy = "movies_count" tags = queryTags(ctx, t, sqb, nil, findFilter) assert.Equal(tagIDs[tagIdx1WithMovie], tags[0].ID) @@ -569,6 +573,45 @@ func verifyTagPerformerCount(t *testing.T, imageCountCriterion models.IntCriteri }) } +func TestTagQueryStudioCount(t *testing.T) { + countCriterion := models.IntCriterionInput{ + Value: 1, + Modifier: models.CriterionModifierEquals, + } + + verifyTagStudioCount(t, countCriterion) + + countCriterion.Modifier = models.CriterionModifierNotEquals + verifyTagStudioCount(t, countCriterion) + + countCriterion.Modifier = models.CriterionModifierLessThan + verifyTagStudioCount(t, countCriterion) + + countCriterion.Value = 0 + countCriterion.Modifier = models.CriterionModifierGreaterThan + verifyTagStudioCount(t, countCriterion) +} + +func verifyTagStudioCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { + withTxn(func(ctx context.Context) error { + qb := db.Tag + tagFilter := models.TagFilterType{ + StudioCount: &imageCountCriterion, + } + + tags, _, err := qb.Query(ctx, &tagFilter, nil) + if err != nil { + t.Errorf("Error querying tag: %s", err.Error()) + } + + for _, tag := range tags { + verifyInt(t, getTagStudioCount(tag.ID), imageCountCriterion) + } + + return nil + }) +} + func TestTagQueryParentCount(t *testing.T) { countCriterion := models.IntCriterionInput{ Value: 1, @@ -882,6 +925,9 @@ func TestTagMerge(t *testing.T) { tagIdxWithPerformer, tagIdx1WithPerformer, tagIdx2WithPerformer, + tagIdxWithStudio, + tagIdx1WithStudio, + tagIdx2WithStudio, tagIdxWithGallery, tagIdx1WithGallery, tagIdx2WithGallery, @@ -970,6 +1016,14 @@ func TestTagMerge(t *testing.T) { assert.Contains(performerTagIDs, destID) + // ensure studio points to new tag + studioTagIDs, err := db.Studio.GetTagIDs(ctx, studioIDs[studioIdxWithTwoTags]) + if err != nil { + return err + } + + assert.Contains(studioTagIDs, destID) + return nil }); err != nil { t.Error(err.Error()) diff --git a/pkg/studio/export_test.go b/pkg/studio/export_test.go index da6da8ad4..0e42141ec 100644 --- a/pkg/studio/export_test.go +++ b/pkg/studio/export_test.go @@ -68,6 +68,7 @@ func createFullStudio(id int, parentID int) models.Studio { Rating: &rating, IgnoreAutoTag: autoTagIgnored, Aliases: models.NewRelatedStrings(aliases), + TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs(stashIDs), } @@ -84,6 +85,7 @@ func createEmptyStudio(id int) models.Studio { CreatedAt: createTime, UpdatedAt: updateTime, Aliases: models.NewRelatedStrings([]string{}), + TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), } } diff --git a/pkg/studio/import.go b/pkg/studio/import.go index bfee4133f..d88065078 100644 --- a/pkg/studio/import.go +++ b/pkg/studio/import.go @@ -4,9 +4,11 @@ import ( "context" "errors" "fmt" + "strings" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" + "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/utils" ) @@ -19,6 +21,7 @@ var ErrParentStudioNotExist = errors.New("parent studio does not exist") type Importer struct { ReaderWriter ImporterReaderWriter + TagWriter models.TagFinderCreator Input jsonschema.Studio MissingRefBehaviour models.ImportMissingRefEnum @@ -34,6 +37,10 @@ func (i *Importer) PreImport(ctx context.Context) error { return err } + if err := i.populateTags(ctx); err != nil { + return err + } + var err error if len(i.Input.Image) > 0 { i.imageData, err = utils.ProcessBase64Image(i.Input.Image) @@ -45,6 +52,74 @@ func (i *Importer) PreImport(ctx context.Context) error { return nil } +func (i *Importer) populateTags(ctx context.Context) error { + if len(i.Input.Tags) > 0 { + + tags, err := importTags(ctx, i.TagWriter, i.Input.Tags, i.MissingRefBehaviour) + if err != nil { + return err + } + + for _, p := range tags { + i.studio.TagIDs.Add(p.ID) + } + } + + return nil +} + +func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string, missingRefBehaviour models.ImportMissingRefEnum) ([]*models.Tag, error) { + tags, err := tagWriter.FindByNames(ctx, names, false) + if err != nil { + return nil, err + } + + var pluckedNames []string + for _, tag := range tags { + pluckedNames = append(pluckedNames, tag.Name) + } + + missingTags := sliceutil.Filter(names, func(name string) bool { + return !sliceutil.Contains(pluckedNames, name) + }) + + if len(missingTags) > 0 { + if missingRefBehaviour == models.ImportMissingRefEnumFail { + return nil, fmt.Errorf("tags [%s] not found", strings.Join(missingTags, ", ")) + } + + if missingRefBehaviour == models.ImportMissingRefEnumCreate { + createdTags, err := createTags(ctx, tagWriter, missingTags) + if err != nil { + return nil, fmt.Errorf("error creating tags: %v", err) + } + + tags = append(tags, createdTags...) + } + + // ignore if MissingRefBehaviour set to Ignore + } + + return tags, nil +} + +func createTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string) ([]*models.Tag, error) { + var ret []*models.Tag + for _, name := range names { + newTag := models.NewTag() + newTag.Name = name + + err := tagWriter.Create(ctx, &newTag) + if err != nil { + return nil, err + } + + ret = append(ret, &newTag) + } + + return ret, nil +} + func (i *Importer) populateParentStudio(ctx context.Context) error { if i.Input.ParentStudio != "" { studio, err := i.ReaderWriter.FindByName(ctx, i.Input.ParentStudio, false) @@ -149,6 +224,7 @@ func studioJSONtoStudio(studioJSON jsonschema.Studio) models.Studio { CreatedAt: studioJSON.CreatedAt.GetTime(), UpdatedAt: studioJSON.UpdatedAt.GetTime(), + TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs(studioJSON.StashIDs), } diff --git a/pkg/studio/import_test.go b/pkg/studio/import_test.go index e89256371..882b8ca56 100644 --- a/pkg/studio/import_test.go +++ b/pkg/studio/import_test.go @@ -16,13 +16,19 @@ const invalidImage = "aW1hZ2VCeXRlcw&&" const ( studioNameErr = "studioNameErr" - existingStudioName = "existingTagName" + existingStudioName = "existingStudioName" existingStudioID = 100 + existingTagID = 105 + errTagsID = 106 existingParentStudioName = "existingParentStudioName" existingParentStudioErr = "existingParentStudioErr" missingParentStudioName = "existingParentStudioName" + + existingTagName = "existingTagName" + existingTagErr = "existingTagErr" + missingTagName = "missingTagName" ) var testCtx = context.Background() @@ -67,6 +73,97 @@ func TestImporterPreImport(t *testing.T) { assert.Equal(t, expectedStudio, i.studio) } +func TestImporterPreImportWithTag(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + ReaderWriter: db.Studio, + TagWriter: db.Tag, + MissingRefBehaviour: models.ImportMissingRefEnumFail, + Input: jsonschema.Studio{ + Tags: []string{ + existingTagName, + }, + }, + } + + db.Tag.On("FindByNames", testCtx, []string{existingTagName}, false).Return([]*models.Tag{ + { + ID: existingTagID, + Name: existingTagName, + }, + }, nil).Once() + db.Tag.On("FindByNames", testCtx, []string{existingTagErr}, false).Return(nil, errors.New("FindByNames error")).Once() + + err := i.PreImport(testCtx) + assert.Nil(t, err) + assert.Equal(t, existingTagID, i.studio.TagIDs.List()[0]) + + i.Input.Tags = []string{existingTagErr} + err = i.PreImport(testCtx) + assert.NotNil(t, err) + + db.AssertExpectations(t) +} + +func TestImporterPreImportWithMissingTag(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + ReaderWriter: db.Studio, + TagWriter: db.Tag, + Input: jsonschema.Studio{ + Tags: []string{ + missingTagName, + }, + }, + MissingRefBehaviour: models.ImportMissingRefEnumFail, + } + + db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3) + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Run(func(args mock.Arguments) { + t := args.Get(1).(*models.Tag) + t.ID = existingTagID + }).Return(nil) + + err := i.PreImport(testCtx) + assert.NotNil(t, err) + + i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore + err = i.PreImport(testCtx) + assert.Nil(t, err) + + i.MissingRefBehaviour = models.ImportMissingRefEnumCreate + err = i.PreImport(testCtx) + assert.Nil(t, err) + assert.Equal(t, existingTagID, i.studio.TagIDs.List()[0]) + + db.AssertExpectations(t) +} + +func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + ReaderWriter: db.Studio, + TagWriter: db.Tag, + Input: jsonschema.Studio{ + Tags: []string{ + missingTagName, + }, + }, + MissingRefBehaviour: models.ImportMissingRefEnumCreate, + } + + db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once() + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Return(errors.New("Create error")) + + err := i.PreImport(testCtx) + assert.NotNil(t, err) + + db.AssertExpectations(t) +} + func TestImporterPreImportWithParent(t *testing.T) { db := mocks.NewDatabase() @@ -156,6 +253,7 @@ func TestImporterPostImport(t *testing.T) { i := Importer{ ReaderWriter: db.Studio, + TagWriter: db.Tag, Input: jsonschema.Studio{ Aliases: []string{"alias"}, }, @@ -181,6 +279,7 @@ func TestImporterFindExistingID(t *testing.T) { i := Importer{ ReaderWriter: db.Studio, + TagWriter: db.Tag, Input: jsonschema.Studio{ Name: studioName, }, @@ -223,6 +322,7 @@ func TestCreate(t *testing.T) { i := Importer{ ReaderWriter: db.Studio, + TagWriter: db.Tag, studio: studio, } @@ -258,6 +358,7 @@ func TestUpdate(t *testing.T) { i := Importer{ ReaderWriter: db.Studio, + TagWriter: db.Tag, studio: studio, } diff --git a/pkg/studio/query.go b/pkg/studio/query.go index b20cec331..97e8e2c1b 100644 --- a/pkg/studio/query.go +++ b/pkg/studio/query.go @@ -2,6 +2,7 @@ package studio import ( "context" + "strconv" "github.com/stashapp/stash/pkg/models" ) @@ -53,3 +54,15 @@ func ByAlias(ctx context.Context, qb models.StudioQueryer, alias string) (*model return nil, nil } + +func CountByTagID(ctx context.Context, qb models.StudioQueryer, id int, depth *int) (int, error) { + filter := &models.StudioFilterType{ + Tags: &models.HierarchicalMultiCriterionInput{ + Value: []string{strconv.Itoa(id)}, + Modifier: models.CriterionModifierIncludes, + Depth: depth, + }, + } + + return qb.QueryCount(ctx, filter, nil) +} diff --git a/ui/v2.5/graphql/data/studio-slim.graphql b/ui/v2.5/graphql/data/studio-slim.graphql index c37513194..406a2ffa7 100644 --- a/ui/v2.5/graphql/data/studio-slim.graphql +++ b/ui/v2.5/graphql/data/studio-slim.graphql @@ -12,4 +12,8 @@ fragment SlimStudioData on Studio { details rating100 aliases + tags { + id + name + } } diff --git a/ui/v2.5/graphql/data/studio.graphql b/ui/v2.5/graphql/data/studio.graphql index 576faea23..afd254d22 100644 --- a/ui/v2.5/graphql/data/studio.graphql +++ b/ui/v2.5/graphql/data/studio.graphql @@ -33,6 +33,9 @@ fragment StudioData on Studio { rating100 favorite aliases + tags { + ...SlimTagData + } } fragment SelectStudioData on Studio { diff --git a/ui/v2.5/graphql/data/tag.graphql b/ui/v2.5/graphql/data/tag.graphql index d473bf8c6..695bb5de6 100644 --- a/ui/v2.5/graphql/data/tag.graphql +++ b/ui/v2.5/graphql/data/tag.graphql @@ -16,6 +16,8 @@ fragment TagData on Tag { gallery_count_all: gallery_count(depth: -1) performer_count performer_count_all: performer_count(depth: -1) + studio_count + studio_count_all: studio_count(depth: -1) movie_count movie_count_all: movie_count(depth: -1) diff --git a/ui/v2.5/src/components/Shared/PopoverCountButton.tsx b/ui/v2.5/src/components/Shared/PopoverCountButton.tsx index dc30cfa1f..c455145fc 100644 --- a/ui/v2.5/src/components/Shared/PopoverCountButton.tsx +++ b/ui/v2.5/src/components/Shared/PopoverCountButton.tsx @@ -4,6 +4,7 @@ import { faImages, faPlayCircle, faUser, + faVideo, faMapMarkerAlt, } from "@fortawesome/free-solid-svg-icons"; import React, { useMemo } from "react"; @@ -20,7 +21,8 @@ type PopoverLinkType = | "gallery" | "marker" | "movie" - | "performer"; + | "performer" + | "studio"; interface IProps { className?: string; @@ -54,6 +56,8 @@ export const PopoverCountButton: React.FC = ({ return faFilm; case "performer": return faUser; + case "studio": + return faVideo; } } @@ -89,6 +93,11 @@ export const PopoverCountButton: React.FC = ({ one: "performer", other: "performers", }; + case "studio": + return { + one: "studio", + other: "studios", + }; } } diff --git a/ui/v2.5/src/components/Shared/TagLink.tsx b/ui/v2.5/src/components/Shared/TagLink.tsx index 9c2ed1cb3..f2fe7c49f 100644 --- a/ui/v2.5/src/components/Shared/TagLink.tsx +++ b/ui/v2.5/src/components/Shared/TagLink.tsx @@ -191,7 +191,14 @@ export const GalleryLink: React.FC = ({ interface ITagLinkProps { tag: INamedObject; - linkType?: "scene" | "gallery" | "image" | "details" | "performer" | "movie"; + linkType?: + | "scene" + | "gallery" + | "image" + | "details" + | "performer" + | "movie" + | "studio"; className?: string; hoverPlacement?: Placement; showHierarchyIcon?: boolean; @@ -212,6 +219,8 @@ export const TagLink: React.FC = ({ return NavUtils.makeTagScenesUrl(tag); case "performer": return NavUtils.makeTagPerformersUrl(tag); + case "studio": + return NavUtils.makeTagStudiosUrl(tag); case "gallery": return NavUtils.makeTagGalleriesUrl(tag); case "image": diff --git a/ui/v2.5/src/components/Studios/StudioCard.tsx b/ui/v2.5/src/components/Studios/StudioCard.tsx index 007635cce..1c1e5e6ee 100644 --- a/ui/v2.5/src/components/Studios/StudioCard.tsx +++ b/ui/v2.5/src/components/Studios/StudioCard.tsx @@ -6,13 +6,17 @@ import { GridCard, calculateCardWidth, } from "src/components/Shared/GridCard/GridCard"; -import { ButtonGroup } from "react-bootstrap"; +import { HoverPopover } from "../Shared/HoverPopover"; +import { Icon } from "../Shared/Icon"; +import { TagLink } from "../Shared/TagLink"; +import { Button, ButtonGroup } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import { PopoverCountButton } from "../Shared/PopoverCountButton"; import { RatingBanner } from "../Shared/RatingBanner"; import ScreenUtils from "src/utils/screen"; import { FavoriteIcon } from "../Shared/FavoriteIcon"; import { useStudioUpdate } from "src/core/StashService"; +import { faTag } from "@fortawesome/free-solid-svg-icons"; interface IProps { studio: GQL.StudioDataFragment; @@ -164,13 +168,31 @@ export const StudioCard: React.FC = ({ ); } + function maybeRenderTagPopoverButton() { + if (studio.tags.length <= 0) return; + + const popoverContent = studio.tags.map((tag) => ( + + )); + + return ( + + + + ); + } + function maybeRenderPopoverButtonGroup() { if ( studio.scene_count || studio.image_count || studio.gallery_count || studio.movie_count || - studio.performer_count + studio.performer_count || + studio.tags.length > 0 ) { return ( <> @@ -181,6 +203,7 @@ export const StudioCard: React.FC = ({ {maybeRenderImagesPopoverButton()} {maybeRenderGalleriesPopoverButton()} {maybeRenderPerformersPopoverButton()} + {maybeRenderTagPopoverButton()} ); diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx index a6c5126cb..5bf877b11 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { TagLink } from "src/components/Shared/TagLink"; import * as GQL from "src/core/generated-graphql"; import { DetailItem } from "src/components/Shared/DetailItem"; import { StashIDPill } from "src/components/Shared/StashID"; @@ -15,6 +16,19 @@ export const StudioDetailsPanel: React.FC = ({ collapsed, fullWidth, }) => { + function renderTagsField() { + if (!studio.tags.length) { + return; + } + return ( +
      + {(studio.tags ?? []).map((tag) => ( + + ))} +
    + ); + } + function renderStashIDs() { if (!studio.stash_ids?.length) { return; @@ -36,11 +50,18 @@ export const StudioDetailsPanel: React.FC = ({ function maybeRenderExtraDetails() { if (!collapsed) { return ( - + <> + + + ); } } diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index dc0c03f36..1089e5ffe 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -16,6 +16,7 @@ import { handleUnsavedChanges } from "src/utils/navigation"; import { formikUtils } from "src/utils/form"; import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup"; import { Studio, StudioSelect } from "../StudioSelect"; +import { useTagsEdit } from "src/hooks/tagsEdit"; interface IStudioEditPanel { studio: Partial; @@ -50,6 +51,7 @@ export const StudioEditPanel: React.FC = ({ details: yup.string().ensure(), parent_id: yup.string().required().nullable(), aliases: yupUniqueAliases(intl, "name"), + tag_ids: yup.array(yup.string().required()).defined(), ignore_auto_tag: yup.boolean().defined(), stash_ids: yup.mixed().defined(), image: yup.string().nullable().optional(), @@ -62,6 +64,7 @@ export const StudioEditPanel: React.FC = ({ details: studio.details ?? "", parent_id: studio.parent_studio?.id ?? null, aliases: studio.aliases ?? [], + tag_ids: (studio.tags ?? []).map((t) => t.id), ignore_auto_tag: studio.ignore_auto_tag ?? false, stash_ids: getStashIDs(studio.stash_ids), }; @@ -75,6 +78,10 @@ export const StudioEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); + const { tagsControl } = useTagsEdit(studio.tags, (ids) => + formik.setFieldValue("tag_ids", ids) + ); + function onSetParentStudio(item: Studio | null) { setParentStudio(item); formik.setFieldValue("parent_id", item ? item.id : null); @@ -157,6 +164,11 @@ export const StudioEditPanel: React.FC = ({ return renderField("parent_id", title, control); } + function renderTagsField() { + const title = intl.formatMessage({ id: "tags" }); + return renderField("tag_ids", title, tagsControl()); + } + if (isLoading) return ; return ( @@ -178,6 +190,7 @@ export const StudioEditPanel: React.FC = ({ {renderInputField("url")} {renderInputField("details", "textarea")} {renderParentStudioField()} + {renderTagsField()} {renderStashIDsField("stash_ids", "studios")}
    {renderInputField("ignore_auto_tag", "checkbox")} diff --git a/ui/v2.5/src/components/Tags/TagCard.tsx b/ui/v2.5/src/components/Tags/TagCard.tsx index 51444f999..424f8c5f5 100644 --- a/ui/v2.5/src/components/Tags/TagCard.tsx +++ b/ui/v2.5/src/components/Tags/TagCard.tsx @@ -223,6 +223,19 @@ export const TagCard: React.FC = ({ ); } + function maybeRenderStudiosPopoverButton() { + if (!tag.studio_count) return; + + return ( + + ); + } + function maybeRenderMoviesPopoverButton() { if (!tag.movie_count) return; @@ -248,6 +261,7 @@ export const TagCard: React.FC = ({ {maybeRenderMoviesPopoverButton()} {maybeRenderSceneMarkersPopoverButton()} {maybeRenderPerformersPopoverButton()} + {maybeRenderStudiosPopoverButton()} ); diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index aa10275b6..c80473db8 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -26,6 +26,7 @@ import { TagScenesPanel } from "./TagScenesPanel"; import { TagMarkersPanel } from "./TagMarkersPanel"; import { TagImagesPanel } from "./TagImagesPanel"; import { TagPerformersPanel } from "./TagPerformersPanel"; +import { TagStudiosPanel } from "./TagStudiosPanel"; import { TagGalleriesPanel } from "./TagGalleriesPanel"; import { CompressedTagDetailsPanel, TagDetailsPanel } from "./TagDetailsPanel"; import { TagEditPanel } from "./TagEditPanel"; @@ -61,6 +62,7 @@ const validTabs = [ "movies", "markers", "performers", + "studios", ] as const; type TabKey = (typeof validTabs)[number]; @@ -109,6 +111,8 @@ const TagPage: React.FC = ({ tag, tabKey }) => { (showAllCounts ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0; const performerCount = (showAllCounts ? tag.performer_count_all : tag.performer_count) ?? 0; + const studioCount = + (showAllCounts ? tag.studio_count_all : tag.studio_count) ?? 0; const populatedDefaultTab = useMemo(() => { let ret: TabKey = "scenes"; @@ -123,6 +127,8 @@ const TagPage: React.FC = ({ tag, tabKey }) => { ret = "markers"; } else if (performerCount != 0) { ret = "performers"; + } else if (studioCount != 0) { + ret = "studios"; } } @@ -133,6 +139,7 @@ const TagPage: React.FC = ({ tag, tabKey }) => { galleryCount, sceneMarkerCount, performerCount, + studioCount, movieCount, ]); @@ -521,6 +528,21 @@ const TagPage: React.FC = ({ tag, tabKey }) => { > + + {intl.formatMessage({ id: "studios" })} + + + } + > + + ); diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx new file mode 100644 index 000000000..ef63cdd52 --- /dev/null +++ b/ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import * as GQL from "src/core/generated-graphql"; +import { useTagFilterHook } from "src/core/tags"; +import { StudioList } from "src/components/Studios/StudioList"; + +interface ITagStudiosPanel { + active: boolean; + tag: GQL.TagDataFragment; +} + +export const TagStudiosPanel: React.FC = ({ + active, + tag, +}) => { + const filterHook = useTagFilterHook(tag); + return ; +}; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 61daff120..805eb9b50 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1376,6 +1376,7 @@ "status": "Status: {statusText}", "studio": "Studio", "studio_and_parent": "Studio & Parent", + "studio_count": "Studio Count", "studio_depth": "Levels (empty for all)", "studio_tagger": { "add_new_studios": "Add New Studios", @@ -1415,6 +1416,7 @@ "update_studios": "Update Studios", "updating_untagged_studios_description": "Updating untagged studios will try to match any studios that lack a stashid and update the metadata." }, + "studio_tags": "Studio Tags", "studios": "Studios", "sub_tag_count": "Sub-Tag Count", "sub_tag_of": "Sub-tag of {parent}", diff --git a/ui/v2.5/src/models/list-filter/criteria/tags.ts b/ui/v2.5/src/models/list-filter/criteria/tags.ts index e85392b65..0dd5d54e3 100644 --- a/ui/v2.5/src/models/list-filter/criteria/tags.ts +++ b/ui/v2.5/src/models/list-filter/criteria/tags.ts @@ -55,6 +55,13 @@ export const PerformerTagsCriterionOption = new BaseTagsCriterionOption( withoutEqualsModifierOptions ); +// TODO - this requires using a nested studios_filter which needs to be added separately +// export const StudioTagsCriterionOption = new BaseTagsCriterionOption( +// "studio_tags", +// "studio_tags", +// withoutEqualsModifierOptions +// ); + export const ParentTagsCriterionOption = new BaseTagsCriterionOption( "parent_tags", "parents", diff --git a/ui/v2.5/src/models/list-filter/galleries.ts b/ui/v2.5/src/models/list-filter/galleries.ts index 8c1fc5a76..630267c72 100644 --- a/ui/v2.5/src/models/list-filter/galleries.ts +++ b/ui/v2.5/src/models/list-filter/galleries.ts @@ -14,6 +14,7 @@ import { ScenesCriterionOption } from "./criteria/scenes"; import { StudiosCriterionOption } from "./criteria/studios"; import { PerformerTagsCriterionOption, + // StudioTagsCriterionOption, TagsCriterionOption, } from "./criteria/tags"; import { ListFilterOptions, MediaSortByOptions } from "./filter-options"; @@ -62,6 +63,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("performer_age"), PerformerFavoriteCriterionOption, createMandatoryNumberCriterionOption("image_count"), + // StudioTagsCriterionOption, ScenesCriterionOption, StudiosCriterionOption, createStringCriterionOption("url"), diff --git a/ui/v2.5/src/models/list-filter/images.ts b/ui/v2.5/src/models/list-filter/images.ts index b8696fea4..d8619112d 100644 --- a/ui/v2.5/src/models/list-filter/images.ts +++ b/ui/v2.5/src/models/list-filter/images.ts @@ -16,6 +16,7 @@ import { OrientationCriterionOption } from "./criteria/orientation"; import { StudiosCriterionOption } from "./criteria/studios"; import { PerformerTagsCriterionOption, + // StudioTagsCriterionOption, TagsCriterionOption, } from "./criteria/tags"; import { ListFilterOptions, MediaSortByOptions } from "./filter-options"; @@ -54,6 +55,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("performer_count"), createMandatoryNumberCriterionOption("performer_age"), PerformerFavoriteCriterionOption, + // StudioTagsCriterionOption, StudiosCriterionOption, createStringCriterionOption("url"), createDateCriterionOption("date"), diff --git a/ui/v2.5/src/models/list-filter/movies.ts b/ui/v2.5/src/models/list-filter/movies.ts index 35e4a24e2..7e89d5939 100644 --- a/ui/v2.5/src/models/list-filter/movies.ts +++ b/ui/v2.5/src/models/list-filter/movies.ts @@ -11,6 +11,7 @@ import { PerformersCriterionOption } from "./criteria/performers"; import { ListFilterOptions } from "./filter-options"; import { DisplayMode } from "./types"; import { RatingCriterionOption } from "./criteria/rating"; +// import { StudioTagsCriterionOption } from "./criteria/tags"; import { TagsCriterionOption } from "./criteria/tags"; const defaultSortBy = "name"; @@ -32,6 +33,7 @@ const sortByOptions = [ ]); const displayModeOptions = [DisplayMode.Grid]; const criterionOptions = [ + // StudioTagsCriterionOption, StudiosCriterionOption, MovieIsMissingCriterionOption, createStringCriterionOption("url"), diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index f6210a918..c25ee9766 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -17,6 +17,7 @@ import { StudiosCriterionOption } from "./criteria/studios"; import { InteractiveCriterionOption } from "./criteria/interactive"; import { PerformerTagsCriterionOption, + // StudioTagsCriterionOption, TagsCriterionOption, } from "./criteria/tags"; import { ListFilterOptions, MediaSortByOptions } from "./filter-options"; @@ -99,6 +100,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("performer_count"), createMandatoryNumberCriterionOption("performer_age"), PerformerFavoriteCriterionOption, + // StudioTagsCriterionOption, StudiosCriterionOption, MoviesCriterionOption, GalleriesCriterionOption, diff --git a/ui/v2.5/src/models/list-filter/studios.ts b/ui/v2.5/src/models/list-filter/studios.ts index ff3eeeebd..a25fd9e22 100644 --- a/ui/v2.5/src/models/list-filter/studios.ts +++ b/ui/v2.5/src/models/list-filter/studios.ts @@ -10,11 +10,12 @@ import { StudioIsMissingCriterionOption } from "./criteria/is-missing"; import { RatingCriterionOption } from "./criteria/rating"; import { StashIDCriterionOption } from "./criteria/stash-ids"; import { ParentStudiosCriterionOption } from "./criteria/studios"; +import { TagsCriterionOption } from "./criteria/tags"; import { ListFilterOptions } from "./filter-options"; import { DisplayMode } from "./types"; const defaultSortBy = "name"; -const sortByOptions = ["name", "random", "rating"] +const sortByOptions = ["name", "tag_count", "random", "rating"] .map(ListFilterOptions.createSortBy) .concat([ { @@ -42,8 +43,10 @@ const criterionOptions = [ createStringCriterionOption("details"), ParentStudiosCriterionOption, StudioIsMissingCriterionOption, + TagsCriterionOption, RatingCriterionOption, createBooleanCriterionOption("ignore_auto_tag"), + createMandatoryNumberCriterionOption("tag_count"), createMandatoryNumberCriterionOption("scene_count"), createMandatoryNumberCriterionOption("image_count"), createMandatoryNumberCriterionOption("gallery_count"), diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index 9a9b71680..51df9ed89 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -43,6 +43,10 @@ const sortByOptions = ["name", "random"] messageID: "marker_count", value: "scene_markers_count", }, + { + messageID: "studio_count", + value: "studios_count", + }, ]); const displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; @@ -57,6 +61,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("image_count"), createMandatoryNumberCriterionOption("gallery_count"), createMandatoryNumberCriterionOption("performer_count"), + createMandatoryNumberCriterionOption("studio_count"), createMandatoryNumberCriterionOption("movie_count"), createMandatoryNumberCriterionOption("marker_count"), ParentTagsCriterionOption, diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 9638c7e94..5a63179ad 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -142,6 +142,7 @@ export type CriterionType = | "tags" | "scene_tags" | "performer_tags" + | "studio_tags" | "tag_count" | "performers" | "studios" @@ -172,6 +173,7 @@ export type CriterionType = | "image_count" | "gallery_count" | "performer_count" + | "studio_count" | "movie_count" | "death_year" | "url" diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index e77f40a38..864618fd4 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -263,7 +263,7 @@ const makeChildTagsUrl = (tag: Partial) => { }; function makeTagFilter(mode: GQL.FilterMode, tag: INamedObject) { - const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); + const filter = new ListFilterModel(mode, undefined); const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], @@ -282,6 +282,10 @@ const makeTagPerformersUrl = (tag: INamedObject) => { return `/performers?${makeTagFilter(GQL.FilterMode.Performers, tag)}`; }; +const makeTagStudiosUrl = (tag: INamedObject) => { + return `/studios?${makeTagFilter(GQL.FilterMode.Studios, tag)}`; +}; + const makeTagSceneMarkersUrl = (tag: INamedObject) => { return `/scenes/markers?${makeTagFilter(GQL.FilterMode.SceneMarkers, tag)}`; }; @@ -410,6 +414,7 @@ const NavUtils = { makeTagSceneMarkersUrl, makeTagScenesUrl, makeTagPerformersUrl, + makeTagStudiosUrl, makeTagGalleriesUrl, makeTagImagesUrl, makeTagMoviesUrl, From 9c13b39f99434bc39d21ddb9b1a4842b1b9b5f1d Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 19 Jun 2024 19:52:33 +1000 Subject: [PATCH 05/28] Fix identify clearing parent studio when merging (#4993) * Refactor ScrapedStudio.ToPartial signature * Add unit test * Don't clear parent studio during ToPartial --- internal/identify/studio.go | 6 +- internal/manager/task_stash_box_tag.go | 12 +-- pkg/models/model_scraped_item.go | 8 +- pkg/models/model_scraped_item_test.go | 120 +++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 14 deletions(-) diff --git a/internal/identify/studio.go b/internal/identify/studio.go index d05967bc4..51bcaf2ee 100644 --- a/internal/identify/studio.go +++ b/internal/identify/studio.go @@ -46,17 +46,17 @@ func createMissingStudio(ctx context.Context, endpoint string, w models.StudioRe return nil, err } - studioPartial := s.Parent.ToPartial(s.Parent.StoredID, endpoint, nil, existingStashIDs) + studioPartial := s.Parent.ToPartial(*s.Parent.StoredID, endpoint, nil, existingStashIDs) parentImage, err := s.Parent.GetImage(ctx, nil) if err != nil { return nil, err } - if err := studio.ValidateModify(ctx, *studioPartial, w); err != nil { + if err := studio.ValidateModify(ctx, studioPartial, w); err != nil { return nil, err } - _, err = w.UpdatePartial(ctx, *studioPartial) + _, err = w.UpdatePartial(ctx, studioPartial) if err != nil { return nil, err } diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index 298b58e27..8bb399601 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -311,13 +311,13 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode return err } - partial := s.ToPartial(s.StoredID, t.box.Endpoint, excluded, existingStashIDs) + partial := s.ToPartial(*s.StoredID, t.box.Endpoint, excluded, existingStashIDs) - if err := studio.ValidateModify(ctx, *partial, qb); err != nil { + if err := studio.ValidateModify(ctx, partial, qb); err != nil { return err } - if _, err := qb.UpdatePartial(ctx, *partial); err != nil { + if _, err := qb.UpdatePartial(ctx, partial); err != nil { return err } @@ -435,13 +435,13 @@ func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent * return err } - partial := parent.ToPartial(parent.StoredID, t.box.Endpoint, excluded, existingStashIDs) + partial := parent.ToPartial(*parent.StoredID, t.box.Endpoint, excluded, existingStashIDs) - if err := studio.ValidateModify(ctx, *partial, qb); err != nil { + if err := studio.ValidateModify(ctx, partial, qb); err != nil { return err } - if _, err := qb.UpdatePartial(ctx, *partial); err != nil { + if _, err := qb.UpdatePartial(ctx, partial); err != nil { return err } diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 206f1109b..84c69d7e4 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -62,9 +62,9 @@ func (s *ScrapedStudio) GetImage(ctx context.Context, excluded map[string]bool) return nil, nil } -func (s *ScrapedStudio) ToPartial(id *string, endpoint string, excluded map[string]bool, existingStashIDs []StashID) *StudioPartial { +func (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[string]bool, existingStashIDs []StashID) StudioPartial { ret := NewStudioPartial() - ret.ID, _ = strconv.Atoi(*id) + ret.ID, _ = strconv.Atoi(id) if s.Name != "" && !excluded["name"] { ret.Name = NewOptionalString(s.Name) @@ -82,8 +82,6 @@ func (s *ScrapedStudio) ToPartial(id *string, endpoint string, excluded map[stri ret.ParentID = NewOptionalInt(parentID) } } - } else { - ret.ParentID = NewOptionalIntPtr(nil) } if s.RemoteSiteID != nil && endpoint != "" { @@ -97,7 +95,7 @@ func (s *ScrapedStudio) ToPartial(id *string, endpoint string, excluded map[stri }) } - return &ret + return ret } // A performer from a scraping operation... diff --git a/pkg/models/model_scraped_item_test.go b/pkg/models/model_scraped_item_test.go index 50657188d..87ce2ad57 100644 --- a/pkg/models/model_scraped_item_test.go +++ b/pkg/models/model_scraped_item_test.go @@ -247,3 +247,123 @@ func Test_scrapedToPerformerInput(t *testing.T) { }) } } + +func TestScrapedStudio_ToPartial(t *testing.T) { + var ( + id = 1000 + idStr = strconv.Itoa(id) + storedID = "storedID" + parentStoredID = 2000 + parentStoredIDStr = strconv.Itoa(parentStoredID) + name = "name" + url = "url" + remoteSiteID = "remoteSiteID" + endpoint = "endpoint" + image = "image" + images = []string{image} + + existingEndpoint = "existingEndpoint" + existingStashID = StashID{"existingStashID", existingEndpoint} + existingStashIDs = []StashID{existingStashID} + ) + + fullStudio := ScrapedStudio{ + StoredID: &storedID, + Name: name, + URL: &url, + Parent: &ScrapedStudio{ + StoredID: &parentStoredIDStr, + }, + Image: &image, + Images: images, + RemoteSiteID: &remoteSiteID, + } + + type args struct { + id string + endpoint string + excluded map[string]bool + existingStashIDs []StashID + } + + stdArgs := args{ + id: idStr, + endpoint: endpoint, + excluded: map[string]bool{}, + existingStashIDs: existingStashIDs, + } + + excludeAll := map[string]bool{ + "name": true, + "url": true, + "parent": true, + } + + tests := []struct { + name string + o ScrapedStudio + args args + want StudioPartial + }{ + { + "full no exclusions", + fullStudio, + stdArgs, + StudioPartial{ + ID: id, + Name: NewOptionalString(name), + URL: NewOptionalString(url), + ParentID: NewOptionalInt(parentStoredID), + StashIDs: &UpdateStashIDs{ + StashIDs: append(existingStashIDs, StashID{ + Endpoint: endpoint, + StashID: remoteSiteID, + }), + Mode: RelationshipUpdateModeSet, + }, + }, + }, + { + "exclude all", + fullStudio, + args{ + id: idStr, + excluded: excludeAll, + }, + StudioPartial{ + ID: id, + }, + }, + { + "overwrite stash id", + fullStudio, + args{ + id: idStr, + excluded: excludeAll, + endpoint: existingEndpoint, + existingStashIDs: existingStashIDs, + }, + StudioPartial{ + ID: id, + StashIDs: &UpdateStashIDs{ + StashIDs: []StashID{{ + Endpoint: existingEndpoint, + StashID: remoteSiteID, + }}, + Mode: RelationshipUpdateModeSet, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := tt.o + got := s.ToPartial(tt.args.id, tt.args.endpoint, tt.args.excluded, tt.args.existingStashIDs) + + // unset updatedAt - we don't need to compare it + got.UpdatedAt = OptionalTime{} + + assert.Equal(t, tt.want, got) + }) + } +} From a1fc14f8c4361433b3025479321949d7826bc721 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 19 Jun 2024 20:00:30 +1000 Subject: [PATCH 06/28] Fix join function for studio scenes_filter handler (#4994) --- pkg/sqlite/studio_filter.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/sqlite/studio_filter.go b/pkg/sqlite/studio_filter.go index 040fc1858..c514364c4 100644 --- a/pkg/sqlite/studio_filter.go +++ b/pkg/sqlite/studio_filter.go @@ -90,7 +90,7 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler { relatedRepo: sceneRepository.repository, relatedHandler: &sceneFilterHandler{studioFilter.ScenesFilter}, joinFn: func(f *filterBuilder) { - sceneRepository.innerJoin(f, "", "studios.id") + studioRepository.scenes.innerJoin(f, "", "studios.id") }, }, From a7e5ccd08004c4a8a86f2ecc8a886439d2dabd9a Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Wed, 19 Jun 2024 22:07:09 +0200 Subject: [PATCH 07/28] Translations update from Hosted Weblate (#4930) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Thai) Currently translated at 77.1% (887 of 1149 strings) Translation: stashapp/stash Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/th/ * Translated using Weblate (Korean) Currently translated at 100.0% (1149 of 1149 strings) Translation: stashapp/stash Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/ko/ * Translated using Weblate (Thai) Currently translated at 85.6% (984 of 1149 strings) Translation: stashapp/stash Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/th/ * Translated using Weblate (Thai) Currently translated at 99.0% (1138 of 1149 strings) Translation: stashapp/stash Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/th/ * Translated using Weblate (Russian) Currently translated at 99.9% (1148 of 1149 strings) Translation: stashapp/stash Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/ru/ * Translated using Weblate (Czech) Currently translated at 100.0% (1149 of 1149 strings) Translation: stashapp/stash Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/cs/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1152 of 1152 strings) Translation: stashapp/stash Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1153 of 1153 strings) Translation: stashapp/stash Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1155 of 1155 strings) Translation: stashapp/stash Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/ --------- Co-authored-by: PZKL48 Co-authored-by: 이예찬 Co-authored-by: Alexusfree (alexusfree) Co-authored-by: Nymeria Co-authored-by: wql219 <160428035+wql219@users.noreply.github.com> Co-authored-by: Hansi --- ui/v2.5/src/locales/cs-CZ.json | 8 +- ui/v2.5/src/locales/ko-KR.json | 64 ++- ui/v2.5/src/locales/ru-RU.json | 4 +- ui/v2.5/src/locales/th-TH.json | 882 +++++++++++++++++++++++++++++++-- ui/v2.5/src/locales/zh-CN.json | 12 +- 5 files changed, 898 insertions(+), 72 deletions(-) diff --git a/ui/v2.5/src/locales/cs-CZ.json b/ui/v2.5/src/locales/cs-CZ.json index 38678f0c9..76571cf17 100644 --- a/ui/v2.5/src/locales/cs-CZ.json +++ b/ui/v2.5/src/locales/cs-CZ.json @@ -247,7 +247,9 @@ "successfully_cancelled_temporary_behaviour": "Úspěšně zrušeno dočasné chování", "until_restart": "pouze do restartu", "video_sort_order": "Výchozí řazení videa", - "video_sort_order_desc": "Nastav řazení videí na výchozí." + "video_sort_order_desc": "Nastav řazení videí na výchozí.", + "server_port": "Port serveru", + "server_port_desc": "Port, na kterém poběží DLNA server. Po změně, vyžaduje DLNA restart." }, "general": { "auth": { @@ -701,7 +703,7 @@ "heading": "(Výchozí nastavení) Pokračovat v playlistu" }, "show_scrubber": "Zobrazit Scrubber", - "track_activity": "Sledování činností", + "track_activity": "Povolit historii přehrávání scén", "disable_mobile_media_auto_rotate": "Zakázat automatické otáčení médií na celou obrazovku v mobilu", "enable_chromecast": "Povolit Chromecast", "show_ab_loop_controls": "Zobrazit ovládací prvky pluginu AB Loop", @@ -770,7 +772,7 @@ "heading": "Zobrazení tagů" }, "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.", + "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" } }, diff --git a/ui/v2.5/src/locales/ko-KR.json b/ui/v2.5/src/locales/ko-KR.json index 05e5df37d..70ba5295b 100644 --- a/ui/v2.5/src/locales/ko-KR.json +++ b/ui/v2.5/src/locales/ko-KR.json @@ -57,9 +57,9 @@ "import_from_file": "파일 불러오기", "logout": "로그아웃", "make_primary": "첫 번째로 만들기", - "merge": "합치기", - "merge_from": "...에서 합치기", - "merge_into": "...로 합치기", + "merge": "병합", + "merge_from": "...에서 병합", + "merge_into": "...로 병합", "migrate_blobs": "Blob 마이그레이션", "migrate_scene_screenshots": "영상 스크린샷 마이그레이션", "next_action": "다음", @@ -134,7 +134,8 @@ "clear_date_data": "날짜 데이터 삭제", "copy_to_clipboard": "클립보드에 복사", "reload": "새로고침", - "remove_date": "날짜 삭제" + "remove_date": "날짜 삭제", + "view_history": "기록 보기" }, "actions_name": "액션", "age": "나이", @@ -178,7 +179,7 @@ "query_mode_path_desc": "전체 파일 경로 사용", "set_cover_desc": "영상 커버가 있다면 그 이미지로 교체합니다.", "set_cover_label": "영상 커버 이미지 설정", - "set_tag_desc": "영상에 이미 존재하는 태그들을 덮어쓰기/합치기 함으로써 태그를 영상에 추가합니다.", + "set_tag_desc": "영상에 이미 존재하는 태그들을 덮어쓰거나 병합함으로써 태그를 영상에 추가합니다.", "set_tag_label": "태그 설정", "show_male_desc": "남성 배우들의 태그 가능 여부 설정을 켜거나 끕니다.", "show_male_label": "남성 배우 보여주기", @@ -257,7 +258,9 @@ "successfully_cancelled_temporary_behaviour": "임시 설정을 취소하는 데에 성공했습니다", "until_restart": "재시작 전까지", "video_sort_order": "기본 비디오 정렬 순서", - "video_sort_order_desc": "비디오를 정렬할 기본값 순서입니다." + "video_sort_order_desc": "비디오를 정렬할 기본값 순서입니다.", + "server_port": "서버 포트", + "server_port_desc": "DLNA 서버를 동작시킬 포트입니다. 변경 이후 DLNA 재시작이 필요합니다." }, "general": { "auth": { @@ -493,7 +496,15 @@ "source": "소스", "source_options": "{source} 옵션", "sources": "소스", - "strategy": "방법" + "strategy": "방법", + "skip_single_name_performers_tooltip": "만약 이 옵션이 설정되지 않은 경우, 'Samantha' 혹은 'Olga'와 같은 흔한 이름들이 매칭될 것입니다", + "tag_skipped_performer_tooltip": "이 옵션에 해당하는 배우들에 대해, 나중에 영상 태거 뷰에서 배우 정보를 원하는 대로 다룰 수 있도록, '식별: 한 단어 이름 배우' 등과 같은 태그를 만듭니다", + "skip_multiple_matches_tooltip": "만약 이 옵션이 설정되지 않은 상태에서 여러 개의 결과가 도출된 경우, 여러 개의 결과 중 무작위로 하나가 선택될 것입니다", + "skip_single_name_performers": "다른 배우의 이름과 겹치지 않으면서도 한 단어의 이름으로 이뤄진 배우의 경우, 처리하지 않고 건너뛰기", + "skip_multiple_matches": "여러 개의 매칭 결과가 나왔을 때, 처리하지 않고 건너뛰기", + "tag_skipped_matches": "처리하지 않고 건너뛴 항목들에 대해 다음과 같이 태그하기", + "tag_skipped_matches_tooltip": "다수 식별 결과가 도출된 항목들을 대상으로, 실제로 일치하는 식별 결과를 영상 태거 뷰에서 직접 고를 수 있도록, '식별: 다수 매칭' 등과 같은 태그를 만듭니다", + "tag_skipped_performers": "처리하지 않고 건너뛴 배우들에 대해 다음과 같이 태그하기" }, "import_from_exported_json": "메타데이터 폴더에서 내보낸 JSON 파일에서 가져오기 작업을 합니다. 기존 데이터베이스를 지웁니다.", "incremental_import": "내보낸 zip 파일에서 증가한 부분만 가져옵니다.", @@ -708,7 +719,7 @@ "heading": "플레이리스트 이어보기" }, "show_scrubber": "스크러버 보이기", - "track_activity": "활동 트래킹", + "track_activity": "영상 재생 기록 활성화", "vr_tag": { "description": "VR 버튼은 이 태그를 가진 영상에서만 보여질 것입니다.", "heading": "VR 태그" @@ -758,7 +769,7 @@ "title": "UI", "use_stash_hosted_funscript": { "heading": "funscript 직접 전달", - "description": "활성화되면, 서드 파티 Handy 서버를 사용하지 않고 Stash로부터 Handy 디바이스로 곧바로 funscript가 전달될 것입니다. Stash가 Handy 디바이스에 접근 가능한 상태여야 합니다." + "description": "활성화되면, 서드 파티 Handy 서버를 사용하지 않고 Stash로부터 Handy 디바이스로 곧바로 funscript가 전달될 것입니다. Stash가 Handy 디바이스에 접근 가능한 상태여야 하고, Stash에서 인증이 설정된 상태라면 API 키가 생성되어 있어야 합니다." }, "detail": { "enable_background_image": { @@ -812,7 +823,10 @@ "not_between": "구간 밖", "not_equals": "≠", "not_matches_regex": "정규표현식 불일치", - "not_null": "값 존재함" + "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}}})" }, "custom": "커스텀", "date": "날짜", @@ -925,7 +939,15 @@ "unsaved_changes": "저장되지 않은 변경 사항들이 있습니다. 그래도 나가겠습니까?", "performers_found": "{count} 명의 배우들을 찾았습니다", "clear_o_history_confirm": "정말 싸버린 기록을 삭제하시겠습니까?", - "clear_play_history_confirm": "정말 재생 기록을 삭제하시겠습니까?" + "clear_play_history_confirm": "정말 재생 기록을 삭제하시겠습니까?", + "merge": { + "destination": "~으로 병합 (병합 결과)", + "source": "~을 (병합 대상)", + "empty_results": "병합 결과 값이 바뀌지 않을 것입니다." + }, + "reassign_files": { + "destination": "~으로 재지정" + } }, "dimensions": "해상도", "director": "감독", @@ -1057,7 +1079,7 @@ "interactive": "인터렉티브", "interactive_speed": "인터랙티브 속도", "isMissing": "데이터 누락됨", - "last_played_at": "마지막으로 재생", + "last_played_at": "마지막 재생 날짜", "library": "라이브러리", "loading": { "generic": "로드 중…" @@ -1192,7 +1214,8 @@ "database_file_path": "데이터베이스 파일 경로", "generated_directory": "생성된 컨텐츠 폴더", "nearly_there": "거의 끝났습니다!", - "stash_library_directories": "Stash 라이브러리 폴더" + "stash_library_directories": "Stash 라이브러리 폴더", + "blobs_use_database": "<데이터베이스 사용 중>" }, "creating": { "creating_your_system": "시스템 생성 중" @@ -1382,7 +1405,8 @@ "to_use_the_studio_tagger": "스튜디오 태거를 사용하려면, stash-box 인스턴스가 설정되어야 합니다.", "update_studios": "스튜디오 업데이트", "untagged_studios": "태그되지 않은 스튜디오", - "update_studio": "스튜디오 업데이트" + "update_studio": "스튜디오 업데이트", + "studio_selection": "스튜디오 선택" }, "audio_codec": "오디오 코덱", "connection_monitor": { @@ -1422,10 +1446,12 @@ "no_sources": "패키지 소스 없음", "version": "버전", "show_all": "모두 보여주기", - "update": "업데이트" + "update": "업데이트", + "selected_only": "선택된 것만", + "required_by": "{packages}로 인해 요구됨" }, "o_count": "싼 횟수", - "orientation": "", + "orientation": "방향", "parent_studio": "부모 스튜디오", "subsidiary_studio_count": "자회사 스튜디오 개수", "time": "시간", @@ -1435,5 +1461,9 @@ "play_history": "재생 기록", "primary_tag": "주 태그", "unknown_date": "날짜 미상", - "urls": "URL" + "urls": "URL", + "distance": "거리", + "studio_and_parent": "스튜디오 & 모회사", + "tag_parent_tooltip": "상위 태그 존재 여부", + "tag_sub_tag_tooltip": "하위 태그 존재 여부" } diff --git a/ui/v2.5/src/locales/ru-RU.json b/ui/v2.5/src/locales/ru-RU.json index 7696b1f6d..6df3cfec1 100644 --- a/ui/v2.5/src/locales/ru-RU.json +++ b/ui/v2.5/src/locales/ru-RU.json @@ -252,7 +252,9 @@ "successfully_cancelled_temporary_behaviour": "Временно запущенный сервис DLNA, успешно отключен", "until_restart": "до перезагрузки", "video_sort_order": "Порядок сортировки видео по умолчанию", - "video_sort_order_desc": "Порядок сортировки видео по умолчанию." + "video_sort_order_desc": "Порядок сортировки видео по умолчанию.", + "server_port_desc": "Порт DLNA-сервера. После изменения требуется перезапуск DLNA.", + "server_port": "Порт DLNA" }, "general": { "auth": { diff --git a/ui/v2.5/src/locales/th-TH.json b/ui/v2.5/src/locales/th-TH.json index 84e2915cb..1716dd437 100644 --- a/ui/v2.5/src/locales/th-TH.json +++ b/ui/v2.5/src/locales/th-TH.json @@ -1,7 +1,7 @@ { "actions": { "add": "เพิ่ม", - "add_directory": "เพิ่มโฟลเดอร์", + "add_directory": "เพิ่มไดเร็กทอรี", "add_entity": "เพิ่ม{entityType}", "add_to_entity": "เพิ่มไปยัง{entityType}", "allow": "อนุญาต", @@ -13,15 +13,15 @@ "cancel": "ยกเลิก", "clean": "เก็บกวาด", "clear": "ล้างค่า", - "clear_back_image": "ล้างค่ารูปภาพเบื้องหลัง", - "clear_front_image": "ล้างค่ารูปภาพด้านหน้า", - "clear_image": "ล้างค่ารูป", + "clear_back_image": "ล้างค่าภาพปกหลัง", + "clear_front_image": "ล้างค่าภาพปกหน้า", + "clear_image": "ล้างค่ารูปภาพ", "close": "ปิด", "confirm": "ยืนยัน", "continue": "ถัดไป", "create": "สร้าง", "create_entity": "สร้าง{entityType}", - "create_marker": "สร้างจุดมาร์ค", + "create_marker": "สร้างมาร์คเกอร์", "created_entity": "สร้าง{entity_type}: {entity_name}", "customise": "ปรับแต่ง", "delete": "ลบ", @@ -114,7 +114,7 @@ "download_anonymised": "ดาวน์โหลดข้อมูลที่ปิดบังตัวตนแล้ว", "optimise_database": "ปรับแต่งฐานข้อมูลให้ดีขึ้น", "assign_stashid_to_parent_studio": "ระบุ Stash ID ให้กับสตูดิโอบริษัทแม่พร้อมกับอัปเดต metadata", - "create_chapters": "สร้างแชปเตอร์", + "create_chapters": "สร้างฉาก", "swap": "สลับ", "reassign": "ตั้งค่าใหม่", "reload": "รีโหลด", @@ -122,11 +122,11 @@ "view_history": "ประวัติการดู", "add_manual_date": "เพิ่มวันที่", "add_o": "เพิ่ม O", - "add_play": "เพิ่มจำนวนการเล่น", + "add_play": "เพิ่มจำนวนครั้งที่เล่น", "anonymise": "ปิดบังตัวตน", "choose_date": "เลือกวันที่", "clean_generated": "ลบไฟล์ที่ถูกสร้าง", - "clear_date_data": "ล้างข้อมูลวันที่", + "clear_date_data": "ล้างค่าข้อมูลวันที่", "copy_to_clipboard": "ทำสำเนา", "create_parent_studio": "สร้างสตูดิโอบริษัทแม่", "disable": "ปิดใช้งาน", @@ -148,10 +148,10 @@ "birthdate": "วันเกิด", "bitrate": "บิตเรท", "captions": "แคปชัน", - "career_length": "ระยะเวลาในอาชีพ", + "career_length": "ระยะเวลาในวงการ", "component_tagger": { "config": { - "active_instance": "instant Stash-box ที่ใช้งานอยู่:", + "active_instance": "Stash-box ที่ใช้งานอยู่:", "blacklist_desc": "รายการบัญชีดำจะไม่รวมอยู่ในการสืบค้น โปรดทราบว่าเป็นนิพจน์ทั่วไปและไม่คำนึงถึงขนาดตัวพิมพ์ อักขระบางตัวต้องหลีกหนีด้วยแบ็กสแลช: {chars_require_escape}", "blacklist_label": "บัญชีดำ", "query_mode_auto": "ออโต้", @@ -179,17 +179,17 @@ "results": { "duration_off": "ระยะเวลาปิดอย่างน้อย {number}s", "duration_unknown": "ระยะเวลาไม่ทราบ", - "fp_found": "{fpCount, plural, =0 {ไม่พบลายนิ้วมือใหม่ที่ตรงกัน} other {# พบลายนิ้วมือใหม่ที่ตรงกัน}}", + "fp_found": "{fpCount, plural, =0 {ไม่พบข้อมูลอัตลักษณ์ที่ตรงกัน} other {# พบข้อมูลอัตลักษณ์ที่ตรงกัน}}", "fp_matches": "ระยะเวลาตรงกัน", - "fp_matches_multi": "ระยะเวลาตรงกัน {matchCount}/{durationsLength} fingerprint(s)", + "fp_matches_multi": "ระยะเวลาตรงกัน {matchCount}/{durationsLength} ชุดข้อมูล", "hash_matches": "{hash_type} ตรงกัน", - "match_failed_already_tagged": "ฉากถูกแท็กแล้ว", + "match_failed_already_tagged": "มีข้อมูลซีนนี้แล้ว", "match_failed_no_result": "ไม่พบผลลัพธ์", - "match_success": "ฉากนี้ถูกแท็กเรียบร้อยแล้ว", + "match_success": "เพิ่มข้อมูลซีนสำเร็จแล้ว", "phash_matches": "{count} PHashes ตรงกัน", "unnamed": "ไม่มีชื่อ" }, - "verb_match_fp": "ลายนิ้วมือตรงกัน", + "verb_match_fp": "ข้อมูลอัตลักษณ์ตรงกัน", "verb_matched": "ตรงกัน", "verb_scrape_all": "สแครปทั้งหมด", "verb_submit_fp": "ยืนยัน {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}", @@ -226,7 +226,7 @@ "system": "ระบบ", "tasks": "งาน", "tools": "เครื่องมือ", - "changelog": "Changelog" + "changelog": "บันทึกความเปลี่ยนแปลง" }, "dlna": { "allow_temp_ip": "อนุญาต {tempIP}", @@ -245,8 +245,8 @@ "server_display_name_desc": "ชื่อที่แสดงสำหรับเซิร์ฟเวอร์ DLNA ค่าเริ่มต้นเป็น {server_name} เป็นค่าว่าง", "successfully_cancelled_temporary_behaviour": "ยกเลิกพฤติกรรมชั่วคราวเรียบร้อยแล้ว", "until_restart": "จนกว่าจะรีสตาร์ท", - "video_sort_order_desc": "เลือกชนิดลำดับที่ต้องการเรียงลำดับวีดีโอเป็นค่าปริยาย", - "video_sort_order": "ค่าปริยายการเรียงลำดับวีดีโอ", + "video_sort_order_desc": "เลือกชนิดลำดับที่ต้องการเรียงลำดับวิดีโอเป็นค่าปริยาย", + "video_sort_order": "ค่าปริยายการเรียงลำดับวิดีโอ", "server_port": "พอร์ตเซิร์ฟเวอร์", "server_port_desc": "พอร์ตที่จะเรียกใช้เซิร์ฟเวอร์ DLNA ต้องรีสตาร์ท DLNA เมื่อเปลี่ยนแปลงค่า" }, @@ -326,7 +326,7 @@ "heading": "พาร์ธ Scrapers" }, "scraping": "การ Scrap", - "sqlite_location": "ตำแหน่งไฟล์สำหรับฐานข้อมูล SQLite (ต้องรีสตาร์ท)\nคำเตือน: ไม่รองรับการจัดเก็บฐานข้อมูลในที่อื่นๆ ที่ไม่ใช่เครื่องเซิร์ฟเวอร์ Stash (เช่นในไดรฟ์เครือข่าย)", + "sqlite_location": "ตำแหน่งไฟล์สำหรับฐานข้อมูล SQLite (ต้องรีสตาร์ท)
    คำเตือน: ไม่รองรับการจัดเก็บฐานข้อมูลในที่อื่นๆ ที่ไม่ใช่เครื่องเซิร์ฟเวอร์ Stash (เช่นในไดรฟ์เครือข่าย)!", "video_ext_desc": "รายการนามสกุลไฟล์ซึ่งจะถูกระบุว่าเป็นวิดีโอ คั่นด้วยเครื่องหมายจุลภาค", "video_ext_head": "นามสกุลไฟล์วิดีโอ", "video_head": "วิดีโอ", @@ -345,12 +345,12 @@ }, "transcode": { "input_args": { - "heading": "การตั้งค่า FFmpeg เพิ่มเติม สำหรับก่อนการสร้างวีดีโอ", - "desc": "ขั้นสูง: ระบุการตั้งค่า FFmpeg เพิ่มเติม สำหรับก่อนการสร้างวีดีโอ" + "heading": "การตั้งค่า FFmpeg เพิ่มเติม สำหรับก่อนการสร้างวิดีโอ", + "desc": "ขั้นสูง: ระบุการตั้งค่า FFmpeg เพิ่มเติม สำหรับก่อนการสร้างวิดีโอ" }, "output_args": { - "desc": "ขั้นสูง: ระบุการตั้งค่า FFmpeg เพิ่มเติม สำหรับหลังการสร้างวีดีโอ", - "heading": "การตั้งค่า FFmpeg เพิ่มเติม สำหรับหลังการสร้างวีดีโอ" + "desc": "ขั้นสูง: ระบุการตั้งค่า FFmpeg เพิ่มเติม สำหรับหลังการสร้างวิดีโอ", + "heading": "การตั้งค่า FFmpeg เพิ่มเติม สำหรับหลังการสร้างวิดีโอ" } }, "download_ffmpeg": { @@ -372,8 +372,8 @@ } }, "hardware_acceleration": { - "desc": "ใช้ฮาร์ดแวร์เข้ารหัสวีดีโอสำหรับการ live transcode", - "heading": "ใช้ฮาร์ดแวร์เข้ารหัสวีดีโอสำหรับ FFmpeg" + "desc": "ใช้ฮาร์ดแวร์เข้ารหัสวิดีโอสำหรับการ live transcode", + "heading": "ใช้ฮาร์ดแวร์เข้ารหัสวิดีโอสำหรับ FFmpeg" } }, "funscript_heatmap_draw_range_desc": "ระบุค่าสูงต่ำ (ตามแกน y) ในการสร้าง heatmaps เมื่อเปลี่ยนแปลงค่านี้ต้องทำการสร้าง heatmaps ขึ้นใหม่", @@ -448,12 +448,12 @@ "empty_queue": "ไม่มีงานใดๆ ในคิว", "export_to_json": "ส่งออกฐานข้อมูลในรูปแบบ JSON ในไดเร็กทอรี metadata", "generate_thumbnails_during_scan": "สร้างภาพขนาดเล็กสำหรับไฟล์ภาพ", - "generate_previews_during_scan_tooltip": "สร้างภาพเคลื่อนไหวตัวอย่างเพิ่มเติม (webp) ต้องเปิดใช้งานเมื่อตั้งค่า Scene/Marker Wall Preview Type เป็นภาพเคลื่อนไหวเท่านั้น มีข้อดีคือใช้งาน CPU น้อยกว่าวีดีโอตัวอย่าง แต่มีข้อเสียคือต้องสร้างไฟล์เพิ่มเติมและมีขนาดใหญ่กว่า", + "generate_previews_during_scan_tooltip": "สร้างภาพเคลื่อนไหวตัวอย่างเพิ่มเติม (webp) ต้องเปิดใช้งานเมื่อตั้งค่า Scene/Marker Wall Preview Type เป็นภาพเคลื่อนไหวเท่านั้น มีข้อดีคือใช้งาน CPU น้อยกว่าวิดีโอตัวอย่าง แต่มีข้อเสียคือต้องสร้างไฟล์เพิ่มเติมและมีขนาดใหญ่กว่า", "anonymise_database": "ทำสำเนาฐานข้อมูลแบบปิดบังตัวตนและบันทึกไว้ในไดเร็กทอรีสำรองข้อมูล สามารถแจกจ่ายไฟล์ฐานข้อมูลแก้ผู้อื่นเพื่อใช้ในการแก้ไขปัญหาต่างๆ และง่ายต่อการ debug และไม่กระทบฐานข้อมูลหลัก โดยมีรูปแบบชื่อไฟล์ {filename_format}", "clean_generated": { "markers": "ภาพตัวอย่างมาร์คเกอร์", "previews_desc": "ภาพตัวอย่าง scene และภาพขนาดเล็ก", - "transcodes": "ไฟล์วีดีโอพร้อมใช้ของ scene", + "transcodes": "ไฟล์วิดีโอพร้อมใช้ของ scene", "blob_files": "ไฟล์ blob", "description": "ลบไฟล์ที่ถูกสร้างขึ้นที่ไม่มีอยู่ในฐานข้อมูล", "previews": "ภาพตัวอย่าง scene", @@ -465,7 +465,7 @@ "generating_from_paths": "กำลังสร้างไฟล์สำหรับ scene ตามตำแหน่งไฟล์ดังนี้", "generating_scenes": "กำลังสร้างไฟล์สำหรับ {num} {scene}" }, - "generate_desc": "กำลังสร้างภาพอื่นๆ sprite วีดีโอ vtt และไฟล์อื่นๆ", + "generate_desc": "กำลังสร้างภาพอื่นๆ sprite วิดีโอ vtt และไฟล์อื่นๆ", "generate_phashes_during_scan": "กำลังสร้าง perceptual hashes", "generate_previews_during_scan": "กำลังสร้างภาพเคลื่อนไหวตัวอย่าง", "identify": { @@ -475,29 +475,29 @@ "heading": "ค้นหาและระบุข้อมูล", "set_cover_images": "ตั้งค่าภาพปก", "set_organized": "ตั้งค่าเป็น organized", - "skip_single_name_performers_tooltip": "เมื่อไม่เปิดใช้งานตัวเลือกนี้ นักแสดงที่มีชื่อทั่วไปเช่น Samantha หรือ Olga จะถูกเลือกเป็นผลลัพธ์", + "skip_single_name_performers_tooltip": "เมื่อปิดใช้งานตัวเลือกนี้ นักแสดงที่มีชื่อทั่วไปเช่น Samantha หรือ Olga จะถูกเลือกเป็นผลลัพธ์", "sources": "แหล่งข้อมูล", "identifying_from_paths": "ระบุ scene จากตำแหน่งต่อไปนี้", - "skip_multiple_matches_tooltip": "เมื่อไม่เปิดใช้งานตัวเลือกนี้ ผลการจับคู่จะเป็นแบบสุ่มหากมีผลลัพธ์มากกว่าหนึ่ง", + "skip_multiple_matches_tooltip": "เมื่อปิดใช้งานตัวเลือกนี้ ผลการเปรียบเทียบข้อมูลจะเป็นแบบสุ่มหากมีผลลัพธ์มากกว่าหนึ่ง", "tag_skipped_matches_tooltip": "สร้างแท็กพิเศษ (เช่น 'Identify: Multiple Matches') เพื่อง่ายในการค้นหาและตั้งแท็กที่เหมาะสมด้วยตนเอง", "description": "ตั้งค่า scene metadata อัตโนมัติโดยใช้แหล่งข้อมูลจาก stash-box และ scraper", "and_create_missing": "และสร้างเนื้อหาที่ไม่มี", "create_missing": "สร้างขึ้นใหม่หากไม่มี", "identifying_scenes": "กำลังระบุ {num} {scene}", "include_male_performers": "ระบุนักแสดงชายด้วย", - "skip_multiple_matches": "ข้ามการจับคู่ข้อมูลหากมีผลลัพธ์มากกว่าหนึ่ง", + "skip_multiple_matches": "ข้ามการเปรียบเทียบข้อมูลหากมีผลลัพธ์มากกว่าหนึ่ง", "skip_single_name_performers": "ข้ามนักแสดงที่มีเพียงชื่อเดียวและไม่มีการระบุตัวตน", "source": "แหล่งข้อมูล", "source_options": "ตัวเลือก {source}", "strategy": "วิธีการ", - "tag_skipped_matches": "แท็กการข้ามการจับคู่ด้วยแท็ก", + "tag_skipped_matches": "แท็กไฟล์ที่ถูกข้ามการเปรียบเทียบข้อมูลด้วยแท็กพิเศษ", "tag_skipped_performers": "แท็กการข้ามการจับคู่นักแสดงด้วยแท็ก", - "field": "Field", + "field": "ประเภทข้อมูล", "field_behaviour": "{strategy} {field}", "tag_skipped_performer_tooltip": "สร้างแท็กพิเศษ (เช่น 'Identify: Single Name Performer') เพื่อง่ายในการค้นหาและเลือกชื่อที่เหมาะสมด้วยตนเอง" }, - "generate_sprites_during_scan_tooltip": "ชุดภาพนิ่งใต้ตัวเล่นวีดีโอ ช่วยให้การกรอวีดีโอง่ายขึ้น", - "generate_video_previews_during_scan": "สร้างวีดีโอตัวอย่าง", + "generate_sprites_during_scan_tooltip": "ชุดภาพนิ่งใต้ตัวเล่นวิดีโอ ช่วยให้การกรอวิดีโอง่ายขึ้น", + "generate_video_previews_during_scan": "สร้างวิดีโอตัวอย่าง", "scan_for_content_desc": "สแกนเนื้อหาใหม่เพื่อเพิ่มเข้าสู่ฐานข้อมูล", "incremental_import": "นำเข้าข้อมูลบางส่วนจากไฟล์ zip ที่เลือก", "migrate_hash_files": "ใช้เครื่องมือนี้หลังจากเปลี่ยนค่ารูปแบบชื่อไฟล์ hash เพื่อให้ตรงกับรูปแบบใหม่", @@ -511,7 +511,7 @@ "set_name_date_details_from_metadata_if_present": "ตั้งค่าชื่อ วันที่ และรายละเอียดจากข้อมูล metadata ที่แนบมาด้วย", "generate_sprites_during_scan": "สร้าง scrubber sprites", "anonymising_database": "กำลังปิดบังตัวตนฐานข้อมูล", - "generate_video_previews_during_scan_tooltip": "สร้างวีดีโอตัวอย่างที่จะเล่นอัตโนมัติเมื่อวางเคอร์เซอร์ไว้บน scene", + "generate_video_previews_during_scan_tooltip": "สร้างวิดีโอตัวอย่างที่จะเล่นอัตโนมัติเมื่อวางเคอร์เซอร์ไว้บน scene", "anonymise_and_download": "ทำสำเนาฐานข้อมูลแบบปิดบังตัวตนและดาวน์โหลด", "generate_clip_previews_during_scan": "กำลังสร้างภาพตัวอย่างสำหรับคลิปภาพ", "generate_phashes_during_scan_tooltip": "มีประโยชน์ช่วยลดไฟล์ซ้ำซ้อนและระบุ scene", @@ -536,10 +536,15 @@ "images": { "options": { "create_image_clips_from_videos": { - "description": "หากตัวเลือกวีดีโอถูกปิดใช้งานในไลบราลี ไฟล์วีดีโอจะถูกสแกนเป็นคลิปภาพแทน", - "heading": "สแกนไฟล์วีดีโอเป็นคลิปภาพ" + "description": "หากตัวเลือกวิดีโอถูกปิดใช้งานในไลบราลี ไฟล์วิดีโอจะถูกสแกนเป็นคลิปภาพแทน", + "heading": "สแกนไฟล์วิดีโอเป็นคลิปภาพ" + }, + "write_image_thumbnails": { + "heading": "บันทึกภาพตัวอย่าง", + "description": "บันทึกภาพตัวอย่างที่ถูกสร้างขึ้นลงบนดิสก์" } - } + }, + "heading": "ภาพ" }, "custom_css": { "description": "ต้องรีโหลดหน้าเพจใหม่ทุกครั้งเพื่อแสดงผลการตั้งค่า ไม่รับรองความเข้ากันได้ระหว่าง custom CSS และอัพเดตใหม่ของ Stash", @@ -618,6 +623,137 @@ "delete_file": "ลบไฟล์จากเครื่องคอมพิวเตอร์ของคุณเสมอ", "delete_generated_supporting_files": "ลบไฟล์เนื้อหาที่ถูกสร้างขึ้นเสมอ" } + }, + "menu_items": { + "description": "ซ่อนหรือแสดงเมนูเนื้อหาต่างๆ บนแถบนำทาง", + "heading": "รายการเมนู" + }, + "interactive_options": "ตัวเลือกอุปกรณ์อินเตอร์แอ็คทีฟ", + "preview_type": { + "description": "ค่าปริยายชนิดภาพตัวอย่างคือไฟล์วิดีโอ mp4 เลือกใช้ภาพเคลื่อนไหว (webp) หากต้องการลดการใช้ทรัพยากรระบบ โดยภาพเคลื่อนไหวจะถูกสร้างขึ้นเพิ่มเติมจากไฟล์วิดีโอและจะมีขนาดใหญ่กว่า", + "heading": "ชนิดภาพตัวอย่าง", + "options": { + "animated": "ภาพเคลื่อนไหว", + "video": "วิดีโอ", + "static": "ภาพนิ่ง" + } + }, + "scene_player": { + "options": { + "always_start_from_beginning": "เริ่มเล่นวิดีโอจากจุดเริ่มต้นเสมอ", + "auto_start_video": "เริ่มเล่นวิดีโออัตโนมัติ", + "auto_start_video_on_play_selected": { + "description": "เริ่มเล่นวิดีโอโดยอัตโนมัติเมื่อสั่งเล่นจากคิว จากไฟล์ที่เลือก หรือจากการสุ่ม", + "heading": "เริ่มเล่นวิดีโอโดยอัตโนมัติเมื่อสั่งเล่นจากไฟล์ที่เลือก" + }, + "continue_playlist_default": { + "heading": "เล่นไฟล์ถัดไปในเพลย์ลิสต์เสมอ", + "description": "เล่นซีนถัดไปในคิวเมื่อจบวิดีโอ" + }, + "disable_mobile_media_auto_rotate": "ปิดไม่ให้หมุนหน้าจออัตโนมัติเมื่อใช้งานบนอุปกรณ์พกพาแบบขยายเต็มจอ", + "show_ab_loop_controls": "แสดงแผงควบคุมปลั๊กอิน AB Loop", + "show_scrubber": "แสดงแผงกรอวิดีโอ", + "enable_chromecast": "เปิดใช้งาน Chromecast", + "track_activity": "เปิดใช้งานประวัติการดูซีน", + "vr_tag": { + "description": "แสดงปุ่ม VR เมื่อซีนมีแท็กเหล่านี้", + "heading": "ปุ่ม VR" + } + }, + "heading": "เครื่องเล่นซีน" + }, + "scroll_attempts_before_change": { + "description": "จำนวนครั้งที่ต้อง scroll ก่อนจะเปลี่ยนภาพ มีผลเฉพาะเมื่อเปิดใช้ตัวเลือก Pan Y", + "heading": "จำนวนครั้งที่ต้อง scroll ก่อนเปลี่ยนภาพ" + }, + "slideshow_delay": { + "heading": "ระยะเวลาเปลี่ยนภาพสไลด์โชว์ (วินาที)", + "description": "ใช้งานสไลด์โชว์ในหน้าแกลเลอรีได้เมื่ออยู่ในโหมดกำแพงภาพ" + }, + "tag_panel": { + "options": { + "show_child_tagged_content": { + "heading": "แสดงเนื้อหาจากแท็กย่อย", + "description": "ในหน้ามุมมองแท็ก ให้แสดงเนื้อหาจากแท็กย่อยด้วย" + } + }, + "heading": "มุมมองแท็ก" + }, + "use_stash_hosted_funscript": { + "heading": "เปิดใช้เรียกใช้ funscripts ได้โดยตรง", + "description": "เมื่อเปิดใช้งานตัวเลือกนี้ อุปกรณ์ Handy จะสามารถเรียกใช้ funscripts จาก Stash ได้โดยตรงโดยไม่ต้องผ่านเซิร์ฟเวอร์เจ้าอื่น โดย Stash จำเป็นต้องเข้าถึงได้จากอุปกรณ์ และต้องใช้กุญแจ API หากตั้งค่าความปลอดภัยไว้" + }, + "title": "ส่วนติดต่อผู้ใช้", + "studio_panel": { + "heading": "มุมมองสตูดิโอ", + "options": { + "show_child_studio_content": { + "description": "ในมุมมองสตูดิโอ ให้แสดงเนื้อหาจากสตูดิโอลูกด้วย", + "heading": "แสดงเนื้อหาจากสตูดิโอลูก" + } + } + }, + "image_wall": { + "direction": "ทิศทาง", + "heading": "กำแพงภาพ", + "margin": "ขนาดขอบ (พิกเซล)" + }, + "language": { + "heading": "ภาษา" + }, + "minimum_play_percent": { + "description": "ร้อยละของเวลาที่ผ่านไปของซีนที่กำลังดูที่จะถูกนับจำนวนครั้งที่เล่นไฟล์", + "heading": "ร้อยละของเวลาดูวิดีโอ" + }, + "performers": { + "options": { + "image_location": { + "description": "กำหนดตำแหน่งจัดเก็บภาพถ่ายนักแสดงด้วยตัวเอง หรือเว้นว่างเพื่อใช้ค่าปริยาย", + "heading": "กำหนดตำแหน่งจัดเก็บภาพถ่ายนักแสดง" + } + } + }, + "image_lightbox": { + "heading": "กล่องภาพ" + }, + "max_loop_duration": { + "description": "ระยะเวลามากสุดก่อนที่ตัวเล่นวิดีโอจะวนลูป - ใส่ 0 เพื่อปิดใช้งาน", + "heading": "ระยะเวลาก่อนวนลูป" + }, + "scene_list": { + "heading": "มุมมองกริด", + "options": { + "show_studio_as_text": "แสดงชื่อสตูดิโอบนหน้าปกเป็นข้อความ" + } + }, + "scene_wall": { + "heading": "กำแพงซีน / มาร์คเกอร์", + "options": { + "display_title": "แสดงชื่อเรื่องและแท็ก", + "toggle_sound": "เปิดเสียง" + } + }, + "show_tag_card_on_hover": { + "heading": "กล่องแท็ก", + "description": "แสดงกล่องแท็กเมื่อวางเคอร์เซอร์บนแท็ก" + }, + "funscript_offset": { + "heading": "หน่วงเวลา Funscript", + "description": "หน่วงเวลาการเล่นสคริปต์อินเตอร์แอ็คทีฟ หน่วยมิลลิวินาที" + }, + "handy_connection": { + "connect": "เชื่อมต่อ", + "server_offset": { + "heading": "หน่วงเวลาเซิร์ฟเวอร์" + }, + "status": { + "heading": "สถานะการเชื่อมต่ออุปกรณ์ Handy" + }, + "sync": "ซิงค์" + }, + "handy_connection_key": { + "description": "ระบุคีย์สำหรับเชื่อมต่ออุปกรณ์ Handy และแบ่งปันข้อมูลการเล่นซีนกับเว็บไซต์ handyfeeling.com", + "heading": "คีย์สำหรับเชื่อมต่อ Handy" } }, "tools": { @@ -633,7 +769,8 @@ "filename": "ชื่อไฟล์", "filename_pattern": "รูปแบบชื่อไฟล์", "select_parser_recipe": "เลือกสูตรการตั้งชื่อ", - "whitespace_chars_desc": "อักขระที่จะใช้แทนที่อักขระ whitespace ในชื่อไฟล์" + "whitespace_chars_desc": "อักขระที่จะใช้แทนที่อักขระ whitespace ในชื่อไฟล์", + "matches_with": "เปรียบเทียบคำโดยตรง" }, "scene_duplicate_checker": "เครื่องมือตรวจสอบ scene ซ้ำ", "scene_tools": "เครื่องมือเกี่ยวกับ scene" @@ -644,10 +781,39 @@ "updating_untagged_studios_description": "อัพเดตสตูดิโอที่ยังไม่มีข้อมูลโดยการค้นหาสตูดิโอที่ยังไม่มี stashid และทำการอัปเดตข้อมูล metadata", "config": { "create_parent_label": "สร้างสตูดิโอบริษัทแม่", - "create_parent_desc": "สร้างสตูดิโอบริษัทแม่หรือแท็กที่เกี่ยวข้องหากยังไม่มี พร้อมอัปเดตข้อมูลและรูปภาพให้กับรายการที่มีอยู่แล้ว" + "create_parent_desc": "สร้างสตูดิโอบริษัทแม่หรือแท็กที่เกี่ยวข้องหากยังไม่มี พร้อมอัปเดตข้อมูลและรูปภาพให้กับรายการที่มีอยู่แล้ว", + "active_stash-box_instance": "Stash-box instance ที่ใช้งาน:", + "excluded_fields": "ข้อมูลที่ไม่ต้องการใช้งาน:", + "edit_excluded_fields": "แก้ไขประเภทข้อมูลที่ไม่ต้องการใช้งาน", + "no_fields_are_excluded": "ไม่มีข้อมูลที่ไม่ต้องการใช้งาน", + "no_instances_found": "ไม่พบแหล่งข้อมูล", + "these_fields_will_not_be_changed_when_updating_studios": "ข้อมูลประเภทที่เลือกไว้จะไม่ถูกเปลี่ยนแปลงหรืออัปเดต" }, "create_or_tag_parent_studios": "สร้างสตูดิโอบริษัทแม่หรือแท็กที่เกียวข้องหากยังไม่มี", - "untagged_studios": "สตูดิโอที่ยังไม่มีข้อมูล" + "untagged_studios": "สตูดิโอที่ยังไม่มีข้อมูล", + "failed_to_save_studio": "บันทึกสตูดิโอ \"{studio}\" ไม่สำเร็จ", + "network_error": "พบข้อผิดพลาดทางเน็ตเวิร์ค", + "refresh_tagged_studios": "รีเฟรชสตูดิโอที่มีข้อมูลแล้ว", + "batch_add_studios": "เพิ่มสตูดิโอพร้อมกันหลายแห่ง", + "any_names_entered_will_be_queried": "รายชื่อสตูดิโอจะถูกเรียกหาข้อมูลจาก stash-box ที่เลือกไว้ โดยจะเลือกเฉพาะข้อมูลที่ตรงกันเท่านั้น", + "batch_update_studios": "อัพเดตสตูดิโอพร้อมกันหลายแห่ง", + "no_results_found": "ไม่พบผลลัพธ์", + "status_tagging_job_queued": "สถานะ: เพิ่มงานเพิ่มข้อมูลแล้ว", + "studio_names_separated_by_comma": "ชื่อสตูดิโอคั่นแต่ละแห่ง คั่นด้วยเครื่องหมายคอมมา (,)", + "to_use_the_studio_tagger": "ต้องทำการตั้งค่า stash-box ก่อนถึงจะใช้งานเครื่องมือเพิ่มข้อมูลสตูดิโอได้", + "status_tagging_studios": "สถานะ: กำลังเพิ่มข้อมูลสตูดิโอ", + "studio_already_tagged": "มีข้อมูลสตูดิโอนี้แล้ว", + "add_new_studios": "เพิ่มสตูดิโอ", + "current_page": "หน้าปัจจุบัน", + "name_already_exists": "พบสตูดิโอที่ใช้ชื่อนี้แล้ว", + "number_of_studios_will_be_processed": "จะอัปเดตสตูดิโอจำนวน {studio_count} แห่ง", + "query_all_studios_in_the_database": "สตูดิโอทั้งหมดในฐานข้อมูล", + "refreshing_will_update_the_data": "การรีเฟรชจะอัปเดตสตูดิโอที่มีข้อมูลแล้วด้วยข้อมูลจาก stash-box ที่เลือก", + "update_studio": "อัพเดตสตูดิโอ", + "studio_selection": "การเลือกสตูดิโอ", + "studio_successfully_tagged": "เพิ่มข้อมูลสตูดิโอสำเร็จแล้ว", + "tag_status": "สถานะการเพิ่มข้อมูล", + "update_studios": "อัพเดตสตูดิโอ" }, "circumcised_types": { "UNCUT": "ยกเลิกการตัด", @@ -655,12 +821,96 @@ }, "setup": { "paths": { - "where_can_stash_store_its_database_description": "Stash ใช้ระบบฐานข้อมูล SQLite เพื่อจัดเก็บข้อมูล metadata ของกรุหนังของคุณ หากไม่ได้ระบุเป็นอย่างอื่น ไฟล์ stash-go.sqlite จะถูกสร้างขึ้นในไดเร็กทอรีเดียวกันกับไฟล์ config หากต้องการระบุค่าเอง ให้ระบุตำแหน่งและชื่อไฟล์ที่ต้องการอย่าง absolute หรือ relative (อ้างอิงกับไดเร็กทอรีปัจจุบัน)", - "stash_alert": "ไม่ได้ตั้งค่าตำแหน่งไลบรารีไว้ จะไม่มีการเพิ่มเนื้อหาใดๆ เข้าสู่ Stash คุณแน่ใจหรือไม่?" + "where_can_stash_store_its_database_description": "Stash ใช้ระบบฐานข้อมูล SQLite เพื่อจัดเก็บข้อมูล metadata ของกรุหนังของคุณ หากไม่ได้ระบุเป็นอย่างอื่น ไฟล์ stash-go.sqlite จะถูกสร้างขึ้นในไดเร็กทอรีเดียวกันกับไฟล์การตั้งค่า หากต้องการระบุค่าเองให้ระบุตำแหน่งและชื่อไฟล์ที่ต้องการแบบ absolute หรือ relative", + "stash_alert": "ไม่ได้ตั้งค่าตำแหน่งไลบรารีไว้ จะไม่มีการเพิ่มเนื้อหาใดๆ เข้าสู่ Stash คุณแน่ใจหรือไม่?", + "database_filename_empty_for_default": "ชื่อฐานข้อมูล (เว้นว่างไว้เพื่อใช้ค่าปริยาย)", + "path_to_cache_directory_empty_for_default": "ไดเร็กทอรีสำหรับไฟล์แคช (เว้นว่างไว้เพื่อใช้ค่าปริยาย)", + "description": "ถัดไปเป็นการตั้งค่าตำแหน่งที่ต้องการเก็บไฟล์หนังของคุณ และตำแหน่งสำหรับบันทึกฐานข้อมูล ไฟล์ที่ถูกสร้าง และไฟล์แคช โดยคุณสามารถเปลี่ยนแปลงค่าเหล่านี้ได้ภายหลัง", + "set_up_your_paths": "ระบุตำแหน่งไฟล์", + "path_to_generated_directory_empty_for_default": "ไดเร็กทอรีสำหรับไฟล์ที่ถูกสร้างขึ้น (เว้นว่างไว้เพื่อใช้ค่าปริยาย)", + "path_to_blobs_directory_empty_for_default": "ไดเร็กทอรีสำหรับไฟล์ blob (เว้นว่างไว้เพื่อใช้ค่าปริยาย)", + "store_blobs_in_database": "จัดเก็บ blob ในฐานข้อมูล", + "where_can_stash_store_blobs": "คุณต้องการให้ Stash จัดเก็บฐานข้อมูลที่ไหน?", + "where_can_stash_store_its_database": "ต้องการให้ Stash เก็บไฟล์ฐานข้อมูลที่ไหน?", + "where_can_stash_store_cache_files": "คุณต้องการให้ Stash เก็บไฟล์แคชที่ไหน?", + "where_can_stash_store_blobs_description_addendum": "อีกทางเลือกหนึ่งคือคุณสามารถตั้งค่าให้เก็บบันทึกข้อมูลในฐานข้อมูลก็ได้เช่นกัน หมายเหตุ: ทางเลือกนี้จะทำให้ขนาดไฟล์ฐานข้อมูลใหญ่ขึ้นและใช้เวลาในการโยกย้ายนานขึ้นด้วย", + "where_can_stash_store_cache_files_description": "Stash ต้องการที่เก็บแคชสำหรับไฟล์ใช้งานชั่วคราวเพื่อให้ฟังก์ชันการแปลงวิดีโอสดแบบ HSL/DASH ทำงานได้ หากไม่ได้ระบุเป็นอย่างอื่น Stash จะสร้างไดเร็กทอรี cache ไว้ในไดเร็กทอรีเดียวกันกับไฟล์การตั้งค่า หากต้องการระบุค่าเองให้ระบุตำแหน่งไดเร็กทอรีแบบ absolute หรือ relative หากไม่มีไดเร็กทอรีนี้อยู่ Stash จะสร้างใหม่ให้", + "where_can_stash_store_blobs_description": "Stash สามารถบันทึกข้อมูลไบนารี เช่น หน้าปกซีน นักแสดง สตูดิโอ และภาพประกอบแท็ก ในฐานข้อมูลหรือในไฟล์ระบบก็ได้ หากไม่ได้ระบุเป็นอย่างอื่น Stash จะจัดเก็บในไฟล์ระบบที่ไดเร็กทอรีย่อย blobs ซึ่งอยู่ในไดเร็กทอรีเดียวกันกับไฟล์การตั้งค่า หากต้องการระบุค่าเองให้ระบุตำแหน่งไดเร็กทอรีในรูปแบบ absolute หรือ relative หากไม่มีไดเร็กทอรีนี้อยู่ Stash จะสร้างใหม่ให้", + "where_can_stash_store_its_generated_content": "ต้องการให้ Stash เก็บเนื้อหาที่ถูกสร้างขึ้นที่ไหน?", + "where_can_stash_store_its_generated_content_description": "Stash จะสร้างไฟล์รูปภาพและวิดีโอขึ้นเพื่อใช้เป็นไฟล์ตัวอย่างสำหรับเนื้อหา รวมถึงการแปลงไฟล์จากรูปแบบที่ไม่รองรับด้วย หากไม่ได้ระบุเป็นอย่างอื่น Stash จะสร้างไดเร็กทอรี generated ขึ้นในไดเร็กทอรีเดียวกับไฟล์การตั้งค่า หากต้องการระบุค่าเองให้ระบุตำแหน่งไดเร็กทอรีในรูปแบบ absolute หรือ relative หากไม่มีไดเร็กทอรีนี้อยู่ Stash จะสร้างใหม่ให้", + "where_is_your_porn_located": "ไฟล์หนังชมพูของคุณอยู่ที่ไหน?", + "where_can_stash_store_its_database_warning": "คำเตือน: Stash ไม่รองรับการจัดเก็บฐานข้อมูลในที่อื่นๆ ที่ไม่ใช่เซิร์ฟเวอร์ Stash! (เช่นจัดเก็บฐานข้อมูลบน NAS แต่เซิร์ฟเวอร์อยู่ที่คอมพิวเตอร์เครื่องอื่น) ระบบฐานข้อมูล SQLite ไม่ได้ถูกออกแบบมาสำหรับการทำงานบนเน็ตเวิร์คและอาจทำให้ฐานข้อมูลพังได้", + "where_is_your_porn_located_description": "เพิ่มไดเร็กทอรีที่มีไฟล์หนังชมพูและรูปภาพชมพูของคุณ Stash จะสแกนและเพิ่มไฟล์เหล่านั้นเข้าสู่ฐานข้อมูล" }, "confirm": { - "stash_library_directories": "ไดเร็กทอรีสำหรับไลบรารี Stash" - } + "stash_library_directories": "ไดเร็กทอรีสำหรับไลบรารี Stash", + "almost_ready": "การตั้งค่าใกล้สำเร็จ กรุณายืนยันค่าต่อไปนี้ คุณสามารถคลิกย้อนกลับเพื่อกลับไปเปลี่ยนแปลงค่าได้ หากข้อมูลทุกอย่างถูกต้องคลิดยืนยันเพื่อเริ่มสร้างระบบ", + "blobs_use_database": "", + "nearly_there": "เกือบเสร็จแล้ว!", + "cache_directory": "ไดเร็กทอรีสำหรับแคช", + "blobs_directory": "ไดเร็กทอรีสำหรับข้อมูล binary", + "configuration_file_location": "ตำแหน่งไฟล์บันทึกการตั้งค่า:", + "database_file_path": "ตำแหน่งไฟล์ฐานข้อมูล", + "generated_directory": "ไดเร็กทอรีสำหรับไฟล์ที่ถูกสร้างขึ้น" + }, + "migrate": { + "migration_irreversible_warning": "การย้ายฐานข้อมูลแบบ schema เป็นการย้ายแบบถาวร เมื่อย้ายสำเร็จฐานข้อมูลนี้จะไม่สามารถใช้งานกับ stash รุ่นก่อนหน้าได้", + "migration_notes": "Migration Notes", + "migration_required": "จำเป็นต้องย้ายฐานข้อมูล", + "migration_failed_error": "พบเจอปัญหาต่อไปนี้ระหว่างการย้ายฐานข้อมูล:", + "schema_too_old": "ฐานข้อมูลปัจจุบันของคุณเป็นรุ่น {databaseSchema} ซึ่งจำเป็นต้องทำการย้ายขึ้นไปเป็นรุ่น {appSchema} คุณไม่สามารถใช้งาน Stash รุ่นนี้ได้โดยไม่ทำการย้ายฐานข้อมูล หากไม่ต้องการคุณสามารถดาวน์เกรดกลับไปใช้รุ่นก่อนหน้าที่เข้ากันได้กับฐานข้อมูลของคุณ", + "backup_recommended": "การสำรองไฟล์ฐานข้อมูลก่อนทำการย้ายฐานข้อมูลเป็นแนวทางปฏิบัติที่ดิ เราช่วยทำให้คุณได้โดยจะบันทึกไฟล์สำรองไว้ที่ {defaultBackupPath}", + "backup_database_path_leave_empty_to_disable_backup": "ตำแหน่งสำหรับสำรองไฟล์ฐานข้อมูล (เว้นว่างไว้หากไม่ต้องการสำรองข้อมูล):", + "migrating_database": "กำลังย้ายฐานข้อมูล", + "migration_failed": "การย้ายฐานข้อมูลไม่สำเร็จ", + "migration_failed_help": "กรุณาแก้ไขก่อนลองอีกครั้ง หากไม่สำเร็จคุณสามารถรายงานบั๊กได้ที่ {githubLink} หรือขอความช่วยเหลือได้ที่ {discordLink}", + "perform_schema_migration": "เริ่มการย้ายฐานข้อมูลแบบ schema" + }, + "creating": { + "creating_your_system": "กำลังสร้างระบบ" + }, + "errors": { + "something_went_wrong": "ไม่นะ! เกิดข้อผิดพลาดบางอย่าง!", + "something_went_wrong_description": "หากปัญหาเกิดขึ้นจากการตั้งค่าไม่ถูกต้อง คลิกย้อนกลับเพื่อกลับไปแก้ไขให้ถูกต้อง หากเป็นกรณีอื่นคุณสามารถรายงานบั๊กได้ที่ {githubLink} หรือขอความช่วยเหลือได้ที่ {discordLink}", + "something_went_wrong_while_setting_up_your_system": "เกิดข้อผิดพลาดระหว่างสร้างระบบ ข้อมูลความผิดพลาดคือ: {error}" + }, + "folder": { + "up_dir": "ย้อนไดเร็กทอรีขึ้นไปหนึ่งระดับ", + "file_path": "ตำแหน่งไฟล์" + }, + "github_repository": "Github repository", + "success": { + "support_us": "สนับสนุนเรา", + "thanks_for_trying_stash": "ขอบคุณที่เลือกใช้ Stash!", + "help_links": "หากพบเจอปัญหา มีข้อสงสัย หรือข้อเสนอแนะ สามารถรายงานปัญหาได้ที่ {githubLink} หรือขอความช่วยเหลือได้ที่ {discordLink}", + "in_app_manual_explained": "เราแนะนำให้ศึกษาคู่มือการใช้งานภายใน Stash โดยคลิกที่ไอคอน {icon} ทางด้านขวาบนของหน้าจอ", + "missing_ffmpeg": "ไม่พบไฟล์ ffmpeg คุณสามารถดาวน์โหลดและติดตั้งในไดเร็กทอรีการตั้งค่าได้ทันทีโดยติ๊กถูกที่กล่องด้านล่างนี้ หรือคุณสามารถระบุตำแหน่งไฟล์ ffmpeg และ ffprobe ได้ด้วยตนเองในหน้าระบบ", + "next_config_step_one": "หน้าถัดไปเป็นหน้าการตั้งค่าต่างๆ ของระบบ เช่นปรับแต่งการค้นหาไฟล์ ตั้งรหัสล็อกอิน เป็นต้น", + "next_config_step_two": "หากตั้งค่าเสร็จสิ้นแล้ว คุณสามารถเริ่มสแกนเนื้อหาได้โดยไปที่หน้า {localized_task} แล้วคลิก {localized_scan}", + "welcome_contrib": "เรายินดีต้อนรับผู้อยากสนับสนุนเราในทุกรูปแบบเสมอ ไม่ว่าจะเป็นการโค้ด (แก้บั๊ก การปรับปรุงโค้ด หรือการเพิ่มความสามารถระบบ) ร่วมทดสอบระบบ รายงานบั๊ก เสนอข้อปรับปรุง เสนอขอความสามารถใหม่ๆ หรือการขอความช่วยเหลือก็ตาม โดยสามารถดูรายละเอียดเพิ่มเติมได้ในคู่มือภายใน Stash", + "your_system_has_been_created": "การติดตั้งสำเร็จ! ระบบถูกสร้างเรียบร้อยแล้ว!", + "download_ffmpeg": "ดาวน์โหลด FFmpeg", + "getting_help": "ขอความช่วยเหลือ", + "open_collective": "อย่าลืมแวะไปที่ {open_collective_link} เพื่อร่วมเป็นส่วนหนึ่งในการพัฒนา Stash ของเรา" + }, + "welcome_specific_config": { + "config_path": "Stash จะใช้ตำแหน่งเก็บไฟล์การตั้งค่าดังนี้: {path}", + "unable_to_locate_specified_config": "Stash ไม่พบไฟล์การตั้งค่าที่ตำแหน่งที่ระบุไว้ ตัวช่วยการติดตั้งนี้จะช่วยดำเนินการปรับแต่งการตั้งค่าให้คุณ", + "next_step": "คลิกต่อไปเมื่อพร้อมดำเนินการต่อ" + }, + "welcome": { + "in_the_current_working_directory": "ภายในไดเร็กทอรีที่ทำงานอยู่ {path}:", + "in_the_current_working_directory_disabled_macos": "ไม่สามารถทำงานได้ในขณะที่ Stash.app ทำงานอยู่

    เรียกใช้ stash-macos เพื่อติดตั้งในไดเร็กทอรีที่ทำงานอยู่", + "in_the_current_working_directory_disabled": "ภายในไดเร็กทอรีที่ทำงานอยู่ {path}:", + "store_stash_config": "ต้องการเก็บไฟล์การตั้งค่าไว้ที่ไหน?", + "config_path_logic_explained": "Stash จะมองหาไฟล์การตั้งค่า (config.yml) จากไดเร็กทอรีที่ทำงานอยู่ก่อนเสมอ หากไม่เจอจึงจะใช้ค่า {fallback_path} แทน คุณสามารถตั้งให้ Stash มองหาไฟล์จากตำแหน่งที่ต้องการได้โดยการระบุ -c '' or --config ''", + "unable_to_locate_config": "ไม่พบไฟล์การตั้งค่าที่มีอยู่ ตัวช่วยการติดตั้งนี้จะช่วยดำเนินการปรับแต่งการตั้งค่าให้คุณ", + "in_current_stash_directory": "ภายในไดเร็กทอรี {path}:", + "next_step": "กรุณาระบุตำแหน่งสำหรับจัดเก็บไฟล์การตั้งค่า", + "unexpected_explained": "พบปัญหาบางอย่าง กรุณารีสตาร์ท Stash ในไดเร็กทอรีที่ถูกต้องด้วยธง -c" + }, + "welcome_to_stash": "ยินดีต้อนรับสู่ Stash", + "stash_setup_wizard": "ตัวช่วยการติดตั้ง Stash" }, "chapters": "ฉาก", "studio_and_parent": "สตูดิโอและบริษัทแม่", @@ -668,7 +918,38 @@ "parent_studio": "สตูดิโอบริษัทแม่", "performer_tagger": { "updating_untagged_performers_description": "อัปเดตนักแสดงที่ยังไม่มีข้อมูลโดยการค้นหานักแสดงที่ไม่มี stashid และทำการอัพเดตข้อมูล metadata", - "untagged_performers": "นักแสดงที่ยังไม่มีข้อมูล" + "untagged_performers": "นักแสดงที่ยังไม่มีข้อมูล", + "add_new_performers": "เพิ่มนักแสดง", + "performer_selection": "การเลือกนักแสดง", + "performer_successfully_tagged": "เพิ่มข้อมูลนักแสดงสำเร็จแล้ว:", + "tag_status": "สถานะการเพิ่มข้อมูล", + "refresh_tagged_performers": "รีเฟรชนักแสดงที่มีข้อมูลแล้ว", + "batch_add_performers": "เพิ่มนักแสดงพร้อมกันหลายคน", + "config": { + "active_stash-box_instance": "แหล่งข้อมูล stash-box ที่เลือกใช้:", + "excluded_fields": "ข้อมูลที่ไม่ต้องการใช้งาน:", + "no_fields_are_excluded": "ไม่มีข้อมูลที่ไม่ต้องการใช้งาน", + "these_fields_will_not_be_changed_when_updating_performers": "ข้อมูลประเภทที่เลือกไว้จะไม่ถูกเปลี่ยนแปลงหรืออัปเดต", + "no_instances_found": "ไม่พบแหล่งข้อมูล", + "edit_excluded_fields": "แก้ไขประเภทข้อมูลที่ไม่ต้องการใช้งาน" + }, + "batch_update_performers": "อัปเดตข้อมูลนักแสดงพร้อมกันหลายคน", + "network_error": "พบปัญหาเน็ตเวิร์ค", + "any_names_entered_will_be_queried": "รายชื่อนักแสดงจะถูกเรียกหาข้อมูลจาก stash-box ที่เลือกไว้ โดยจะเลือกเฉพาะข้อมูลที่ตรงกันเท่านั้น", + "failed_to_save_performer": "ไม่สามารถบันทึกข้อมูลนักแสดง \"{performer}\" ได้", + "no_results_found": "ไม่พบผลลัพธ์", + "update_performer": "เครื่องมืออัปเดตข้อมูลนักแสดง", + "to_use_the_performer_tagger": "ต้องทำการตั้งค่า stash-box ก่อนถึงจะใช้งานเครื่องมือเพิ่มข้อมูลนักแสดงได้", + "performer_names_separated_by_comma": "ชื่อนักแสดงแต่ละคน คั่นด้วยเครื่องหมายคอมมา (,)", + "refreshing_will_update_the_data": "การรีเฟรชจะอัปเดตนักแสดงที่มีข้อมูลแล้วด้วยข้อมูลจาก stash-box ที่เลือก", + "name_already_exists": "พบนักแสดงที่ใช้ชื่อนี้แล้ว", + "number_of_performers_will_be_processed": "จะอัปเดตนักแสดงจำนวน {performer_count} คน", + "status_tagging_performers": "สถานะ: กำลังเพิ่มข้อมูลนักแสดง", + "update_performers": "เครื่องมืออัปเดตข้อมูลนักแสดง", + "status_tagging_job_queued": "สถานะ: งานเพิ่มข้อมูลถูกเพิ่มเข้าคิว", + "current_page": "หน้าปัจจุบัน", + "performer_already_tagged": "มีข้อมูลนักแสดงคนนี้แล้ว", + "query_all_performers_in_the_database": "นักแสดงทั้งหมดในฐานข้อมูล" }, "library": "ไลบราลี", "audio_codec": "โคเด็คเสียง", @@ -679,5 +960,510 @@ "circumcised": "ขลิบ", "appears_with": "แสดงร่วมกับ", "parent_studios": "สตูดิโอบริษัทแม่", - "between_and": "และ" + "between_and": "และ", + "criterion_modifier": { + "greater_than": "มากกว่า", + "not_null": "ไม่ถูกเว้นว่าง", + "is_null": "ถูกเว้นว่าง", + "matches_regex": "ตรงกับ regex", + "equals": "คือ", + "between": "ระหว่าง", + "includes": "มี", + "includes_all": "มีทั้งหมด", + "less_than": "น้อยกว่า", + "not_between": "ไม่อยู่ระหว่าง", + "not_equals": "ไม่ใช่", + "not_matches_regex": "ไม่ตรงกับ regex", + "excludes": "ยกเว้น", + "format_string": "{criterion} {modifierString} {valueString}", + "format_string_depth": "{criterion} {modifierString} {valueString} (+{depth, plural, =-1 {all} other {{depth}}})", + "format_string_excludes": "{criterion} {modifierString} {valueString} (excludes {excludedString})", + "format_string_excludes_depth": "{criterion} {modifierString} {valueString} (excludes {excludedString}) (+{depth, plural, =-1 {all} other {{depth}}})" + }, + "country": "ประเทศ", + "dialogs": { + "clear_play_history_confirm": "คุณแน่ใจว่าต้องการล้างประวัติการเล่นใช่หรือไม่?", + "clear_o_history_confirm": "คุณแน่ใจว่าต้องการล้างประวัติ O ใช่หรือไม่?", + "edit_entity_title": "แก้ไข{count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "lightbox": { + "delay": "การหน่วงเวลา (วินาที)", + "scroll_mode": { + "pan_y": "Pan Y", + "label": "โหมดการเลื่อนเปลี่ยนภาพ", + "zoom": "ซูม", + "description": "กดปุ่ม shift ค้างไว้เพื่อใช้สลับใช้งานอีกโหมดชั่วคราว" + }, + "display_mode": { + "label": "โหมดแสดงภาพ", + "fit_to_screen": "ขนาดพอดีขนาดจอ", + "original": "ขนาดตามจริง", + "fit_horizontally": "ขนาดพอดีความกว้างจอ" + }, + "options": "ตัวเลือก", + "page_header": "หน้าที่ {page} / {total}", + "reset_zoom_on_nav": "รีเซ็ตระดับการซูมเมื่อเปลี่ยนภาพ", + "scale_up": { + "description": "ขยายภาพที่มีขนาดเล็กให้พอดีหน้าจอ", + "label": "ขยายภาพให้พอดีจอ" + } + }, + "scene_gen": { + "image_previews_tooltip": "สร้างภาพเคลื่อนไหวตัวอย่าง (webp) เปิดใช้งานเฉพาะเมื่อเลือกประเภทของภาพตัวอย่างสำหรับกำแพงซีน/มาร์คเกอร์เป็นแบบภาพเคลื่อนไหวเท่านั้น ใช้ทรัพยากร CPU น้อยกว่าวิดีโอตัวอย่าง แต่จะใช้เนื้อที่เพิ่มขึ้น", + "marker_image_previews_tooltip": "สร้างภาพเคลื่อนไหวตัวอย่าง (webp) เปิดใช้งานเฉพาะเมื่อเลือกประเภทของภาพตัวอย่างสำหรับกำแพงซีน/มาร์คเกอร์เป็นแบบภาพเคลื่อนไหวเท่านั้น ใช้ทรัพยากร CPU น้อยกว่าวิดีโอตัวอย่าง แต่จะใช้เนื้อที่เพิ่มขึ้น", + "preview_exclude_start_time_desc": "ไม่รวม x วินาทีแรกของวิดีโอในพรีวิว สามารถระบุค่าเป็นวินาทีหรือร้อยละของระยะเวลาทั้งหมดของซีน", + "preview_preset_desc": "พรีเซ็ตมีผลกับขนาด คุณภาพ และระยะเวลาในการเข้ารหัสไฟล์พรีวิว ไม่แนะนำให้ใช้พรีเซ็ตที่ช้ากว่า \"ช้า\" เพราะแทบไม่เห็นความแตกต่างในคุณภาพ", + "transcodes_tooltip": "วิดีโอทั้งหมดจะถูกแปลงเป็น MP4 ไว้ล่วงหน้า มีประโยชน์กับคอมพิวเตอร์ที่มี CPU ไม่แรง แต่กินพื้นที่ดิสก์มาก", + "overwrite": "เขียนทับไฟล์", + "clip_previews": "คลิปภาพตัวอย่าง", + "force_transcodes_tooltip": "โดยทั่วไปไฟล์จะถูก transcode เมื่อบราวเซอร์ไม่รองรับประเภทไฟล์ที่ต้องการเล่น เมื่อเปิดใช้งานตัวเลือกนี้ไฟล์ที่เล่นจะถูก transcode เสมอแม้บราวเซอร์จะรองรับ", + "image_thumbnails": "ภาพตัวอย่างขนาดเล็ก", + "marker_screenshots_tooltip": "สร้างภาพนิ่ง JPG สำหรับมาร์คเกอร์ ใช้เมื่อตั้งค่ารูปแบบการพรีวิวเป็นภาพนิ่ง", + "markers": "การพรีวิวมาร์คเกอร์", + "override_preview_generation_options": "ไม่สนใจการตั้งค่าตัวเลือกการสร้างพรีวิวของระบบ", + "preview_exclude_end_time_desc": "ไม่รวม x วินาทีสุดท้ายของวิดีโอในพรีวิว สามารถระบุค่าเป็นวินาทีหรือร้อยละของระยะเวลาทั้งหมดของซีน", + "preview_exclude_end_time_head": "ไม่รวมช่วงท้ายวิดีโอ", + "preview_options": "ตัวเลือกพรีวิว", + "transcodes": "แปลงไฟล์", + "preview_seg_duration_desc": "ระยะเวลาของแต่ละตอนในพรีวิว หน่วยเป็นวินาที", + "preview_seg_duration_head": "ระยะเวลาของตอน", + "sprites": "ภาพนิ่งในแถบกรอวิดีโอ", + "phash": "Perceptual hashes", + "preview_exclude_start_time_head": "ไม่รวมช่วงต้นวิดีโอ", + "covers": "หน้าปกซีน", + "image_previews": "ภาพเคลื่อนไหวตัวอย่าง", + "interactive_heatmap_speed": "สร้าง heatmaps และกราฟความเร็วสำหรับซีนอินเตอร์แอ็คทีฟ", + "marker_image_previews": "สร้างภาพเคลื่อนไหวตัวอย่างสำหรับมาร์คเกอร์", + "marker_screenshots": "ภาพหน้าจอมาร์คเกอร์", + "override_preview_generation_options_desc": "ไม่สนใจการตั้งค่าตัวเลือกการสร้างพรีวิวของระบบ เปลี่ยนค่าปริยายที่หน้า ระบบ -> การสร้างพริวิว", + "preview_generation_options": "ตัวเลือกการสร้างพรีวิว", + "preview_preset_head": "พรีเซ็ตการเข้ารหัสพรีวิว", + "preview_seg_count_desc": "จำนวนตอนในไฟล์พรีวิว", + "preview_seg_count_head": "จำนวนตอนในไฟล์พรีวิว", + "sprites_tooltip": "กลุ่มภาพนิ่งที่ใช้แสดงในแถบกรอวิดีโอเพื่อช่วยในการกรอ", + "video_previews": "พรีวิว", + "video_previews_tooltip": "วิดีโอพรีวิวที่จะเล่นเมื่อวางเคอร์เซอร์บนซีน", + "force_transcodes": "บังคับให้ transcode", + "markers_tooltip": "วิดีโอตัวอย่างยาว 20 วินาที", + "phash_tooltip": "ช่วยค้นหาไฟล์ซ้ำและการจำแนกซีน" + }, + "scrape_entity_query": "คำค้นหาเพื่อ scrape {entity_type}", + "dont_show_until_updated": "ไม่แสดงผลอีกจนถึงอัปเดตถัดไป", + "merge": { + "destination": "ปลายทาง", + "empty_results": "ค่าปลายทางจะไม่มีการเปลี่ยนแปลง", + "source": "ต้นทาง" + }, + "delete_alert": "ไฟล์{count, plural, one {{singularEntity}} other {{pluralEntity}}}ต่อไปนี้จะถูกลบอย่างถาวร:", + "delete_entity_desc": "{count, plural, one {คุณแน่ใจว่าต้องการลบ{singularEntity}ใช่หรือไม่? หากไม่เลือกให้ลบไฟล์ทิ้งด้วย {singularEntity}เหล่านี้จะถูกเพิ่มกลับเข้ามาใหม่เมื่อทำการสแกนอีกครั้ง} other {คุณแน่ใจว่าต้องการลบ{pluralEntity}ใช่หรือไม่? หากไม่เลือกให้ลบไฟล์ทิ้งด้วย {pluralEntity} เหล่านี้จะถูกเพิ่มกลับเข้ามาใหม่เมื่อทำการสแกนอีกครั้ง}}", + "delete_confirm": "คุณแน่ใจว่าต้องการลบ{entityName}ใช่หรือไม่?", + "delete_entity_simple_desc": "{count, plural, one {คุณแน่ใจว่าต้องการลบ{singularEntity}ใช่หรือไม่?} other {คุณแน่ใจว่าต้องการลบ{pluralEntity}ใช่หรือไม่?}}", + "delete_entity_title": "{count, plural, one {ลบ{singularEntity}} other {ลบ{pluralEntity}}}", + "export_include_related_objects": "รวมไฟล์อื่นๆ ที่เกี่ยวข้องในการส่งออกข้อมูลด้วย", + "export_title": "ส่งออกข้อมูล", + "imagewall": { + "direction": { + "description": "ทิศทางเลย์เอาท์ทางตั้งหรือทางนอน", + "column": "ทางตั้ง", + "row": "ทางนอน" + }, + "margin_desc": "ขนาดพื้นที่ขอบรอบรูปภาพ" + }, + "delete_object_desc": "คุณแน่ใจว่าต้องการลบ{count, plural, one {{singularEntity}} other {{pluralEntity}}}นี้ใช่หรือไม่?", + "delete_object_title": "ลบ{count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "create_new_entity": "สร้าง {entity} ใหม่", + "delete_galleries_extra": "…รวมถึงไฟล์ภาพต่างๆ ที่ไม่เกี่ยวข้องกับแกลเลอรีอื่นๆ ด้วย", + "delete_gallery_files": "ลบโฟลเดอร์แกลเลอรี/ไฟล์ซิปและรูปภาพอื่นๆ ที่ไม่เกี่ยวข้องกับแกลเลอรีอื่นๆ", + "delete_object_overflow": "…และอีก {count} {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "merge_tags": { + "destination": "ปลายทาง", + "source": "ต้นทาง" + }, + "overwrite_filter_confirm": "คุณแน่ใจว่าต้องการเขียนทับเงื่อนไขการค้นหา{entityName}ใช่หรือไม่?", + "reassign_files": { + "destination": "ย้ายไปที่" + }, + "scenes_found": "พบซีน {count} ซีน", + "scrape_entity_title": "ผลการค้นหาการ scape {entity_type}", + "scrape_results_existing": "ข้อมูลที่มีอยู่แล้ว", + "scrape_results_scraped": "ข้อมูลที่พบ", + "set_image_url_title": "URL ภาพ", + "unsaved_changes": "ยังไม่ได้บันทึกการเปลี่ยนแปลง คุณแน่ใจว่าต้องการออกจากหน้านี้ใช่หรือไม่?", + "performers_found": "พบนักแสดง {count} คน", + "reassign_entity_title": "{count, plural, one {ย้าย{singularEntity}} other {ย้าย{pluralEntity}}}" + }, + "cover_image": "ภาพหน้าปก", + "dimensions": "ขนาด", + "director": "ผู้กำกับ", + "dupe_check": { + "duration_diff": "ความต่างของระยะเวลาวิดีโอสูงสุด", + "duration_options": { + "equal": "ระยะเวลาต้องเท่ากัน", + "any": "เท่าไหร่ก็ได้" + }, + "only_select_matching_codecs": "ทำการเลือกถ้าโคเด็คของทุกไฟล์ในกลุ่มตรงกัน", + "select_none": "ไม่เลือกไฟล์ใดๆ", + "select_all_but_largest_resolution": "เลือกทุกไฟล์ยกเว้นไฟล์ที่มีความละเอียดสูงสุด", + "options": { + "low": "ต่ำ", + "medium": "ปานกลาง", + "exact": "แม่นยำ", + "high": "สูง" + }, + "title": "ซีนที่ซ้ำ", + "description": "ตัวเลือกที่ต่ำกว่า \"แม่นยำ\" จะใช้เวลาในการประมวลผลมากขึ้น และจะได้ผลลัพธ์ที่ไม่ตรงมากขึ้นเช่นกัน", + "found_sets": "{setCount, plural, one{พบไฟล์ซ้ำ # กลุ่ม} other {พบไฟล์ซ้ำ # กลุ่ม}}", + "search_accuracy_label": "ความแม่นยำในการค้นหา", + "select_all_but_largest_file": "เลือกทุกไฟล์ยกเว้นไฟล์ที่มีขนาดใหญ่สุด", + "select_oldest": "เลือกไฟล์ที่เก่าที่สุด", + "select_options": "ตัวเลือกการเลือกไฟล์…", + "select_youngest": "เลือกไฟล์ที่ใหม่ที่สุด" + }, + "effect_filters": { + "rotate_right_and_scale": "หมุนขวาและปรับขนาด", + "blur": "เบลอ", + "brightness": "ความสว่าง", + "contrast": "คอนทราสต์", + "aspect": "ขนาดภาพ", + "saturation": "ความอิ่มสี", + "scale": "ขนาด", + "hue": "เนื้อสี", + "name": "ฟิลเตอร์", + "name_transforms": "ปรับขนาด", + "rotate_left_and_scale": "หมุนซ้ายและปรับขนาด", + "gamma": "แกมมา", + "green": "เขียว", + "red": "แดง", + "reset_filters": "รีเซ็ตฟิลเตอร์", + "reset_transforms": "รีเซ็ตการปรับขนาด", + "warmth": "โทนอุ่น", + "blue": "น้ำเงิน", + "rotate": "หมุน" + }, + "empty_server": "เพิ่มเนื้อหาเข้าสู่เซิร์ฟเวอร์เพื่อแสดงผลไฟล์แนะนำในหน้านี้", + "existing_value": "ค่าที่มีอยู่", + "errors": { + "image_index_greater_than_zero": "สารบัญภาพต้องมากกว่า 0", + "something_went_wrong": "มีบางอย่างผิดพลาด", + "lazy_component_error_help": "หากคุณอัปเดต Stash เมื่อไม่นานมานี้ กรุณารีโหลดหน้านี้หรือล้างแคชของบราวเซอร์", + "header": "ข้อผิดพลาด", + "loading_type": "พบข้อผิดพลาดในการโหลด{type}" + }, + "eye_color": "สีตา", + "fake_tits": "หน้าอก", + "false": "ปลอม", + "favourite": "ชอบ", + "file_count": "จำนวนไฟล์", + "gender": "เพศสภาพ", + "gender_types": { + "MALE": "ชาย", + "INTERSEX": "กำกวม", + "TRANSGENDER_FEMALE": "หญิงข้ามเพศ", + "FEMALE": "หญิง", + "NON_BINARY": "นอน-ไบนารี", + "TRANSGENDER_MALE": "ชายข้ามเพศ" + }, + "images": "รูปภาพ", + "filesize": "ขนาดไฟล์", + "markers": "มาร์คเกอร์", + "marker_count": "จำนวนมาร์คเกอร์", + "media_info": { + "checksum": "Checksum", + "audio_codec": "โคเด็คเสียง", + "phash": "PHash", + "play_count": "จำนวนครั้งที่เล่น", + "o_count": "จำนวน O", + "performer_card": { + "age": "อายุ {age} {years_old}", + "age_context": "อายุ {age} {years_old} ในเรื่องนี้" + }, + "downloaded_from": "ดาวน์โหลดมาจาก", + "hash": "Hash", + "play_duration": "ระยะเวลาที่เล่น", + "stream": "สตรีม", + "video_codec": "โคเด็ควิดีโอ", + "interactive_speed": "ความเร็วอินเตอร์แอ็คทีฟ" + }, + "package_manager": { + "add_source": "เพิ่มแหล่งข้อมูล", + "source": { + "url": "URL แหล่งข้อมูล", + "name": "ชื่อเรื่อง", + "local_path": { + "description": "ตำแหน่งไฟล์แบบ relative เพื่อจัดเก็บแพ็กเกจของแหล่งข้อมูลนี้ หากเปลี่ยนแปลงค่าต้องทำการย้ายไฟล์แพ็กเกจด้วยตนเอง", + "heading": "ตำแหน่งในเครื่องคอมพิวเตอร์" + } + }, + "edit_source": "แก้ไขแหล่งข้อมูล", + "install": "ติดตั้ง", + "confirm_uninstall": "คุณแน่ใจหรือไม่ว่าต้องการยกเลิกการติดตั้งแพ็กเกจ {number} แพ็กเกจ?", + "description": "คำอธิบาย", + "show_all": "แสดงทั้งหมด", + "uninstall": "ถอนการติดตั้ง", + "unknown": "", + "no_upgradable": "ไม่มีแพ็กเกจที่ต้องอัปเดต", + "required_by": "ถูกใช้งานโดย {packages}", + "selected_only": "เฉพาะที่เลือกไว้", + "update": "อัปเดต", + "check_for_updates": "ตรวจสอบอัปเดต", + "confirm_delete_source": "คุณแน่ใจว่าต้องการลบแหล่งข้อมูล {name} ({url}) ใช่หรือไม่?", + "hide_unselected": "ซ่อนรายการที่ไม่ได้เลือก", + "installed_version": "เวอร์ชันที่ติดตั้ง", + "latest_version": "เวอร์ชันล่าสุด", + "no_packages": "ไม่พบแพ็กเกจ", + "no_sources": "ไม่ได้ตั้งค่าแหล่งข้อมูล", + "package": "แพ็กเกจ", + "version": "เวอร์ชัน" + }, + "perceptual_similarity": "Perceptual Similarity (pHash)", + "performer_age": "อายุ", + "performer_image": "รูปถ่ายนักแสดง", + "configuration": "ปรับแต่งการตั้งค่า", + "developmentVersion": "เวอร์ชันของการพัฒนา", + "file_info": "ข้อมูลไฟล์", + "file_mod_time": "เวลาแก้ไขไฟล์ล่าสุด", + "front_page": { + "types": { + "saved_filter": "ฟิลเตอร์ที่บันทึกไว้", + "premade_filter": "ฟิลเตอร์สำเร็จรูป" + } + }, + "help": "ความช่วยเหลือ", + "last_played_at": "เล่นครั้งสุดท้ายเมื่อ", + "measurements": "สัดส่วน", + "folder": "โฟลเดอร์", + "framerate": "อัตราเฟรม", + "frames_per_second": "{value} เฟรมต่อวินาที", + "penis_length_cm": "ความยาวองคชาต (ซม.)", + "connection_monitor": { + "websocket_connection_failed": "ไม่สามารถเชื่อมต่อ websocket ได้ ตรวจสอบรายละเอียดเพิ่มเติมที่คอนโซลของบราวเซอร์", + "websocket_connection_reestablished": "เชื่อมต่อ websocket สำเร็จ" + }, + "created_at": "เวลาที่สร้าง", + "criterion": { + "greater_than": "มากกว่า", + "less_than": "น้อยกว่า", + "value": "ข้อความ" + }, + "custom": "กำหนดเอง", + "date": "วันที่", + "date_format": "YYYY-MM-DD", + "datetime_format": "YYYY-MM-DD HH:MM", + "death_date": "วันที่เสียชีวิต", + "death_year": "ปีที่เสียชีวิต", + "descending": "เรียกจากมากไปน้อย", + "description": "คำอธิบาย", + "detail": "รายละเอียด", + "details": "รายละเอียด", + "display_mode": { + "unknown": "ไม่มีข้อมูล", + "grid": "กริด", + "list": "รายการ", + "tagger": "เครื่องมือแท็ก", + "wall": "กำแพง" + }, + "duplicated_phash": "ไฟล์ที่ซ้ำ (pHash)", + "filter_name": "ชื่อฟิลเตอร์", + "filters": "ฟิลเตอร์", + "height_cm": "ความสูง (ซม.)", + "history": "ประวัติ", + "image": "รูปภาพ", + "image_count": "จำนวนรูปภาพ", + "image_index": "รูปภาพที่ #", + "megabits_per_second": "{value} mbps", + "movie": "หนัง", + "o_count": "จำนวน O", + "orientation": "ทิศทาง", + "parent_of": "บริษัทแม่ของ {children}", + "pagination": { + "previous": "ก่อนหน้า", + "first": "หน้าแรก", + "last": "หน้าสุดท้าย", + "next": "ถัดไป" + }, + "gallery": "แกลเลอรี", + "gallery_count": "จำนวนแกลเลอรี", + "handy_connection_status": { + "missing": "ไม่พบอุปกรณ์", + "connecting": "กำลังเชื่อมต่อ", + "disconnected": "ตัดการเชื่อมต่อ", + "error": "พบข้อผิดพลาดในการติดต่อ Handy", + "ready": "พร้อมใช้งาน", + "syncing": "กำลังซิงค์กับเซิร์ฟเวอร์", + "uploading": "กำลังอัปโหลดสคริปท์" + }, + "index_of_total": "รายการที่ {index} จาก {total}", + "galleries": "แกลเลอรี", + "interactive_speed": "ความเร็วอินเตอร์แอ็คทีฟ", + "include_sub_studios": "นับรวมสตูดิโอลูกด้วย", + "isMissing": "ที่ไม่มี", + "include_sub_tags": "นับรวมแท็กย่อยด้วย", + "last_o_at": "O ครั้งสุดท้ายเมื่อ", + "instagram": "อินสตาแกรม", + "interactive": "อินเตอร์แอ็คทีฟ", + "performer": "นักแสดง", + "movie_scene_number": "ลำดับซีน", + "movies": "หนัง", + "name": "ชื่อเรื่อง", + "new": "เพิ่ม", + "none": "ไม่มี", + "o_counter": "O-Counter", + "o_history": "ประวัติ O", + "organized": "จัดระเบียบแล้ว", + "disambiguation": "แก้ความกำกวม", + "distance": "ระยะทาง", + "duration": "ความยาว", + "ethnicity": "เชื้อชาติ", + "file": "ไฟล์", + "files": "ไฟล์", + "files_amount": "{value} ไฟล์", + "filter": "ฟิลเตอร์", + "hair_color": "สีผม", + "hasChapters": "มีฉาก", + "hasMarkers": "มีมาร์คเกอร์", + "height": "ความสูง", + "ignore_auto_tag": "ไม่สนใจการแท็กอัตโนมัติ", + "include_parent_tags": "นับรวมแท็กหลักด้วย", + "loading": { + "generic": "กำลังโหลด…" + }, + "odate_recorded_no": "ไม่มีประวัติ O", + "parent_tag_count": "จำนวนแท็กย่อย", + "parent_tags": "แท็กหลัก", + "part_of": "เป็นส่วนหนึ่งของ {parent}", + "path": "ตำแหน่ง", + "penis": "องคชาต", + "penis_length": "ความยาวองคชาต", + "performer_count": "จำนวนนักแสดง", + "donate": "บริจาค", + "search_filter": { + "update_filter": "อัปเดตฟิลเตอร์", + "name": "ฟิลเตอร์ค้นหา", + "saved_filters": "ฟิลเตอร์ที่บันทึกไว้", + "edit_filter": "แก้ไขฟิลเตอร์ค้นหา" + }, + "seconds": "วินาที", + "piercings": "การเจาะ", + "play_count": "จำนวนครั้งที่เล่น", + "play_duration": "รายะเวลาที่เล่น", + "play_history": "ประวัติการเล่น", + "scene_created_at": "เวลาที่ถูกสร้าง", + "resume_time": "เวลาที่จะเริ่มเล่น", + "rating": "คะแนน", + "recently_added_objects": "{objects} ที่เพิ่งถูกเพิ่ม", + "random": "สุ่ม", + "scene": "ซีน", + "scenes": "ซีน", + "second": "วินาที", + "settings": "การตั้งค่า", + "recently_released_objects": "{objects} ที่เพิ่งวางขาย", + "release_notes": "Release Notes", + "sceneTagger": "เครื่องมือเพิ่มข้อมูลซีน", + "scene_tags": "แท็กของซีน", + "performer_favorite": "ชอบใจ", + "performer_tags": "แท็กของนักแสดง", + "performers": "นักแสดง", + "photographer": "ช่างภาพ", + "playdate_recorded_no": "ไม่พบบันทึกการเล่น", + "plays": "เล่นแล้ว {value} ครั้ง", + "primary_file": "ไฟล์หลัก", + "primary_tag": "แท็กหลัก", + "queue": "คิว", + "resolution": "ความละเอียด", + "scene_code": "รหัสไฟล์", + "scene_count": "จำนวนซีน", + "scene_id": "รหัสซีน", + "scene_updated_at": "วันที่อัปเดตซีน", + "scenes_updated_at": "วันที่อัปเดตซีน", + "scene_date": "วันที่ปล่อยซีน", + "stash_ids": "Stash ID", + "stash_id_endpoint": "Stash ID Endpoint", + "stats": { + "total_play_duration": "ระยะเวลาที่เล่นรวม", + "total_o_count": "จำนวน O-Count ทั้งหมด", + "total_play_count": "จำนวนครั้งที่เล่นรวม", + "scenes_played": "จำนวนซีนที่เล่นแล้ว", + "image_size": "ขนาดรูปภาพ", + "scenes_duration": "ระยะเวลารวมทุกซีน", + "scenes_size": "ขนาดซีนทั้งหมด" + }, + "studio": "สตูดิโอ", + "studios": "สตูดิโอ", + "true": "จริง", + "twitter": "ทวิตเตอร์", + "video_codec": "โคเด็ควิดีโอ", + "weight_kg": "น้ำหนัก (กก.)", + "years_old": "ปี", + "zip_file_count": "จำนวนไฟล์ซิป", + "countables": { + "images": "{count, plural, one {รูปภาพ} other {รูปภาพ}}", + "scenes": "{count, plural, one {ซีน} other {ซีน}}", + "studios": "{count, plural, one {สตูดิโอ} other {สตูดิโอ}}", + "tags": "{count, plural, one {แท็ก} other {แท็ก}}", + "files": "{count, plural, one {ไฟล์} other {ไฟล์}}", + "galleries": "{count, plural, one {แกลเลอรี} other {แกลเลอรี}}", + "markers": "{count, plural, one {มาร์คเกอร์} other {มาร์คเกอร์}}", + "movies": "{count, plural, one {หนัง} other {หนัง}}", + "performers": "{count, plural, one {นักแสดง} other {นักแสดง}}" + }, + "sub_tags": "แท็กย่อย", + "synopsis": "เรื่องย่อ", + "tag": "แท็ก", + "toast": { + "merged_scenes": "รวมซีนแล้ว", + "delete_past_tense": "ลบ{count, plural, one {{singularEntity}} other {{pluralEntity}}}แล้ว", + "added_generation_job_to_queue": "เพิ่มงานสร้างไฟล์เพิ่มเติมในคิวแล้ว", + "generating_screenshot": "กำลังสร้างภาพหน้าจอ…", + "rescanning_entity": "กำลังสแกน{count, plural, one {{singularEntity}} other {{pluralEntity}}}…", + "started_auto_tagging": "เริ่มงานเพิ่มข้อมูลอัตโนมัติแล้ว", + "started_importing": "เริ่มการนำเข้าข้อมูลแล้ว", + "added_entity": "เพิ่ม{count, plural, one {{singularEntity}} other {{pluralEntity}}}แล้ว", + "created_entity": "สร้าง{entity}แล้ว", + "default_filter_set": "ชุดฟิลเตอร์พื้นฐาน", + "image_index_too_large": "พบความผิดพลาด: index รูปภาพมีขนาดใหญ่กว่าจำนวนรูปภาพในแกลเลอรี", + "merged_tags": "รวมแท็กแล้ว", + "reassign_past_tense": "ย้ายไฟล์แล้ว", + "removed_entity": "ลบ{count, plural, one {{singularEntity}} other {{pluralEntity}}}แล้ว", + "saved_entity": "บันทึก{entity}แล้ว", + "started_generating": "เริ่มสร้างไฟล์เพิ่มเติมแล้ว", + "updated_entity": "อัปเดต{entity}แล้ว" + }, + "stashbox": { + "submit_update": "มีอยู่แล้วที่ {endpoint_name}", + "go_review_draft": "ไปที่ {endpoint_name} เพื่อตรวจสอบการตั้งค่า", + "selected_stash_box": "Stash-Box endpoint ที่เลือกไว้", + "source": "แหล่งข้อมูล Stash-Box", + "submission_failed": "การส่งข้อมูลไม่สำเร็จ", + "submission_successful": "การส่งข้อมูลสำเร็จ" + }, + "studio_depth": "ระดับ (เว้นว่างเพื่อนับทั้งหมด)", + "type": "ประเภท", + "url": "URL", + "urls": "URLs", + "validation": { + "date_invalid_form": "${path} ต้องอยู่ในรูปแบบ YYYY-MM-DD", + "required": "จำเป็นต้องระบุ ${path}", + "blank": "${path} ต้องไม่เว้นว่างไว้", + "unique": "${path} ต้องไม่ซ้ำ" + }, + "operations": "ปฏิบัติการ", + "statistics": "สถิติ", + "status": "สถานะ: {statusText}", + "subsidiary_studio_count": "จำนวนสตูดิโอย่อย", + "subsidiary_studios": "สตูดิโอย่อย", + "tag_count": "จำนวนแท็ก", + "tag_parent_tooltip": "มีแท็กหลัก", + "sub_tag_count": "จำนวนแท็กย่อย", + "sub_tag_of": "เป็นแท็กย่อยของ {parent}", + "tag_sub_tag_tooltip": "มีแท็กย่อย", + "tags": "แท็ก", + "tattoos": "รอยสัก", + "time": "เวลา", + "title": "ชื่อเรื่อง", + "updated_at": "อัปเดตเมื่อ", + "total": "ทั้งหมด", + "unknown_date": "ไม่ระบุวันที่", + "view_all": "ดูทั้งหมด", + "weight": "น้ำหนัก", + "videos": "วิดีโอ", + "stash_id": "Stash ID" } diff --git a/ui/v2.5/src/locales/zh-CN.json b/ui/v2.5/src/locales/zh-CN.json index f972a74f2..97613a7e9 100644 --- a/ui/v2.5/src/locales/zh-CN.json +++ b/ui/v2.5/src/locales/zh-CN.json @@ -1013,7 +1013,9 @@ "lazy_component_error_help": "如果您最近升级了 Stash,请重新加载页面或清除浏览器缓存。", "something_went_wrong": "出了些问题。", "header": "错误", - "loading_type": "加载 {type} 出错" + "loading_type": "加载 {type} 出错", + "invalid_javascript_string": "无效的javascript代码:{error}", + "invalid_json_string": "无效的JSON字符串:{error}" }, "ethnicity": "人种", "existing_value": "现值", @@ -1119,7 +1121,8 @@ "first": "首页", "last": "尾页", "next": "下一页", - "previous": "上一页" + "previous": "上一页", + "current_total": "第{current}页, 共 {total}页" }, "parent_of": "{children}的上级", "parent_studios": "上级工作室", @@ -1465,5 +1468,8 @@ "websocket_connection_reestablished": "Websocket连接已重新建立", "websocket_connection_failed": "无法建立websocket连接:有关详细信息,请参阅浏览器控制台" }, - "o_count": "高潮次数" + "o_count": "高潮次数", + "movie_count": "影片数量", + "studio_tags": "工作室标签", + "studio_count": "工作室计数" } From 2b1a57c6d079436d07f302d4cfabc4d772d87498 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 21 Jun 2024 16:15:59 +1000 Subject: [PATCH 08/28] Fix key for tagger scenes (#5000) --- ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx index c8b1c43d9..ab6bd226e 100755 --- a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx @@ -264,7 +264,7 @@ export const Tagger: React.FC = ({ scenes, queue }) => {
    {filteredScenes.map((s, i) => ( Date: Fri, 21 Jun 2024 16:16:16 +1000 Subject: [PATCH 09/28] Fix save default filter not clearing criteria (#4999) --- .../src/components/List/SavedFilterList.tsx | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/ui/v2.5/src/components/List/SavedFilterList.tsx b/ui/v2.5/src/components/List/SavedFilterList.tsx index e10ca5fd6..63654c028 100644 --- a/ui/v2.5/src/components/List/SavedFilterList.tsx +++ b/ui/v2.5/src/components/List/SavedFilterList.tsx @@ -10,7 +10,7 @@ import { Tooltip, } from "react-bootstrap"; import { - useConfigureUI, + useConfigureUISetting, useFindSavedFilters, useSavedFilterDestroy, useSaveFilter, @@ -51,7 +51,7 @@ export const SavedFilterList: React.FC = ({ const [saveFilter] = useSaveFilter(); const [destroyFilter] = useSavedFilterDestroy(); - const [saveUI] = useConfigureUI(); + const [saveUISetting] = useConfigureUISetting(); const savedFilters = data?.findSavedFilters ?? []; @@ -136,17 +136,14 @@ export const SavedFilterList: React.FC = ({ try { setSaving(true); - await saveUI({ + await saveUISetting({ variables: { - partial: { - defaultFilters: { - [view.toString()]: { - mode: filter.mode, - find_filter: filterCopy.makeFindFilter(), - object_filter: filterCopy.makeSavedFilter(), - ui_options: filterCopy.makeSavedUIOptions(), - }, - }, + key: `defaultFilters.${view.toString()}`, + value: { + mode: filter.mode, + find_filter: filterCopy.makeFindFilter(), + object_filter: filterCopy.makeSavedFilter(), + ui_options: filterCopy.makeSavedUIOptions(), }, }, }); From 6775a28ec7ef6046bed417c962dc43b0b62a66db Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 24 Jun 2024 13:15:54 +1000 Subject: [PATCH 10/28] Add schema migration to fix view_date format (#4992) Also adds index on scene_id and adds a not null constraint to scene_id --- pkg/sqlite/database.go | 2 +- pkg/sqlite/migrations/64_fixes.up.sql | 49 +++++++++++++ pkg/sqlite/migrations/64_postmigrate.go | 92 +++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 pkg/sqlite/migrations/64_fixes.up.sql create mode 100644 pkg/sqlite/migrations/64_postmigrate.go diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 6436efee8..84220b398 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -30,7 +30,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 63 +var appSchemaVersion uint = 64 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/64_fixes.up.sql b/pkg/sqlite/migrations/64_fixes.up.sql new file mode 100644 index 000000000..6128c292d --- /dev/null +++ b/pkg/sqlite/migrations/64_fixes.up.sql @@ -0,0 +1,49 @@ +PRAGMA foreign_keys=OFF; + +-- recreate scenes_view_dates adding not null to scene_id and adding indexes +CREATE TABLE `scenes_view_dates_new` ( + `scene_id` integer not null, + `view_date` datetime not null, + foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE +); + +INSERT INTO `scenes_view_dates_new` + ( + `scene_id`, + `view_date` + ) + SELECT + `scene_id`, + `view_date` + FROM `scenes_view_dates` + WHERE `scenes_view_dates`.`scene_id` IS NOT NULL; + +DROP INDEX IF EXISTS `index_scenes_view_dates`; +DROP TABLE `scenes_view_dates`; +ALTER TABLE `scenes_view_dates_new` rename to `scenes_view_dates`; +CREATE INDEX `index_scenes_view_dates` ON `scenes_view_dates` (`scene_id`); + +-- recreate scenes_o_dates adding not null to scene_id and adding indexes +CREATE TABLE `scenes_o_dates_new` ( + `scene_id` integer not null, + `o_date` datetime not null, + foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE +); + +INSERT INTO `scenes_o_dates_new` + ( + `scene_id`, + `o_date` + ) + SELECT + `scene_id`, + `o_date` + FROM `scenes_o_dates` + WHERE `scenes_o_dates`.`scene_id` IS NOT NULL; + +DROP INDEX IF EXISTS `index_scenes_o_dates`; +DROP TABLE `scenes_o_dates`; +ALTER TABLE `scenes_o_dates_new` rename to `scenes_o_dates`; +CREATE INDEX `index_scenes_o_dates` ON `scenes_o_dates` (`scene_id`); + +PRAGMA foreign_keys=ON; \ No newline at end of file diff --git a/pkg/sqlite/migrations/64_postmigrate.go b/pkg/sqlite/migrations/64_postmigrate.go new file mode 100644 index 000000000..ecf291050 --- /dev/null +++ b/pkg/sqlite/migrations/64_postmigrate.go @@ -0,0 +1,92 @@ +package migrations + +import ( + "context" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/sqlite" +) + +// this is a copy of the 55 post migration +// some non-UTC dates were missed, so we need to correct them + +type schema64Migrator struct { + migrator +} + +func post64(ctx context.Context, db *sqlx.DB) error { + logger.Info("Running post-migration for schema version 64") + + m := schema64Migrator{ + migrator: migrator{ + db: db, + }, + } + + return m.migrate(ctx) +} + +func (m *schema64Migrator) migrate(ctx context.Context) error { + // the last_played_at column was storing in a different format than the rest of the timestamps + // convert the play history date to the correct format + if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { + query := "SELECT DISTINCT `scene_id`, `view_date` FROM `scenes_view_dates`" + + rows, err := m.db.Query(query) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var ( + id int + viewDate sqlite.Timestamp + ) + + err := rows.Scan(&id, &viewDate) + if err != nil { + return err + } + + // skip if already in the correct format + if viewDate.Timestamp.Location() == time.UTC { + logger.Debugf("view date %s is already in the correct format", viewDate.Timestamp) + continue + } + + utcTimestamp := sqlite.UTCTimestamp{ + Timestamp: viewDate, + } + + // convert the timestamp to the correct format + logger.Debugf("correcting view date %q to UTC date %q for scene %d", viewDate.Timestamp, viewDate.Timestamp.UTC(), id) + r, err := m.db.Exec("UPDATE scenes_view_dates SET view_date = ? WHERE view_date = ? OR view_date = ?", utcTimestamp, viewDate.Timestamp, viewDate) + if err != nil { + return fmt.Errorf("error correcting view date %s to %s: %w", viewDate.Timestamp, viewDate, err) + } + + rowsAffected, err := r.RowsAffected() + if err != nil { + return err + } + + if rowsAffected == 0 { + return fmt.Errorf("no rows affected when updating view date %s to %s for scene %d", viewDate.Timestamp, viewDate.Timestamp.UTC(), id) + } + } + + return rows.Err() + }); err != nil { + return err + } + + return nil +} + +func init() { + sqlite.RegisterPostMigration(64, post64) +} From a4e25f32ea8e8f59fcda4fb40670e4f6450f157f Mon Sep 17 00:00:00 2001 From: NodudeWasTaken <75137537+NodudeWasTaken@users.noreply.github.com> Date: Mon, 24 Jun 2024 05:33:27 +0200 Subject: [PATCH 11/28] Add apple encoder and fix extra_hw_frames bug (#4986) * Fixes format in full hw encoding to nv12 for cuda, vaapi and qsv now * Remove extra_hw_frames * Add apple transcoder support * Up the duration to discover decoding errors * yuv420p is not supported on intel --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- pkg/ffmpeg/codec_hardware.go | 162 +++++++++++++++++++++++++-------- pkg/ffmpeg/ffmpeg.go | 14 +++ pkg/ffmpeg/ffmpeg_test.go | 75 +++++++++++++++ pkg/ffmpeg/filter.go | 31 +++---- pkg/ffmpeg/stream_segmented.go | 2 +- pkg/ffmpeg/stream_transcode.go | 4 +- 6 files changed, 227 insertions(+), 61 deletions(-) create mode 100644 pkg/ffmpeg/ffmpeg_test.go diff --git a/pkg/ffmpeg/codec_hardware.go b/pkg/ffmpeg/codec_hardware.go index 4c39cb3b9..e4797a84a 100644 --- a/pkg/ffmpeg/codec_hardware.go +++ b/pkg/ffmpeg/codec_hardware.go @@ -4,7 +4,9 @@ import ( "bytes" "context" "fmt" + "math" "regexp" + "strconv" "strings" "github.com/stashapp/stash/pkg/logger" @@ -25,7 +27,7 @@ var ( VideoCodecVVPX VideoCodec = "vp8_vaapi" ) -const minHeight int = 256 +const minHeight int = 480 // Tests all (given) hardware codec's func (f *FFMpeg) InitHWSupport(ctx context.Context) { @@ -38,17 +40,19 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) { VideoCodecR264, VideoCodecIVP9, VideoCodecVVP9, + VideoCodecM264, } { var args Args args = append(args, "-hide_banner") args = args.LogLevel(LogLevelWarning) args = f.hwDeviceInit(args, codec, false) args = args.Format("lavfi") - args = args.Input(fmt.Sprintf("color=c=red:s=%dx%d", 1280, 720)) + vFile := &models.VideoFile{Width: 1280, Height: 720} + args = args.Input(fmt.Sprintf("color=c=red:s=%dx%d", vFile.Width, vFile.Height)) args = args.Duration(0.1) // Test scaling - videoFilter := f.hwMaxResFilter(codec, 1280, 720, minHeight, false) + videoFilter := f.hwMaxResFilter(codec, vFile, minHeight, false) args = append(args, CodecInit(codec)...) args = args.VideoFilter(videoFilter) @@ -93,9 +97,9 @@ func (f *FFMpeg) hwCanFullHWTranscode(ctx context.Context, codec VideoCodec, vf args = args.XError() args = f.hwDeviceInit(args, codec, true) args = args.Input(vf.Path) - args = args.Duration(0.1) + args = args.Duration(1) - videoFilter := f.hwMaxResFilter(codec, vf.Width, vf.Height, reqHeight, true) + videoFilter := f.hwMaxResFilter(codec, vf, reqHeight, true) args = append(args, CodecInit(codec)...) args = args.VideoFilter(videoFilter) @@ -128,12 +132,12 @@ func (f *FFMpeg) hwDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args { args = append(args, "-hwaccel_device") args = append(args, "0") if fullhw { + args = append(args, "-threads") + args = append(args, "1") args = append(args, "-hwaccel") args = append(args, "cuda") args = append(args, "-hwaccel_output_format") args = append(args, "cuda") - args = append(args, "-extra_hw_frames") - args = append(args, "5") } case VideoCodecV264, VideoCodecVVP9: @@ -158,6 +162,16 @@ func (f *FFMpeg) hwDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args { args = append(args, "-filter_hw_device") args = append(args, "hw") } + case VideoCodecM264: + if fullhw { + args = append(args, "-hwaccel") + args = append(args, "videotoolbox") + args = append(args, "-hwaccel_output_format") + args = append(args, "videotoolbox_vld") + } else { + args = append(args, "-init_hw_device") + args = append(args, "videotoolbox=vt") + } } return args @@ -175,7 +189,7 @@ func (f *FFMpeg) hwFilterInit(toCodec VideoCodec, fullhw bool) VideoFilter { } case VideoCodecN264: if !fullhw { - videoFilter = videoFilter.Append("format=nv12") + videoFilter = videoFilter.Append("format=yuv420p") videoFilter = videoFilter.Append("hwupload_cuda") } case VideoCodecI264, @@ -184,80 +198,146 @@ func (f *FFMpeg) hwFilterInit(toCodec VideoCodec, fullhw bool) VideoFilter { videoFilter = videoFilter.Append("hwupload=extra_hw_frames=64") videoFilter = videoFilter.Append("format=qsv") } + case VideoCodecM264: + if !fullhw { + videoFilter = videoFilter.Append("format=nv12") + videoFilter = videoFilter.Append("hwupload") + } } return videoFilter } -var scaler_re = regexp.MustCompile(`scale=(?P[-\d]+:[-\d]+)`) +var scaler_re = regexp.MustCompile(`scale=(?P([-\d]+):([-\d]+))`) -func templateReplaceScale(input string, template string, match []int, minusonehack bool) string { +func templateReplaceScale(input string, template string, match []int, vf *models.VideoFile, minusonehack bool) string { result := []byte{} - res := string(scaler_re.ExpandString(result, template, input, match)) - - // BUG: [scale_qsv]: Size values less than -1 are not acceptable. - // Fix: Replace all instances of -2 with -1 in a scale operation if minusonehack { - res = strings.ReplaceAll(res, "-2", "-1") + // Parse width and height + w, err := strconv.Atoi(input[match[4]:match[5]]) + if err != nil { + logger.Error("failed to parse width") + return input + } + h, err := strconv.Atoi(input[match[6]:match[7]]) + if err != nil { + logger.Error("failed to parse height") + return input + } + + // Calculate ratio + ratio := float64(vf.Width) / float64(vf.Height) + if w < 0 { + w = int(math.Round(float64(h) * ratio)) + } else if h < 0 { + h = int(math.Round(float64(w) / ratio)) + } + + // Fix not divisible by 2 errors + if w%2 != 0 { + w++ + } + if h%2 != 0 { + h++ + } + + template = strings.ReplaceAll(template, "$value", fmt.Sprintf("%d:%d", w, h)) } + res := string(scaler_re.ExpandString(result, template, input, match)) + matchStart := match[0] matchEnd := match[1] return input[0:matchStart] + res + input[matchEnd:] } -// Replace video filter scaling with hardware scaling for full hardware transcoding -func (f *FFMpeg) hwCodecFilter(args VideoFilter, codec VideoCodec, fullhw bool) VideoFilter { +// Replace video filter scaling with hardware scaling for full hardware transcoding (also fixes the format) +func (f *FFMpeg) hwCodecFilter(args VideoFilter, codec VideoCodec, vf *models.VideoFile, fullhw bool) VideoFilter { sargs := string(args) match := scaler_re.FindStringSubmatchIndex(sargs) if match == nil { - return args + return f.hwApplyFullHWFilter(args, codec, fullhw) } + return f.hwApplyScaleTemplate(sargs, codec, match, vf, fullhw) +} + +// Apply format switching if applicable +func (f *FFMpeg) hwApplyFullHWFilter(args VideoFilter, codec VideoCodec, fullhw bool) VideoFilter { switch codec { case VideoCodecN264: - template := "scale_cuda=$value" - // In 10bit inputs you might get an error like "10 bit encode not supported" - if fullhw && f.version.major >= 5 { - template += ":format=nv12" + if fullhw && f.version.Gteq(FFMpegVersion{major: 5}) { // Added in FFMpeg 5 + args = args.Append("scale_cuda=format=yuv420p") + } + case VideoCodecV264, VideoCodecVVP9: + if fullhw && f.version.Gteq(FFMpegVersion{major: 3, minor: 1}) { // Added in FFMpeg 3.1 + args = args.Append("scale_vaapi=format=nv12") + } + case VideoCodecI264, VideoCodecIVP9: + if fullhw && f.version.Gteq(FFMpegVersion{major: 3, minor: 3}) { // Added in FFMpeg 3.3 + args = args.Append("scale_qsv=format=nv12") } - args = VideoFilter(templateReplaceScale(sargs, template, match, false)) - case VideoCodecV264, - VideoCodecVVP9: - template := "scale_vaapi=$value" - args = VideoFilter(templateReplaceScale(sargs, template, match, false)) - case VideoCodecI264, - VideoCodecIVP9: - template := "scale_qsv=$value" - args = VideoFilter(templateReplaceScale(sargs, template, match, true)) } return args } +// Switch scaler +func (f *FFMpeg) hwApplyScaleTemplate(sargs string, codec VideoCodec, match []int, vf *models.VideoFile, fullhw bool) VideoFilter { + var template string + + switch codec { + case VideoCodecN264: + template = "scale_cuda=$value" + if fullhw && f.version.Gteq(FFMpegVersion{major: 5}) { // Added in FFMpeg 5 + template += ":format=yuv420p" + } + case VideoCodecV264, VideoCodecVVP9: + template = "scale_vaapi=$value" + if fullhw && f.version.Gteq(FFMpegVersion{major: 3, minor: 1}) { // Added in FFMpeg 3.1 + template += ":format=nv12" + } + case VideoCodecI264, VideoCodecIVP9: + template = "scale_qsv=$value" + if fullhw && f.version.Gteq(FFMpegVersion{major: 3, minor: 3}) { // Added in FFMpeg 3.3 + template += ":format=nv12" + } + case VideoCodecM264: + template = "scale_vt=$value" + default: + return VideoFilter(sargs) + } + + // BUG: [scale_qsv]: Size values less than -1 are not acceptable. + isIntel := codec == VideoCodecI264 || codec == VideoCodecIVP9 + // BUG: scale_vt doesn't call ff_scale_adjust_dimensions, thus cant accept negative size values + isApple := codec == VideoCodecM264 + return VideoFilter(templateReplaceScale(sargs, template, match, vf, isIntel || isApple)) +} + // Returns the max resolution for a given codec, or a default -func (f *FFMpeg) hwCodecMaxRes(codec VideoCodec, dW int, dH int) (int, int) { +func (f *FFMpeg) hwCodecMaxRes(codec VideoCodec) (int, int) { switch codec { case VideoCodecN264, VideoCodecI264: return 4096, 4096 } - return dW, dH + return 0, 0 } // Return a maxres filter -func (f *FFMpeg) hwMaxResFilter(toCodec VideoCodec, width int, height int, reqHeight int, fullhw bool) VideoFilter { - if width == 0 || height == 0 { +func (f *FFMpeg) hwMaxResFilter(toCodec VideoCodec, vf *models.VideoFile, reqHeight int, fullhw bool) VideoFilter { + if vf.Width == 0 || vf.Height == 0 { return "" } videoFilter := f.hwFilterInit(toCodec, fullhw) - maxWidth, maxHeight := f.hwCodecMaxRes(toCodec, width, height) - videoFilter = videoFilter.ScaleMaxLM(width, height, reqHeight, maxWidth, maxHeight) - return f.hwCodecFilter(videoFilter, toCodec, fullhw) + maxWidth, maxHeight := f.hwCodecMaxRes(toCodec) + videoFilter = videoFilter.ScaleMaxLM(vf.Width, vf.Height, reqHeight, maxWidth, maxHeight) + return f.hwCodecFilter(videoFilter, toCodec, vf, fullhw) } // Return if a hardware accelerated for HLS is available @@ -267,7 +347,8 @@ func (f *FFMpeg) hwCodecHLSCompatible() *VideoCodec { case VideoCodecN264, VideoCodecI264, VideoCodecV264, - VideoCodecR264: + VideoCodecR264, + VideoCodecM264: // Note that the Apple encoder sucks at startup, thus HLS quality is crap return &element } } @@ -279,7 +360,8 @@ func (f *FFMpeg) hwCodecMP4Compatible() *VideoCodec { for _, element := range f.hwCodecSupport { switch element { case VideoCodecN264, - VideoCodecI264: + VideoCodecI264, + VideoCodecM264: return &element } } diff --git a/pkg/ffmpeg/ffmpeg.go b/pkg/ffmpeg/ffmpeg.go index e929cc7f8..5ee98a873 100644 --- a/pkg/ffmpeg/ffmpeg.go +++ b/pkg/ffmpeg/ffmpeg.go @@ -195,6 +195,20 @@ type FFMpegVersion struct { patch int } +// Gteq returns true if the version is greater than or equal to the other version. +func (v FFMpegVersion) Gteq(other FFMpegVersion) bool { + if v.major > other.major { + return true + } + if v.major == other.major && v.minor > other.minor { + return true + } + if v.major == other.major && v.minor == other.minor && v.patch >= other.patch { + return true + } + return false +} + // FFMpeg provides an interface to ffmpeg. type FFMpeg struct { ffmpeg string diff --git a/pkg/ffmpeg/ffmpeg_test.go b/pkg/ffmpeg/ffmpeg_test.go new file mode 100644 index 000000000..3e9151ed9 --- /dev/null +++ b/pkg/ffmpeg/ffmpeg_test.go @@ -0,0 +1,75 @@ +// Package ffmpeg provides a wrapper around the ffmpeg and ffprobe executables. +package ffmpeg + +import "testing" + +func TestFFMpegVersion_GreaterThan(t *testing.T) { + tests := []struct { + name string + this FFMpegVersion + other FFMpegVersion + want bool + }{ + { + "major greater, minor equal, patch equal", + FFMpegVersion{2, 0, 0}, + FFMpegVersion{1, 0, 0}, + true, + }, + { + "major greater, minor less, patch less", + FFMpegVersion{2, 1, 1}, + FFMpegVersion{1, 0, 0}, + true, + }, + { + "major equal, minor greater, patch equal", + FFMpegVersion{1, 1, 0}, + FFMpegVersion{1, 0, 0}, + true, + }, + { + "major equal, minor equal, patch greater", + FFMpegVersion{1, 0, 1}, + FFMpegVersion{1, 0, 0}, + true, + }, + { + "major equal, minor equal, patch equal", + FFMpegVersion{1, 0, 0}, + FFMpegVersion{1, 0, 0}, + true, + }, + { + "major less, minor equal, patch equal", + FFMpegVersion{1, 0, 0}, + FFMpegVersion{2, 0, 0}, + false, + }, + { + "major equal, minor less, patch equal", + FFMpegVersion{1, 0, 0}, + FFMpegVersion{1, 1, 0}, + false, + }, + { + "major equal, minor equal, patch less", + FFMpegVersion{1, 0, 0}, + FFMpegVersion{1, 0, 1}, + false, + }, + { + "major less, minor less, patch less", + FFMpegVersion{1, 0, 0}, + FFMpegVersion{2, 1, 1}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.this.Gteq(tt.other); got != tt.want { + t.Errorf("FFMpegVersion.GreaterThan() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/ffmpeg/filter.go b/pkg/ffmpeg/filter.go index 52be57c9c..dd6ecc106 100644 --- a/pkg/ffmpeg/filter.go +++ b/pkg/ffmpeg/filter.go @@ -59,33 +59,28 @@ func (f VideoFilter) ScaleMax(inputWidth, inputHeight, maxSize int) VideoFilter return f.ScaleDimensions(maxSize, -2) } -// ScaleMaxLM returns a VideoFilter scaling to maxSize with respect to a max size. +// ScaleMaxLM scales an image to fit within specified maximum dimensions while maintaining its aspect ratio. func (f VideoFilter) ScaleMaxLM(width int, height int, reqHeight int, maxWidth int, maxHeight int) VideoFilter { - // calculate the aspect ratio of the current resolution - aspectRatio := width / height + if maxWidth == 0 || maxHeight == 0 { + return f.ScaleMax(width, height, reqHeight) + } - // find the max height + aspectRatio := float64(width) / float64(height) desiredHeight := reqHeight if desiredHeight == 0 { desiredHeight = height } + desiredWidth := int(float64(desiredHeight) * aspectRatio) - // calculate the desired width based on the desired height and the aspect ratio - desiredWidth := int(desiredHeight * aspectRatio) - - // check which dimension to scale based on the maximum resolution - if desiredHeight > maxHeight || desiredWidth > maxWidth { - if desiredHeight-maxHeight > desiredWidth-maxWidth { - // scale the height down to the maximum height - return f.ScaleDimensions(-2, maxHeight) - } else { - // scale the width down to the maximum width - return f.ScaleDimensions(maxWidth, -2) - } + if desiredHeight <= maxHeight && desiredWidth <= maxWidth { + return f.ScaleMax(width, height, reqHeight) } - // the current resolution can be scaled to the desired height without exceeding the maximum resolution - return f.ScaleMax(width, height, reqHeight) + if float64(desiredHeight-maxHeight) > float64(desiredWidth-maxWidth) { + return f.ScaleDimensions(-2, maxHeight) + } else { + return f.ScaleDimensions(maxWidth, -2) + } } // Fps returns a VideoFilter setting the frames per second. diff --git a/pkg/ffmpeg/stream_segmented.go b/pkg/ffmpeg/stream_segmented.go index 1058fb8eb..56ef392f1 100644 --- a/pkg/ffmpeg/stream_segmented.go +++ b/pkg/ffmpeg/stream_segmented.go @@ -342,7 +342,7 @@ func (s *runningStream) makeStreamArgs(sm *StreamManager, segment int) Args { videoOnly := ProbeAudioCodec(s.vf.AudioCodec) == MissingUnsupported - videoFilter := sm.encoder.hwMaxResFilter(codec, s.vf.Width, s.vf.Height, s.maxTranscodeSize, fullhw) + videoFilter := sm.encoder.hwMaxResFilter(codec, s.vf, s.maxTranscodeSize, fullhw) args = append(args, s.streamType.Args(codec, segment, videoFilter, videoOnly, s.outputDir)...) diff --git a/pkg/ffmpeg/stream_transcode.go b/pkg/ffmpeg/stream_transcode.go index ce56e0795..714652470 100644 --- a/pkg/ffmpeg/stream_transcode.go +++ b/pkg/ffmpeg/stream_transcode.go @@ -60,7 +60,7 @@ func CodecInit(codec VideoCodec) (args Args) { ) case VideoCodecM264: args = append(args, - "-prio_speed", "1", + "-realtime", "1", ) case VideoCodecO264: args = append(args, @@ -198,7 +198,7 @@ func (o TranscodeOptions) makeStreamArgs(sm *StreamManager) Args { videoOnly := ProbeAudioCodec(o.VideoFile.AudioCodec) == MissingUnsupported - videoFilter := sm.encoder.hwMaxResFilter(codec, o.VideoFile.Width, o.VideoFile.Height, maxTranscodeSize, fullhw) + videoFilter := sm.encoder.hwMaxResFilter(codec, o.VideoFile, maxTranscodeSize, fullhw) args = append(args, o.StreamType.Args(codec, videoFilter, videoOnly)...) From 1f5377da1ccb95c2e341abfaa48f13cd07d28b3c Mon Sep 17 00:00:00 2001 From: CJ <72030708+cj12312021@users.noreply.github.com> Date: Sun, 23 Jun 2024 22:39:32 -0500 Subject: [PATCH 12/28] Added path column to tables in list view (#5005) --- .../components/Galleries/GalleryListTable.tsx | 15 ++++++++ ui/v2.5/src/components/List/styles.scss | 35 ++++++++++++++++++- .../src/components/Scenes/SceneListTable.tsx | 17 ++++++++- 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryListTable.tsx b/ui/v2.5/src/components/Galleries/GalleryListTable.tsx index 7e6a56188..68f926587 100644 --- a/ui/v2.5/src/components/Galleries/GalleryListTable.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryListTable.tsx @@ -134,6 +134,16 @@ export const GalleryListTable: React.FC = (
); + const PathCell = (scene: GQL.SlimGalleryDataFragment) => ( +
    + {scene.files.map((file) => ( +
  • + {file.path} +
  • + ))} +
+ ); + interface IColumnSpec { value: string; label: string; @@ -211,6 +221,11 @@ export const GalleryListTable: React.FC = ( label: intl.formatMessage({ id: "photographer" }), render: (s) => <>{s.photographer}, }, + { + value: "path", + label: intl.formatMessage({ id: "path" }), + render: PathCell, + }, ]; const defaultColumns = allColumns diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 5b1e3b845..eeaa8527a 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -444,13 +444,42 @@ input[type="range"].zoom-slider { } } + .newline-list { + list-style: none; + margin: 0; + padding: 4px 2px; + + li { + display: inline; + white-space: pre-wrap; + } + + li::after { + content: "\A"; + } + + li:last-child::after { + content: ""; + } + } + + .newline-list.overflowable, .comma-list.overflowable { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + + .comma-list.overflowable { width: 190px; } + .newline-list.overflowable { + -webkit-line-clamp: 1; + width: 700px; + } + + .newline-list.overflowable:hover, .comma-list.overflowable:hover { background: #28343c; border: 1px solid #414c53; @@ -459,7 +488,6 @@ input[type="range"].zoom-slider { height: auto; margin-left: -0.4rem; margin-top: -0.9rem; - max-width: 40rem; overflow: hidden; padding: 0.1rem 0.5rem; position: absolute; @@ -469,6 +497,11 @@ input[type="range"].zoom-slider { z-index: 100; } + .comma-list.overflowable:hover { + max-width: 40rem; + } + + .newline-list.overflowable li .ellips-data:hover, .comma-list.overflowable li .ellips-data:hover { max-width: fit-content; } diff --git a/ui/v2.5/src/components/Scenes/SceneListTable.tsx b/ui/v2.5/src/components/Scenes/SceneListTable.tsx index 33581baa4..0365e7687 100644 --- a/ui/v2.5/src/components/Scenes/SceneListTable.tsx +++ b/ui/v2.5/src/components/Scenes/SceneListTable.tsx @@ -226,7 +226,7 @@ export const SceneListTable: React.FC = ( ); const AudioCodecCell = (scene: GQL.SlimSceneDataFragment) => ( -
    +
      {scene.files.map((file) => (
    • {file.audio_codec} @@ -245,6 +245,16 @@ export const SceneListTable: React.FC = (
    ); + const PathCell = (scene: GQL.SlimSceneDataFragment) => ( +
      + {scene.files.map((file) => ( +
    • + {file.path} +
    • + ))} +
    + ); + interface IColumnSpec { value: string; label: string; @@ -343,6 +353,11 @@ export const SceneListTable: React.FC = ( label: intl.formatMessage({ id: "resolution" }), render: ResolutionCell, }, + { + value: "path", + label: intl.formatMessage({ id: "path" }), + render: PathCell, + }, { value: "filesize", label: intl.formatMessage({ id: "filesize" }), From 593207866ffc74fe0a3db59a4bdfcfea71105d0b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 24 Jun 2024 16:02:18 +1000 Subject: [PATCH 13/28] Adjust 64 post-migrate where logic I think not including the scene_id meant that a date could be corrected earlier, meaning the rows affected would be 0. Adding scene_id means that each row should be migrated one by one. --- pkg/sqlite/migrations/64_postmigrate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/sqlite/migrations/64_postmigrate.go b/pkg/sqlite/migrations/64_postmigrate.go index ecf291050..5b0f31a25 100644 --- a/pkg/sqlite/migrations/64_postmigrate.go +++ b/pkg/sqlite/migrations/64_postmigrate.go @@ -64,7 +64,7 @@ func (m *schema64Migrator) migrate(ctx context.Context) error { // convert the timestamp to the correct format logger.Debugf("correcting view date %q to UTC date %q for scene %d", viewDate.Timestamp, viewDate.Timestamp.UTC(), id) - r, err := m.db.Exec("UPDATE scenes_view_dates SET view_date = ? WHERE view_date = ? OR view_date = ?", utcTimestamp, viewDate.Timestamp, viewDate) + r, err := m.db.Exec("UPDATE scenes_view_dates SET view_date = ? WHERE scene_id = ? AND (view_date = ? OR view_date = ?)", utcTimestamp, id, viewDate.Timestamp, viewDate) if err != nil { return fmt.Errorf("error correcting view date %s to %s: %w", viewDate.Timestamp, viewDate, err) } From 3156191b837c046717d2a91967fd914d846877d6 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 24 Jun 2024 16:02:46 +1000 Subject: [PATCH 14/28] Fix scene marker query (#5014) --- pkg/sqlite/scene_marker.go | 1 + pkg/sqlite/scene_marker_test.go | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index 158916a82..87a849d20 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -301,6 +301,7 @@ func (qb *SceneMarkerStore) makeQuery(ctx context.Context, sceneMarkerFilter *mo distinctIDs(&query, sceneMarkerTable) if q := findFilter.Q; q != nil && *q != "" { + query.join(sceneTable, "", "scenes.id = scene_markers.scene_id") query.join(tagTable, "", "scene_markers.primary_tag_id = tags.id") searchColumns := []string{"scene_markers.title", "scenes.title", "tags.name"} query.parseQueryString(searchColumns, *q) diff --git a/pkg/sqlite/scene_marker_test.go b/pkg/sqlite/scene_marker_test.go index fffd0b88f..0a8343a8b 100644 --- a/pkg/sqlite/scene_marker_test.go +++ b/pkg/sqlite/scene_marker_test.go @@ -74,6 +74,27 @@ func TestMarkerCountByTagID(t *testing.T) { }) } +func TestMarkerQueryQ(t *testing.T) { + withTxn(func(ctx context.Context) error { + q := getSceneTitle(sceneIdxWithMarkers) + m, _, err := db.SceneMarker.Query(ctx, nil, &models.FindFilterType{ + Q: &q, + }) + + if err != nil { + t.Errorf("Error querying scene markers: %s", err.Error()) + } + + if !assert.Greater(t, len(m), 0) { + return nil + } + + assert.Equal(t, sceneIDs[sceneIdxWithMarkers], m[0].SceneID) + + return nil + }) +} + func TestMarkerQuerySortBySceneUpdated(t *testing.T) { withTxn(func(ctx context.Context) error { sort := "scenes_updated_at" From d986a9eb4f600315eb743cb10255c11cffbf3675 Mon Sep 17 00:00:00 2001 From: CJ <72030708+cj12312021@users.noreply.github.com> Date: Mon, 24 Jun 2024 01:03:29 -0500 Subject: [PATCH 15/28] Address resize loop (#5004) --- .../src/components/Shared/GridCard/GridCard.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx b/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx index 911064a94..1d1a37528 100644 --- a/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx +++ b/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx @@ -42,9 +42,9 @@ interface IDimension { height: number; } -export const useContainerDimensions = < - T extends HTMLElement = HTMLDivElement ->(): [MutableRefObject, IDimension] => { +export const useContainerDimensions = ( + sensitivityThreshold = 20 +): [MutableRefObject, IDimension] => { const target = useRef(null); const [dimension, setDimension] = useState({ width: 0, @@ -53,7 +53,14 @@ export const useContainerDimensions = < useResizeObserver(target, (entry) => { const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0]; - setDimension({ width, height }); + let difference = Math.abs(dimension.width - width); + // Only adjust when width changed by a significant margin. This addresses the cornercase that sees + // the dimensions toggle back and forward when the window is adjusted perfectly such that overflow + // is trigger then immediable disabled because of a resize event then continues this loop endlessly. + // the scrollbar size varies between platforms. Windows is apparently around 17 pixels. + if (difference > sensitivityThreshold) { + setDimension({ width, height }); + } }); return [target, dimension]; From af6841be49279746481af45c216438c5222007c5 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 26 Jun 2024 11:39:31 +1000 Subject: [PATCH 16/28] Rename Movie to Group in UI (#4963) * Replace movies with groups in the UI * Massage menu items * Change view names * Rename Movie components to Group * Refactor movie to group variable names * Rename movie class names to group --- ui/v2.5/src/App.tsx | 4 +- ui/v2.5/src/components/FrontPage/Control.tsx | 4 +- .../components/FrontPage/FrontPageConfig.tsx | 2 +- ui/v2.5/src/components/FrontPage/styles.scss | 4 +- .../src/components/List/EditFilterDialog.tsx | 2 +- .../List/Filters/LabeledIdFilter.tsx | 2 +- ui/v2.5/src/components/List/views.ts | 8 +- ui/v2.5/src/components/MainNavbar.tsx | 48 +++++---- .../components/Movies/EditMoviesDialog.tsx | 6 +- ui/v2.5/src/components/Movies/MovieCard.tsx | 48 ++++----- .../src/components/Movies/MovieCardGrid.tsx | 16 +-- .../components/Movies/MovieDetails/Movie.tsx | 100 +++++++++--------- .../Movies/MovieDetails/MovieCreate.tsx | 24 ++--- .../Movies/MovieDetails/MovieDetailsPanel.tsx | 42 ++++---- .../Movies/MovieDetails/MovieEditPanel.tsx | 80 +++++++------- .../Movies/MovieDetails/MovieScenesPanel.tsx | 16 +-- .../Movies/MovieDetails/MovieScrapeDialog.tsx | 50 ++++----- ui/v2.5/src/components/Movies/MovieList.tsx | 38 +++---- .../Movies/MovieRecommendationRow.tsx | 12 +-- ui/v2.5/src/components/Movies/MovieSelect.tsx | 62 +++++------ ui/v2.5/src/components/Movies/Movies.tsx | 22 ++-- ui/v2.5/src/components/Movies/styles.scss | 26 ++--- .../components/Performers/PerformerCard.tsx | 10 +- .../Performers/PerformerDetails/Performer.tsx | 16 +-- .../PerformerDetails/PerformerMoviesPanel.tsx | 8 +- .../SceneDuplicateChecker.tsx | 16 +-- .../components/Scenes/EditScenesDialog.tsx | 50 ++++----- ui/v2.5/src/components/Scenes/SceneCard.tsx | 26 ++--- .../components/Scenes/SceneDetails/Scene.tsx | 10 +- .../Scenes/SceneDetails/SceneEditPanel.tsx | 30 +++--- .../Scenes/SceneDetails/SceneMoviePanel.tsx | 20 ++-- .../Scenes/SceneDetails/SceneMovieTable.tsx | 54 +++++----- .../Scenes/SceneDetails/SceneScrapeDialog.tsx | 44 ++++---- .../src/components/Scenes/SceneListTable.tsx | 16 +-- .../components/Scenes/SceneMergeDialog.tsx | 28 ++--- ui/v2.5/src/components/Scenes/styles.scss | 14 +-- .../SettingsInterfacePanel.tsx | 30 ++++-- .../Settings/SettingsScrapingPanel.tsx | 18 ++-- ui/v2.5/src/components/Shared/Link.tsx | 6 +- ui/v2.5/src/components/Shared/MultiSet.tsx | 2 +- .../components/Shared/PopoverCountButton.tsx | 10 +- .../Shared/ScrapeDialog/ScrapedObjectsRow.tsx | 14 +-- .../Shared/ScrapeDialog/createObjects.ts | 16 +-- ui/v2.5/src/components/Shared/Select.tsx | 12 +-- ui/v2.5/src/components/Shared/TagLink.tsx | 20 ++-- ui/v2.5/src/components/Stats.tsx | 2 +- ui/v2.5/src/components/Studios/StudioCard.tsx | 10 +- .../Studios/StudioDetails/Studio.tsx | 20 ++-- .../StudioDetails/StudioMoviesPanel.tsx | 10 +- ui/v2.5/src/components/Tags/TagCard.tsx | 10 +- .../src/components/Tags/TagDetails/Tag.tsx | 20 ++-- .../Tags/TagDetails/TagMoviesPanel.tsx | 6 +- ui/v2.5/src/core/config.ts | 6 +- ui/v2.5/src/core/movies.ts | 2 +- ui/v2.5/src/index.scss | 16 +-- ui/v2.5/src/locales/en-GB.json | 10 +- .../models/list-filter/criteria/criterion.ts | 2 +- .../src/models/list-filter/criteria/movies.ts | 4 +- ui/v2.5/src/models/list-filter/scenes.ts | 5 +- ui/v2.5/src/models/list-filter/tags.ts | 4 +- ui/v2.5/src/pluginApi.d.ts | 6 +- ui/v2.5/src/utils/bulkUpdate.ts | 4 +- ui/v2.5/src/utils/navigation.ts | 32 +++--- 63 files changed, 643 insertions(+), 612 deletions(-) diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index b3ff5e10f..7aba652cd 100644 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -66,7 +66,7 @@ const Galleries = lazyComponent( () => import("./components/Galleries/Galleries") ); -const Movies = lazyComponent(() => import("./components/Movies/Movies")); +const Groups = lazyComponent(() => import("./components/Movies/Movies")); const Tags = lazyComponent(() => import("./components/Tags/Tags")); const Images = lazyComponent(() => import("./components/Images/Images")); const Setup = lazyComponent(() => import("./components/Setup/Setup")); @@ -312,7 +312,7 @@ export const App: React.FC = () => { - + = ({ mode, filter, header }) => { ); case GQL.FilterMode.Movies: return ( - = ({ const FilterModeToConfigKey = { [FilterMode.Galleries]: "galleries", [FilterMode.Images]: "images", - [FilterMode.Movies]: "movies", + [FilterMode.Movies]: "groups", [FilterMode.Performers]: "performers", [FilterMode.SceneMarkers]: "sceneMarkers", [FilterMode.Scenes]: "scenes", diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx index 78f271c0f..792c4a7e7 100644 --- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -24,7 +24,7 @@ export const LabeledIdFilter: React.FC = ({ inputType !== "performer_tags" && inputType !== "tags" && inputType !== "scenes" && - inputType !== "movies" && + inputType !== "groups" && inputType !== "galleries" ) { return null; diff --git a/ui/v2.5/src/components/List/views.ts b/ui/v2.5/src/components/List/views.ts index 7e8880f9d..2b4179014 100644 --- a/ui/v2.5/src/components/List/views.ts +++ b/ui/v2.5/src/components/List/views.ts @@ -2,7 +2,7 @@ export enum View { Galleries = "galleries", Images = "images", Scenes = "scenes", - Movies = "movies", + Groups = "groups", Performers = "performers", Tags = "tags", SceneMarkers = "scene_markers", @@ -17,7 +17,7 @@ export enum View { PerformerScenes = "performer_scenes", PerformerGalleries = "performer_galleries", PerformerImages = "performer_images", - PerformerMovies = "performer_movies", + PerformerGroups = "performer_groups", PerformerAppearsWith = "performer_appears_with", StudioGalleries = "studio_galleries", @@ -26,9 +26,9 @@ export enum View { GalleryImages = "gallery_images", StudioScenes = "studio_scenes", - StudioMovies = "studio_movies", + StudioGroups = "studio_groups", StudioPerformers = "studio_performers", StudioChildren = "studio_children", - MovieScenes = "movie_scenes", + GroupScenes = "group_scenes", } diff --git a/ui/v2.5/src/components/MainNavbar.tsx b/ui/v2.5/src/components/MainNavbar.tsx index 89e3563e8..98bbc26c6 100644 --- a/ui/v2.5/src/components/MainNavbar.tsx +++ b/ui/v2.5/src/components/MainNavbar.tsx @@ -1,4 +1,10 @@ -import React, { useEffect, useRef, useState, useCallback } from "react"; +import React, { + useEffect, + useRef, + useState, + useCallback, + useMemo, +} from "react"; import { defineMessages, FormattedMessage, @@ -52,9 +58,9 @@ const messages = defineMessages({ id: "images", defaultMessage: "Images", }, - movies: { - id: "movies", - defaultMessage: "Movies", + groups: { + id: "groups", + defaultMessage: "Groups", }, markers: { id: "markers", @@ -107,9 +113,9 @@ const allMenuItems: IMenuItem[] = [ hotkey: "g i", }, { - name: "movies", - message: messages.movies, - href: "/movies", + name: "groups", + message: messages.groups, + href: "/groups", icon: faFilm, hotkey: "g v", userCreatable: true, @@ -179,20 +185,26 @@ export const MainNavbar: React.FC = () => { const { configuration, loading } = React.useContext(ConfigurationContext); const { openManual } = React.useContext(ManualStateContext); - // Show all menu items by default, unless config says otherwise - const [menuItems, setMenuItems] = useState(allMenuItems); - const [expanded, setExpanded] = useState(false); - useEffect(() => { - const iCfg = configuration?.interface; - if (iCfg?.menuItems) { - setMenuItems( - allMenuItems.filter((menuItem) => - iCfg.menuItems!.includes(menuItem.name) - ) - ); + // Show all menu items by default, unless config says otherwise + const menuItems = useMemo(() => { + let cfgMenuItems = configuration?.interface.menuItems; + if (!cfgMenuItems) { + return allMenuItems; } + + // translate old movies menu item to groups + cfgMenuItems = cfgMenuItems.map((item) => { + if (item === "movies") { + return "groups"; + } + return item; + }); + + return allMenuItems.filter((menuItem) => + cfgMenuItems!.includes(menuItem.name) + ); }, [configuration]); // react-bootstrap typing bug diff --git a/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx b/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx index af48cbeaf..800bad044 100644 --- a/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx +++ b/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx @@ -24,7 +24,7 @@ interface IListOperationProps { onClose: (applied: boolean) => void; } -export const EditMoviesDialog: React.FC = ( +export const EditGroupsDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); @@ -69,7 +69,7 @@ export const EditMoviesDialog: React.FC = ( intl.formatMessage( { id: "toast.updated_entity" }, { - entity: intl.formatMessage({ id: "movies" }).toLocaleLowerCase(), + entity: intl.formatMessage({ id: "groups" }).toLocaleLowerCase(), } ) ); @@ -126,7 +126,7 @@ export const EditMoviesDialog: React.FC = ( icon={faPencilAlt} header={intl.formatMessage( { id: "actions.edit_entity" }, - { entityType: intl.formatMessage({ id: "movies" }) } + { entityType: intl.formatMessage({ id: "groups" }) } )} accept={{ onClick: onSave, diff --git a/ui/v2.5/src/components/Movies/MovieCard.tsx b/ui/v2.5/src/components/Movies/MovieCard.tsx index 1f763649e..739761251 100644 --- a/ui/v2.5/src/components/Movies/MovieCard.tsx +++ b/ui/v2.5/src/components/Movies/MovieCard.tsx @@ -12,7 +12,7 @@ import { faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons"; import ScreenUtils from "src/utils/screen"; interface IProps { - movie: GQL.MovieDataFragment; + group: GQL.MovieDataFragment; containerWidth?: number; sceneIndex?: number; selecting?: boolean; @@ -20,8 +20,8 @@ interface IProps { onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; } -export const MovieCard: React.FC = ({ - movie, +export const GroupCard: React.FC = ({ + group, sceneIndex, containerWidth, selecting, @@ -47,7 +47,7 @@ export const MovieCard: React.FC = ({ return ( <>
    - + #{sceneIndex} @@ -55,9 +55,9 @@ export const MovieCard: React.FC = ({ } function maybeRenderScenesPopoverButton() { - if (movie.scenes.length === 0) return; + if (group.scenes.length === 0) return; - const popoverContent = movie.scenes.map((scene) => ( + const popoverContent = group.scenes.map((scene) => ( )); @@ -69,31 +69,31 @@ export const MovieCard: React.FC = ({ > ); } function maybeRenderTagPopoverButton() { - if (movie.tags.length <= 0) return; + if (group.tags.length <= 0) return; - const popoverContent = movie.tags.map((tag) => ( - + const popoverContent = group.tags.map((tag) => ( + )); return ( ); } function maybeRenderPopoverButtonGroup() { - if (sceneIndex || movie.scenes.length > 0 || movie.tags.length > 0) { + if (sceneIndex || group.scenes.length > 0 || group.tags.length > 0) { return ( <> {maybeRenderSceneNumber()} @@ -109,28 +109,28 @@ export const MovieCard: React.FC = ({ return ( {movie.name - + } details={ -
    - {movie.date} +
    + {group.date}
    diff --git a/ui/v2.5/src/components/Movies/MovieCardGrid.tsx b/ui/v2.5/src/components/Movies/MovieCardGrid.tsx index 4475a0c8a..52cbc0f53 100644 --- a/ui/v2.5/src/components/Movies/MovieCardGrid.tsx +++ b/ui/v2.5/src/components/Movies/MovieCardGrid.tsx @@ -1,27 +1,27 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { MovieCard } from "./MovieCard"; +import { GroupCard } from "./MovieCard"; import { useContainerDimensions } from "../Shared/GridCard/GridCard"; -interface IMovieCardGrid { - movies: GQL.MovieDataFragment[]; +interface IGroupCardGrid { + groups: GQL.MovieDataFragment[]; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; } -export const MovieCardGrid: React.FC = ({ - movies, +export const GroupCardGrid: React.FC = ({ + groups, selectedIds, onSelectChange, }) => { const [componentRef, { width }] = useContainerDimensions(); return (
    - {movies.map((p) => ( - ( + 0} selected={selectedIds.has(p.id)} onSelectedChanged={(selected: boolean, shiftKey: boolean) => diff --git a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx index 69aecd20d..686a92b39 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx @@ -17,12 +17,12 @@ import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useLightbox } from "src/hooks/Lightbox/hooks"; import { ModalComponent } from "src/components/Shared/Modal"; import { useToast } from "src/hooks/Toast"; -import { MovieScenesPanel } from "./MovieScenesPanel"; +import { GroupScenesPanel } from "./MovieScenesPanel"; import { CompressedMovieDetailsPanel, - MovieDetailsPanel, + GroupDetailsPanel, } from "./MovieDetailsPanel"; -import { MovieEditPanel } from "./MovieEditPanel"; +import { GroupEditPanel } from "./MovieEditPanel"; import { faChevronDown, faChevronUp, @@ -38,14 +38,14 @@ import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { ExternalLinksButton } from "src/components/Shared/ExternalLinksButton"; interface IProps { - movie: GQL.MovieDataFragment; + group: GQL.MovieDataFragment; } -interface IMovieParams { +interface IGroupParams { id: string; } -const MoviePage: React.FC = ({ movie }) => { +const GroupPage: React.FC = ({ group }) => { const intl = useIntl(); const history = useHistory(); const Toast = useToast(); @@ -70,35 +70,35 @@ const MoviePage: React.FC = ({ movie }) => { const [encodingImage, setEncodingImage] = useState(false); const defaultImage = - movie.front_image_path && movie.front_image_path.includes("default=true") + group.front_image_path && group.front_image_path.includes("default=true") ? true : false; const lightboxImages = useMemo(() => { const covers = [ - ...(movie.front_image_path && !defaultImage + ...(group.front_image_path && !defaultImage ? [ { paths: { - thumbnail: movie.front_image_path, - image: movie.front_image_path, + thumbnail: group.front_image_path, + image: group.front_image_path, }, }, ] : []), - ...(movie.back_image_path + ...(group.back_image_path ? [ { paths: { - thumbnail: movie.back_image_path, - image: movie.back_image_path, + thumbnail: group.back_image_path, + image: group.back_image_path, }, }, ] : []), ]; return covers; - }, [movie.front_image_path, movie.back_image_path, defaultImage]); + }, [group.front_image_path, group.back_image_path, defaultImage]); const index = lightboxImages.length; @@ -108,7 +108,7 @@ const MoviePage: React.FC = ({ movie }) => { const [updateMovie, { loading: updating }] = useMovieUpdate(); const [deleteMovie, { loading: deleting }] = useMovieDestroy({ - id: movie.id, + id: group.id, }); // set up hotkeys @@ -135,7 +135,7 @@ const MoviePage: React.FC = ({ movie }) => { await updateMovie({ variables: { input: { - id: movie.id, + id: group.id, ...input, }, }, @@ -144,7 +144,7 @@ const MoviePage: React.FC = ({ movie }) => { Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, - { entity: intl.formatMessage({ id: "movie" }).toLocaleLowerCase() } + { entity: intl.formatMessage({ id: "group" }).toLocaleLowerCase() } ) ); } @@ -157,7 +157,7 @@ const MoviePage: React.FC = ({ movie }) => { } // redirect to movies page - history.push(`/movies`); + history.push(`/groups`); } function toggleEditing(value?: boolean) { @@ -187,8 +187,8 @@ const MoviePage: React.FC = ({ movie }) => { id="dialogs.delete_confirm" values={{ entityName: - movie.name ?? - intl.formatMessage({ id: "movie" }).toLocaleLowerCase(), + group.name ?? + intl.formatMessage({ id: "group" }).toLocaleLowerCase(), }} />

    @@ -216,7 +216,7 @@ const MoviePage: React.FC = ({ movie }) => { } function renderFrontImage() { - let image = movie.front_image_path; + let image = group.front_image_path; if (isEditing) { if (frontImage === null && image) { const imageURL = new URL(image); @@ -229,14 +229,14 @@ const MoviePage: React.FC = ({ movie }) => { if (image && defaultImage) { return ( -
    +
    ); } else if (image) { return (
    @@ -384,9 +384,9 @@ const MoviePage: React.FC = ({ movie }) => { }); return ( -
    +
    - {movie?.name} + {group?.name}
    @@ -399,7 +399,7 @@ const MoviePage: React.FC = ({ movie }) => { message={intl.formatMessage({ id: "actions.encoding_image" })} /> ) : ( -
    +
    {renderFrontImage()} {renderBackImage()}
    @@ -407,15 +407,15 @@ const MoviePage: React.FC = ({ movie }) => {
    -
    +

    - {movie.name} + {group.name} {maybeRenderShowCollapseButton()} {renderClickableIcons()}

    {maybeRenderAliases()} setRating(value)} clickToRate withoutContext @@ -428,8 +428,8 @@ const MoviePage: React.FC = ({ movie }) => {
    {maybeRenderCompressedDetails()}
    -
    -
    {maybeRenderTab()}
    +
    +
    {maybeRenderTab()}
    {renderDeleteAlert()} @@ -437,7 +437,7 @@ const MoviePage: React.FC = ({ movie }) => { ); }; -const MovieLoader: React.FC> = ({ +const GroupLoader: React.FC> = ({ match, }) => { const { id } = match.params; @@ -450,7 +450,7 @@ const MovieLoader: React.FC> = ({ if (!data?.findMovie) return ; - return ; + return ; }; -export default MovieLoader; +export default GroupLoader; diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx index a7ab492b9..2f65463c9 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx @@ -5,16 +5,16 @@ import { useHistory, useLocation } from "react-router-dom"; import { useIntl } from "react-intl"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useToast } from "src/hooks/Toast"; -import { MovieEditPanel } from "./MovieEditPanel"; +import { GroupEditPanel } from "./MovieEditPanel"; -const MovieCreate: React.FC = () => { +const GroupCreate: React.FC = () => { const history = useHistory(); const intl = useIntl(); const Toast = useToast(); const location = useLocation(); const query = useMemo(() => new URLSearchParams(location.search), [location]); - const movie = { + const group = { name: query.get("q") ?? undefined, }; @@ -30,7 +30,7 @@ const MovieCreate: React.FC = () => { variables: { input }, }); if (result.data?.movieCreate?.id) { - history.push(`/movies/${result.data.movieCreate.id}`); + history.push(`/groups/${result.data.movieCreate.id}`); Toast.success( intl.formatMessage( { id: "toast.created_entity" }, @@ -43,7 +43,7 @@ const MovieCreate: React.FC = () => { function renderFrontImage() { if (frontImage) { return ( -
    +
    Front Cover
    ); @@ -53,7 +53,7 @@ const MovieCreate: React.FC = () => { function renderBackImage() { if (backImage) { return ( -
    +
    Back Cover
    ); @@ -63,24 +63,24 @@ const MovieCreate: React.FC = () => { // TODO: CSS class return (
    -
    +
    {encodingImage ? ( ) : ( -
    +
    {renderFrontImage()} {renderBackImage()}
    )}
    - history.push("/movies")} + onCancel={() => history.push("/groups")} onDelete={() => {}} setFrontImage={setFrontImage} setBackImage={setBackImage} @@ -91,4 +91,4 @@ const MovieCreate: React.FC = () => { ); }; -export default MovieCreate; +export default GroupCreate; diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx index 7c5a9cf3a..8f911c08c 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx @@ -7,14 +7,14 @@ import { Link } from "react-router-dom"; import { DirectorLink } from "src/components/Shared/Link"; import { TagLink } from "src/components/Shared/TagLink"; -interface IMovieDetailsPanel { - movie: GQL.MovieDataFragment; +interface IGroupDetailsPanel { + group: GQL.MovieDataFragment; collapsed?: boolean; fullWidth?: boolean; } -export const MovieDetailsPanel: React.FC = ({ - movie, +export const GroupDetailsPanel: React.FC = ({ + group, collapsed, fullWidth, }) => { @@ -22,13 +22,13 @@ export const MovieDetailsPanel: React.FC = ({ const intl = useIntl(); function renderTagsField() { - if (!movie.tags.length) { + if (!group.tags.length) { return; } return (
      - {(movie.tags ?? []).map((tag) => ( - + {(group.tags ?? []).map((tag) => ( + ))}
    ); @@ -40,7 +40,7 @@ export const MovieDetailsPanel: React.FC = ({ <> = ({ - {movie.studio?.name} + group.studio?.id ? ( + + {group.studio?.name} ) : ( "" @@ -84,8 +84,8 @@ export const MovieDetailsPanel: React.FC = ({ + group.director ? ( + ) : ( "" ) @@ -97,8 +97,8 @@ export const MovieDetailsPanel: React.FC = ({ ); }; -export const CompressedMovieDetailsPanel: React.FC = ({ - movie, +export const CompressedMovieDetailsPanel: React.FC = ({ + group, }) => { function scrollToTop() { window.scrollTo({ top: 0, behavior: "smooth" }); @@ -107,13 +107,13 @@ export const CompressedMovieDetailsPanel: React.FC = ({ return (
    - scrollToTop()}> - {movie.name} + scrollToTop()}> + {group.name} - {movie?.studio?.name ? ( + {group?.studio?.name ? ( <> / - {movie?.studio?.name} + {group?.studio?.name} ) : ( "" diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx index 5cd4cda79..0a281df51 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx @@ -15,7 +15,7 @@ import TextUtils from "src/utils/text"; import ImageUtils from "src/utils/image"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; -import { MovieScrapeDialog } from "./MovieScrapeDialog"; +import { GroupScrapeDialog } from "./MovieScrapeDialog"; import isEqual from "lodash-es/isEqual"; import { handleUnsavedChanges } from "src/utils/navigation"; import { formikUtils } from "src/utils/form"; @@ -27,8 +27,8 @@ import { import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; -interface IMovieEditPanel { - movie: Partial; +interface IGroupEditPanel { + group: Partial; onSubmit: (movie: GQL.MovieCreateInput) => Promise; onCancel: () => void; onDelete: () => void; @@ -37,8 +37,8 @@ interface IMovieEditPanel { setEncodingImage: (loading: boolean) => void; } -export const MovieEditPanel: React.FC = ({ - movie, +export const GroupEditPanel: React.FC = ({ + group, onSubmit, onCancel, onDelete, @@ -49,7 +49,7 @@ export const MovieEditPanel: React.FC = ({ const intl = useIntl(); const Toast = useToast(); - const isNew = movie.id === undefined; + const isNew = group.id === undefined; const [isLoading, setIsLoading] = useState(false); const [isImageAlertOpen, setIsImageAlertOpen] = useState(false); @@ -57,7 +57,7 @@ export const MovieEditPanel: React.FC = ({ const [imageClipboard, setImageClipboard] = useState(); const Scrapers = useListMovieScrapers(); - const [scrapedMovie, setScrapedMovie] = useState(); + const [scrapedGroup, setScrapedGroup] = useState(); const [studio, setStudio] = useState(null); @@ -76,15 +76,15 @@ export const MovieEditPanel: React.FC = ({ }); const initialValues = { - name: movie?.name ?? "", - aliases: movie?.aliases ?? "", - duration: movie?.duration ?? null, - date: movie?.date ?? "", - studio_id: movie?.studio?.id ?? null, - tag_ids: (movie?.tags ?? []).map((t) => t.id), - director: movie?.director ?? "", - urls: movie?.urls ?? [], - synopsis: movie?.synopsis ?? "", + name: group?.name ?? "", + aliases: group?.aliases ?? "", + duration: group?.duration ?? null, + date: group?.date ?? "", + studio_id: group?.studio?.id ?? null, + tag_ids: (group?.tags ?? []).map((t) => t.id), + director: group?.director ?? "", + urls: group?.urls ?? [], + synopsis: group?.synopsis ?? "", }; type InputValues = yup.InferType; @@ -97,7 +97,7 @@ export const MovieEditPanel: React.FC = ({ }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( - movie.tags, + group.tags, (ids) => formik.setFieldValue("tag_ids", ids) ); @@ -107,8 +107,8 @@ export const MovieEditPanel: React.FC = ({ } useEffect(() => { - setStudio(movie.studio ?? null); - }, [movie.studio]); + setStudio(group.studio ?? null); + }, [group.studio]); // set up hotkeys useEffect(() => { @@ -128,7 +128,7 @@ export const MovieEditPanel: React.FC = ({ }; }); - function updateMovieEditStateFromScraper( + function updateGroupEditStateFromScraper( state: Partial ) { if (state.name) { @@ -200,11 +200,11 @@ export const MovieEditPanel: React.FC = ({ return; } - // if this is a new movie, just dump the data + // if this is a new group, just dump the data if (isNew) { - updateMovieEditStateFromScraper(result.data.scrapeMovieURL); + updateGroupEditStateFromScraper(result.data.scrapeMovieURL); } else { - setScrapedMovie(result.data.scrapeMovieURL); + setScrapedGroup(result.data.scrapeMovieURL); } } catch (e) { Toast.error(e); @@ -223,25 +223,25 @@ export const MovieEditPanel: React.FC = ({ } function maybeRenderScrapeDialog() { - if (!scrapedMovie) { + if (!scrapedGroup) { return; } - const currentMovie = { - id: movie.id!, + const currentGroup = { + id: group.id!, ...formik.values, }; // Get image paths for scrape gui - currentMovie.front_image = movie?.front_image_path; - currentMovie.back_image = movie?.back_image_path; + currentGroup.front_image = group?.front_image_path; + currentGroup.back_image = group?.back_image_path; return ( - { onScrapeDialogClosed(m); }} @@ -251,9 +251,9 @@ export const MovieEditPanel: React.FC = ({ function onScrapeDialogClosed(p?: GQL.ScrapedMovieDataFragment) { if (p) { - updateMovieEditStateFromScraper(p); + updateGroupEditStateFromScraper(p); } - setScrapedMovie(undefined); + setScrapedGroup(undefined); } const encodingImage = ImageUtils.usePasteImage(showImageAlert); @@ -373,7 +373,7 @@ export const MovieEditPanel: React.FC = ({

    {intl.formatMessage( { id: "actions.add_entity" }, - { entityType: intl.formatMessage({ id: "movie" }) } + { entityType: intl.formatMessage({ id: "group" }) } )}

    )} @@ -382,14 +382,14 @@ export const MovieEditPanel: React.FC = ({ when={formik.dirty} message={(location, action) => { // Check if it's a redirect after movie creation - if (action === "PUSH" && location.pathname.startsWith("/movies/")) + if (action === "PUSH" && location.pathname.startsWith("/groups/")) return true; - return handleUnsavedChanges(intl, "movies", movie.id)(location); + return handleUnsavedChanges(intl, "groups", group.id)(location); }} /> -
    + {renderInputField("name")} {renderInputField("aliases")} {renderDurationField("duration")} @@ -402,7 +402,7 @@ export const MovieEditPanel: React.FC = ({ = ({ +export const GroupScenesPanel: React.FC = ({ active, - movie, + group, }) => { function filterHook(filter: ListFilterModel) { - const movieValue = { id: movie.id, label: movie.name }; + const movieValue = { id: group.id, label: group.name }; // if movie is already present, then we modify it, otherwise add let movieCriterion = filter.criteria.find((c) => { return c.criterionOption.type === "movies"; @@ -29,7 +29,7 @@ export const MovieScenesPanel: React.FC = ({ // add the movie if not present if ( !movieCriterion.value.find((p) => { - return p.id === movie.id; + return p.id === group.id; }) ) { movieCriterion.value.push(movieValue); @@ -46,13 +46,13 @@ export const MovieScenesPanel: React.FC = ({ return filter; } - if (movie && movie.id) { + if (group && group.id) { return ( ); } diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx index b28bded5c..644564112 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx @@ -20,33 +20,33 @@ import { uniq } from "lodash-es"; import { Tag } from "src/components/Tags/TagSelect"; import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags"; -interface IMovieScrapeDialogProps { - movie: Partial; - movieStudio: Studio | null; - movieTags: Tag[]; +interface IGroupScrapeDialogProps { + group: Partial; + groupStudio: Studio | null; + groupTags: Tag[]; scraped: GQL.ScrapedMovie; onClose: (scrapedMovie?: GQL.ScrapedMovie) => void; } -export const MovieScrapeDialog: React.FC = ({ - movie, - movieStudio, - movieTags, +export const GroupScrapeDialog: React.FC = ({ + group, + groupStudio: groupStudio, + groupTags: groupTags, scraped, onClose, }) => { const intl = useIntl(); const [name, setName] = useState>( - new ScrapeResult(movie.name, scraped.name) + new ScrapeResult(group.name, scraped.name) ); const [aliases, setAliases] = useState>( - new ScrapeResult(movie.aliases, scraped.aliases) + new ScrapeResult(group.aliases, scraped.aliases) ); const [duration, setDuration] = useState>( new ScrapeResult( - TextUtils.secondsToTimestamp(movie.duration || 0), + TextUtils.secondsToTimestamp(group.duration || 0), // convert seconds to string if it's a number scraped.duration && !isNaN(+scraped.duration) ? TextUtils.secondsToTimestamp(parseInt(scraped.duration, 10)) @@ -54,20 +54,20 @@ export const MovieScrapeDialog: React.FC = ({ ) ); const [date, setDate] = useState>( - new ScrapeResult(movie.date, scraped.date) + new ScrapeResult(group.date, scraped.date) ); const [director, setDirector] = useState>( - new ScrapeResult(movie.director, scraped.director) + new ScrapeResult(group.director, scraped.director) ); const [synopsis, setSynopsis] = useState>( - new ScrapeResult(movie.synopsis, scraped.synopsis) + new ScrapeResult(group.synopsis, scraped.synopsis) ); const [studio, setStudio] = useState>( new ObjectScrapeResult( - movieStudio + groupStudio ? { - stored_id: movieStudio.id, - name: movieStudio.name, + stored_id: groupStudio.id, + name: groupStudio.name, } : undefined, scraped.studio?.stored_id ? scraped.studio : undefined @@ -75,17 +75,17 @@ export const MovieScrapeDialog: React.FC = ({ ); const [urls, setURLs] = useState>( new ScrapeResult( - movie.urls, + group.urls, scraped.urls - ? uniq((movie.urls ?? []).concat(scraped.urls ?? [])) + ? uniq((group.urls ?? []).concat(scraped.urls ?? [])) : undefined ) ); const [frontImage, setFrontImage] = useState>( - new ScrapeResult(movie.front_image, scraped.front_image) + new ScrapeResult(group.front_image, scraped.front_image) ); const [backImage, setBackImage] = useState>( - new ScrapeResult(movie.back_image, scraped.back_image) + new ScrapeResult(group.back_image, scraped.back_image) ); const [newStudio, setNewStudio] = useState( @@ -99,7 +99,7 @@ export const MovieScrapeDialog: React.FC = ({ }); const { tags, newTags, scrapedTagsRow } = useScrapedTags( - movieTags, + groupTags, scraped.tags ); @@ -194,13 +194,13 @@ export const MovieScrapeDialog: React.FC = ({ {scrapedTagsRow} setFrontImage(value)} /> setBackImage(value)} /> @@ -212,7 +212,7 @@ export const MovieScrapeDialog: React.FC = ({ { diff --git a/ui/v2.5/src/components/Movies/MovieList.tsx b/ui/v2.5/src/components/Movies/MovieList.tsx index 8b42a3b73..d4d4bf1c9 100644 --- a/ui/v2.5/src/components/Movies/MovieList.tsx +++ b/ui/v2.5/src/components/Movies/MovieList.tsx @@ -14,11 +14,11 @@ import { import { makeItemList, showWhenSelected } from "../List/ItemList"; import { ExportDialog } from "../Shared/ExportDialog"; import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; -import { MovieCardGrid } from "./MovieCardGrid"; -import { EditMoviesDialog } from "./EditMoviesDialog"; +import { GroupCardGrid } from "./MovieCardGrid"; +import { EditGroupsDialog } from "./EditMoviesDialog"; import { View } from "../List/views"; -const MovieItemList = makeItemList({ +const GroupItemList = makeItemList({ filterMode: GQL.FilterMode.Movies, useResult: useFindMovies, getItems(result: GQL.FindMoviesQueryResult) { @@ -29,13 +29,13 @@ const MovieItemList = makeItemList({ }, }); -interface IMovieList { +interface IGroupList { filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; alterQuery?: boolean; } -export const MovieList: React.FC = ({ +export const GroupList: React.FC = ({ filterHook, alterQuery, view, @@ -90,7 +90,7 @@ export const MovieList: React.FC = ({ if (singleResult.data.findMovies.movies.length === 1) { const { id } = singleResult.data.findMovies.movies[0]; // navigate to the movie page - history.push(`/movies/${id}`); + history.push(`/groups/${id}`); } } } @@ -111,7 +111,7 @@ export const MovieList: React.FC = ({ selectedIds: Set, onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void ) { - function maybeRenderMovieExportDialog() { + function maybeRenderGroupExportDialog() { if (isExportDialogOpen) { return ( = ({ } } - function renderMovies() { + function renderGroups() { if (!result.data?.findMovies) return; if (filter.displayMode === DisplayMode.Grid) { return ( - @@ -145,36 +145,36 @@ export const MovieList: React.FC = ({ } return ( <> - {maybeRenderMovieExportDialog()} - {renderMovies()} + {maybeRenderGroupExportDialog()} + {renderGroups()} ); } function renderEditDialog( - selectedMovies: GQL.MovieDataFragment[], + selectedGroups: GQL.MovieDataFragment[], onClose: (applied: boolean) => void ) { - return ; + return ; } function renderDeleteDialog( - selectedMovies: GQL.SlimMovieDataFragment[], + selectedGroups: GQL.SlimMovieDataFragment[], onClose: (confirmed: boolean) => void ) { return ( ); } return ( - = (props: IProps) => { +export const GroupRecommendationRow: React.FC = (props: IProps) => { const result = useFindMovies(props.filter); const cardCount = result.data?.findMovies.count; @@ -24,10 +24,10 @@ export const MovieRecommendationRow: React.FC = (props: IProps) => { return ( + } @@ -40,10 +40,10 @@ export const MovieRecommendationRow: React.FC = (props: IProps) => { > {result.loading ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
    +
    )) : result.data?.findMovies.movies.map((m) => ( - + ))}
    diff --git a/ui/v2.5/src/components/Movies/MovieSelect.tsx b/ui/v2.5/src/components/Movies/MovieSelect.tsx index 279994d1a..1aa791235 100644 --- a/ui/v2.5/src/components/Movies/MovieSelect.tsx +++ b/ui/v2.5/src/components/Movies/MovieSelect.tsx @@ -30,13 +30,13 @@ import { sortByRelevance } from "src/utils/query"; import { PatchComponent, PatchFunction } from "src/patch"; import { TruncatedText } from "../Shared/TruncatedText"; -export type Movie = Pick< +export type Group = Pick< GQL.Movie, "id" | "name" | "date" | "front_image_path" | "aliases" > & { studio?: Pick | null; }; -type Option = SelectOption; +type Option = SelectOption; type FindMoviesResult = Awaited< ReturnType @@ -56,9 +56,9 @@ const movieSelectSort = PatchFunction( sortMoviesByRelevance ); -const _MovieSelect: React.FC< +const _GroupSelect: React.FC< IFilterProps & - IFilterValueProps & { + IFilterValueProps & { hoverPlacement?: Placement; excludeIds?: string[]; } @@ -94,7 +94,7 @@ const _MovieSelect: React.FC< })); } - const MovieOption: React.FC> = (optionProps) => { + const GroupOption: React.FC> = (optionProps) => { let thisOptionProps = optionProps; const { object } = optionProps.data; @@ -111,24 +111,24 @@ const _MovieSelect: React.FC< thisOptionProps = { ...optionProps, children: ( - - + + {object.front_image_path && ( )} - + {title} {alias && ( - {` (${alias})`} + {` (${alias})`} )} } @@ -136,13 +136,13 @@ const _MovieSelect: React.FC< /> {object.studio?.name && ( - + {object.studio?.name} )} {object.date && ( - {object.date} + {object.date} )} @@ -153,7 +153,7 @@ const _MovieSelect: React.FC< return ; }; - const MovieMultiValueLabel: React.FC< + const GroupMultiValueLabel: React.FC< MultiValueGenericProps > = (optionProps) => { let thisOptionProps = optionProps; @@ -168,7 +168,7 @@ const _MovieSelect: React.FC< return ; }; - const MovieValueLabel: React.FC> = ( + const GroupValueLabel: React.FC> = ( optionProps ) => { let thisOptionProps = optionProps; @@ -190,7 +190,7 @@ const _MovieSelect: React.FC< return { value: result.data!.movieCreate!.id, item: result.data!.movieCreate!, - message: "Created movie", + message: "Created group", }; }; @@ -201,7 +201,7 @@ const _MovieSelect: React.FC< }; }; - const isValidNewOption = (inputValue: string, options: Movie[]) => { + const isValidNewOption = (inputValue: string, options: Group[]) => { if (!inputValue) { return false; } @@ -221,12 +221,12 @@ const _MovieSelect: React.FC< }; return ( - + {...props} className={cx( - "movie-select", + "group-select", { - "movie-select-active": props.active, + "group-select-active": props.active, }, props.className )} @@ -234,9 +234,9 @@ const _MovieSelect: React.FC< getNamedObject={getNamedObject} isValidNewOption={isValidNewOption} components={{ - Option: MovieOption, - MultiValueLabel: MovieMultiValueLabel, - SingleValue: MovieValueLabel, + Option: GroupOption, + MultiValueLabel: GroupMultiValueLabel, + SingleValue: GroupValueLabel, }} isMulti={props.isMulti ?? false} creatable={props.creatable ?? defaultCreatable} @@ -247,7 +247,7 @@ const _MovieSelect: React.FC< { id: "actions.select_entity" }, { entityType: intl.formatMessage({ - id: props.isMulti ? "movies" : "movie", + id: props.isMulti ? "groups" : "group", }), } ) @@ -257,22 +257,22 @@ const _MovieSelect: React.FC< ); }; -export const MovieSelect = PatchComponent("MovieSelect", _MovieSelect); +export const GroupSelect = PatchComponent("GroupSelect", _GroupSelect); -const _MovieIDSelect: React.FC> = ( +const _GroupIDSelect: React.FC> = ( props ) => { const { ids, onSelect: onSelectValues } = props; - const [values, setValues] = useState([]); + const [values, setValues] = useState([]); const idsChanged = useCompare(ids); - function onSelect(items: Movie[]) { + function onSelect(items: Group[]) { setValues(items); onSelectValues?.(items); } - async function loadObjectsByID(idsToLoad: string[]): Promise { + async function loadObjectsByID(idsToLoad: string[]): Promise { const query = await queryFindMoviesByIDForSelect(idsToLoad); const { movies: loadedMovies } = query.data.findMovies; @@ -303,7 +303,7 @@ const _MovieIDSelect: React.FC> = ( load(); }, [ids, idsChanged, values]); - return ; + return ; }; -export const MovieIDSelect = PatchComponent("MovieIDSelect", _MovieIDSelect); +export const GroupIDSelect = PatchComponent("GroupIDSelect", _GroupIDSelect); diff --git a/ui/v2.5/src/components/Movies/Movies.tsx b/ui/v2.5/src/components/Movies/Movies.tsx index e93e14720..202d8f494 100644 --- a/ui/v2.5/src/components/Movies/Movies.tsx +++ b/ui/v2.5/src/components/Movies/Movies.tsx @@ -2,30 +2,30 @@ import React from "react"; import { Route, Switch } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; -import Movie from "./MovieDetails/Movie"; -import MovieCreate from "./MovieDetails/MovieCreate"; -import { MovieList } from "./MovieList"; +import Group from "./MovieDetails/Movie"; +import GroupCreate from "./MovieDetails/MovieCreate"; +import { GroupList } from "./MovieList"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { View } from "../List/views"; -const Movies: React.FC = () => { +const Groups: React.FC = () => { useScrollToTopOnMount(); - return ; + return ; }; -const MovieRoutes: React.FC = () => { - const titleProps = useTitleProps({ id: "movies" }); +const GroupRoutes: React.FC = () => { + const titleProps = useTitleProps({ id: "groups" }); return ( <> - - - + + + ); }; -export default MovieRoutes; +export default GroupRoutes; diff --git a/ui/v2.5/src/components/Movies/styles.scss b/ui/v2.5/src/components/Movies/styles.scss index 58071d4b8..3d1868fb8 100644 --- a/ui/v2.5/src/components/Movies/styles.scss +++ b/ui/v2.5/src/components/Movies/styles.scss @@ -1,4 +1,4 @@ -.movie-card { +.group-card { width: 240px; @media (max-width: 576px) { @@ -14,7 +14,7 @@ width: 100%; } - .movie-scene-number { + .group-scene-number { text-align: center; } @@ -23,14 +23,14 @@ } } -.movie-images { +.group-images { align-items: center; display: flex; flex-direction: row; justify-content: space-evenly; max-width: 100%; - .movie-image-container { + .group-image-container { box-shadow: none; } @@ -40,17 +40,17 @@ } } -#movie-page .rating-number .text-input { +#group-page .rating-number .text-input { width: auto; } -.movie-select-option { - .movie-select-row { +.group-select-option { + .group-select-row { align-items: center; display: flex; width: 100%; - .movie-select-image { + .group-select-image { background-color: $body-bg; margin-right: 0.4em; max-height: 50px; @@ -59,26 +59,26 @@ object-position: center; } - .movie-select-details { + .group-select-details { display: flex; flex-direction: column; justify-content: flex-start; max-height: 4.1rem; overflow: hidden; - .movie-select-title { + .group-select-title { flex-shrink: 0; white-space: pre-wrap; word-break: break-all; - .movie-select-alias { + .group-select-alias { font-size: 0.8rem; font-weight: bold; } } - .movie-select-date, - .movie-select-studio { + .group-select-date, + .group-select-studio { color: $text-muted; flex-shrink: 0; font-size: 0.9rem; diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index 4792e452c..3d5765ada 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -178,15 +178,15 @@ export const PerformerCard: React.FC = ({ ); } - function maybeRenderMoviesPopoverButton() { + function maybeRenderGroupsPopoverButton() { if (!performer.movie_count) return; return ( = ({
    {maybeRenderScenesPopoverButton()} - {maybeRenderMoviesPopoverButton()} + {maybeRenderGroupsPopoverButton()} {maybeRenderImagesPopoverButton()} {maybeRenderGalleriesPopoverButton()} {maybeRenderTagPopoverButton()} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index 85674e023..2c19fa775 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -27,7 +27,7 @@ import { } from "./PerformerDetailsPanel"; import { PerformerScenesPanel } from "./PerformerScenesPanel"; import { PerformerGalleriesPanel } from "./PerformerGalleriesPanel"; -import { PerformerMoviesPanel } from "./PerformerMoviesPanel"; +import { PerformerGroupsPanel } from "./PerformerMoviesPanel"; import { PerformerImagesPanel } from "./PerformerImagesPanel"; import { PerformerAppearsWithPanel } from "./performerAppearsWithPanel"; import { PerformerEditPanel } from "./PerformerEditPanel"; @@ -60,7 +60,7 @@ const validTabs = [ "scenes", "galleries", "images", - "movies", + "groups", "appearswith", ] as const; type TabKey = (typeof validTabs)[number]; @@ -146,7 +146,7 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { } else if (performer.image_count != 0) { ret = "images"; } else if (performer.movie_count != 0) { - ret = "movies"; + ret = "groups"; } } @@ -191,7 +191,7 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { Mousetrap.bind("e", () => toggleEditing()); Mousetrap.bind("c", () => setTabKey("scenes")); Mousetrap.bind("g", () => setTabKey("galleries")); - Mousetrap.bind("m", () => setTabKey("movies")); + Mousetrap.bind("m", () => setTabKey("groups")); Mousetrap.bind("f", () => setFavorite(!performer.favorite)); Mousetrap.bind(",", () => setCollapsed(!collapsed)); @@ -319,10 +319,10 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { /> - {intl.formatMessage({ id: "movies" })} + {intl.formatMessage({ id: "groups" })} = ({ performer, tabKey }) => { } > - diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx index 0f1c8b7d5..f9a1f7f5a 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx @@ -1,6 +1,6 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { MovieList } from "src/components/Movies/MovieList"; +import { GroupList } from "src/components/Movies/MovieList"; import { usePerformerFilterHook } from "src/core/performers"; import { View } from "src/components/List/views"; @@ -9,16 +9,16 @@ interface IPerformerDetailsProps { performer: GQL.PerformerDataFragment; } -export const PerformerMoviesPanel: React.FC = ({ +export const PerformerGroupsPanel: React.FC = ({ active, performer, }) => { const filterHook = usePerformerFilterHook(performer); return ( - ); }; diff --git a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx index 4c3e6ee54..9ee04ac68 100644 --- a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx +++ b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx @@ -21,7 +21,7 @@ import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; import { GalleryLink, - MovieLink, + GroupLink, SceneMarkerLink, TagLink, } from "../Shared/TagLink"; @@ -386,14 +386,14 @@ export const SceneDuplicateChecker: React.FC = () => { return ; } - function maybeRenderMoviePopoverButton(scene: GQL.SlimSceneDataFragment) { + function maybeRenderGroupPopoverButton(scene: GQL.SlimSceneDataFragment) { if (scene.movies.length <= 0) return; const popoverContent = scene.movies.map((sceneMovie) => ( -
    +
    { src={sceneMovie.movie.front_image_path ?? ""} /> -
    @@ -523,7 +523,7 @@ export const SceneDuplicateChecker: React.FC = () => { {maybeRenderTagPopoverButton(scene)} {maybeRenderPerformerPopoverButton(scene)} - {maybeRenderMoviePopoverButton(scene)} + {maybeRenderGroupPopoverButton(scene)} {maybeRenderSceneMarkerPopoverButton(scene)} {maybeRenderOCounter(scene)} {maybeRenderGallery(scene)} diff --git a/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx b/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx index fb85dbf6f..f4fc8a1e2 100644 --- a/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx +++ b/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx @@ -13,7 +13,7 @@ import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { getAggregateInputIDs, getAggregateInputValue, - getAggregateMovieIds, + getAggregateGroupIds, getAggregatePerformerIds, getAggregateRating, getAggregateStudioId, @@ -42,11 +42,11 @@ export const EditScenesDialog: React.FC = ( ); const [tagIds, setTagIds] = useState(); const [existingTagIds, setExistingTagIds] = useState(); - const [movieMode, setMovieMode] = React.useState( + const [groupMode, setGroupMode] = React.useState( GQL.BulkUpdateIdMode.Add ); - const [movieIds, setMovieIds] = useState(); - const [existingMovieIds, setExistingMovieIds] = useState(); + const [groupIds, setGroupIds] = useState(); + const [existingGroupIds, setExistingGroupIds] = useState(); const [organized, setOrganized] = useState(); const [updateScenes] = useBulkSceneUpdate(getSceneInput()); @@ -62,7 +62,7 @@ export const EditScenesDialog: React.FC = ( const aggregateStudioId = getAggregateStudioId(props.selected); const aggregatePerformerIds = getAggregatePerformerIds(props.selected); const aggregateTagIds = getAggregateTagIds(props.selected); - const aggregateMovieIds = getAggregateMovieIds(props.selected); + const aggregateGroupIds = getAggregateGroupIds(props.selected); const sceneInput: GQL.BulkSceneUpdateInput = { ids: props.selected.map((scene) => { @@ -80,9 +80,9 @@ export const EditScenesDialog: React.FC = ( ); sceneInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); sceneInput.movie_ids = getAggregateInputIDs( - movieMode, - movieIds, - aggregateMovieIds + groupMode, + groupIds, + aggregateGroupIds ); if (organized !== undefined) { @@ -115,7 +115,7 @@ export const EditScenesDialog: React.FC = ( let updateStudioID: string | undefined; let updatePerformerIds: string[] = []; let updateTagIds: string[] = []; - let updateMovieIds: string[] = []; + let updateGroupIds: string[] = []; let updateOrganized: boolean | undefined; let first = true; @@ -126,14 +126,14 @@ export const EditScenesDialog: React.FC = ( .map((p) => p.id) .sort(); const sceneTagIDs = (scene.tags ?? []).map((p) => p.id).sort(); - const sceneMovieIDs = (scene.movies ?? []).map((m) => m.movie.id).sort(); + const sceneGroupIDs = (scene.movies ?? []).map((m) => m.movie.id).sort(); if (first) { updateRating = sceneRating ?? undefined; updateStudioID = sceneStudioID; updatePerformerIds = scenePerformerIDs; updateTagIds = sceneTagIDs; - updateMovieIds = sceneMovieIDs; + updateGroupIds = sceneGroupIDs; first = false; updateOrganized = scene.organized; } else { @@ -149,8 +149,8 @@ export const EditScenesDialog: React.FC = ( if (!isEqual(sceneTagIDs, updateTagIds)) { updateTagIds = []; } - if (!isEqual(sceneMovieIDs, updateMovieIds)) { - updateMovieIds = []; + if (!isEqual(sceneGroupIDs, updateGroupIds)) { + updateGroupIds = []; } if (scene.organized !== updateOrganized) { updateOrganized = undefined; @@ -162,7 +162,7 @@ export const EditScenesDialog: React.FC = ( setStudioId(updateStudioID); setExistingPerformerIds(updatePerformerIds); setExistingTagIds(updateTagIds); - setExistingMovieIds(updateMovieIds); + setExistingGroupIds(updateGroupIds); setOrganized(updateOrganized); }, [props.selected]); @@ -173,7 +173,7 @@ export const EditScenesDialog: React.FC = ( }, [organized, checkboxRef]); function renderMultiSelect( - type: "performers" | "tags" | "movies", + type: "performers" | "tags" | "groups", ids: string[] | undefined ) { let mode = GQL.BulkUpdateIdMode.Add; @@ -187,9 +187,9 @@ export const EditScenesDialog: React.FC = ( mode = tagMode; existingIds = existingTagIds; break; - case "movies": - mode = movieMode; - existingIds = existingMovieIds; + case "groups": + mode = groupMode; + existingIds = existingGroupIds; break; } @@ -205,8 +205,8 @@ export const EditScenesDialog: React.FC = ( case "tags": setTagIds(itemIDs); break; - case "movies": - setMovieIds(itemIDs); + case "groups": + setGroupIds(itemIDs); break; } }} @@ -218,8 +218,8 @@ export const EditScenesDialog: React.FC = ( case "tags": setTagMode(newMode); break; - case "movies": - setMovieMode(newMode); + case "groups": + setGroupMode(newMode); break; } }} @@ -306,11 +306,11 @@ export const EditScenesDialog: React.FC = ( {renderMultiSelect("tags", tagIds)} - + - + - {renderMultiSelect("movies", movieIds)} + {renderMultiSelect("groups", groupIds)} diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index b648ec437..694d1bdcc 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -7,7 +7,7 @@ import { Icon } from "../Shared/Icon"; import { GalleryLink, TagLink, - MovieLink, + GroupLink, SceneMarkerLink, } from "../Shared/TagLink"; import { HoverPopover } from "../Shared/HoverPopover"; @@ -143,24 +143,24 @@ const SceneCardPopovers = PatchComponent( return ; } - function maybeRenderMoviePopoverButton() { + function maybeRenderGroupPopoverButton() { if (props.scene.movies.length <= 0) return; - const popoverContent = props.scene.movies.map((sceneMovie) => ( -
    + const popoverContent = props.scene.movies.map((sceneGroup) => ( +
    {sceneMovie.movie.name -
    @@ -170,7 +170,7 @@ const SceneCardPopovers = PatchComponent(
    diff --git a/ui/v2.5/src/components/Shared/Link.tsx b/ui/v2.5/src/components/Shared/Link.tsx index 7bfeef413..d3da7eac1 100644 --- a/ui/v2.5/src/components/Shared/Link.tsx +++ b/ui/v2.5/src/components/Shared/Link.tsx @@ -6,14 +6,14 @@ import NavUtils from "src/utils/navigation"; export const DirectorLink: React.FC<{ director: string; - linkType: "scene" | "movie"; + linkType: "scene" | "group"; }> = ({ director: director, linkType = "scene" }) => { const link = useMemo(() => { switch (linkType) { case "scene": return NavUtils.makeDirectorScenesUrl(director); - case "movie": - return NavUtils.makeDirectorMoviesUrl(director); + case "group": + return NavUtils.makeDirectorGroupsUrl(director); } }, [director, linkType]); diff --git a/ui/v2.5/src/components/Shared/MultiSet.tsx b/ui/v2.5/src/components/Shared/MultiSet.tsx index f92b57ff3..521a2577b 100644 --- a/ui/v2.5/src/components/Shared/MultiSet.tsx +++ b/ui/v2.5/src/components/Shared/MultiSet.tsx @@ -10,7 +10,7 @@ import { } from "../Galleries/GallerySelect"; interface IMultiSetProps { - type: "performers" | "studios" | "tags" | "movies" | "galleries"; + type: "performers" | "studios" | "tags" | "groups" | "galleries"; existingIds?: string[]; ids?: string[]; mode: GQL.BulkUpdateIdMode; diff --git a/ui/v2.5/src/components/Shared/PopoverCountButton.tsx b/ui/v2.5/src/components/Shared/PopoverCountButton.tsx index c455145fc..aada2fe19 100644 --- a/ui/v2.5/src/components/Shared/PopoverCountButton.tsx +++ b/ui/v2.5/src/components/Shared/PopoverCountButton.tsx @@ -20,7 +20,7 @@ type PopoverLinkType = | "image" | "gallery" | "marker" - | "movie" + | "group" | "performer" | "studio"; @@ -52,7 +52,7 @@ export const PopoverCountButton: React.FC = ({ return faImages; case "marker": return faMapMarkerAlt; - case "movie": + case "group": return faFilm; case "performer": return faUser; @@ -83,10 +83,10 @@ export const PopoverCountButton: React.FC = ({ one: "marker", other: "markers", }; - case "movie": + case "group": return { - one: "movie", - other: "movies", + one: "group", + other: "groups", }; case "performer": return { diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx index 7184716a7..73589ce92 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx @@ -8,7 +8,7 @@ import { } from "src/components/Shared/ScrapeDialog/scrapeResult"; import { TagSelect } from "src/components/Tags/TagSelect"; import { StudioSelect } from "src/components/Studios/StudioSelect"; -import { MovieSelect } from "src/components/Movies/MovieSelect"; +import { GroupSelect } from "src/components/Movies/MovieSelect"; interface IScrapedStudioRow { title: string; @@ -196,10 +196,10 @@ export const ScrapedPerformersRow: React.FC< ); }; -export const ScrapedMoviesRow: React.FC< +export const ScrapedGroupsRow: React.FC< IScrapedObjectRowImpl > = ({ title, result, onChange, newObjects, onCreateNew }) => { - const moviesCopy = useMemo(() => { + const groupsCopy = useMemo(() => { return ( newObjects?.map((p) => { const name: string = p.name ?? ""; @@ -208,7 +208,7 @@ export const ScrapedMoviesRow: React.FC< ); }, [newObjects]); - function renderScrapedMovies( + function renderScrapedGroups( scrapeResult: ScrapeResult, isNew?: boolean, onChangeFn?: (value: GQL.ScrapedMovie[]) => void @@ -228,7 +228,7 @@ export const ScrapedMoviesRow: React.FC< }); return ( - title={title} result={result} - renderObjects={renderScrapedMovies} + renderObjects={renderScrapedGroups} onChange={onChange} - newObjects={moviesCopy} + newObjects={groupsCopy} onCreateNew={onCreateNew} getName={(value) => value.name ?? ""} /> diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts b/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts index 009677e59..397681483 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts @@ -9,7 +9,7 @@ import { import { ObjectScrapeResult, ScrapeResult } from "./scrapeResult"; import { useIntl } from "react-intl"; import { scrapedPerformerToCreateInput } from "src/core/performers"; -import { scrapedMovieToCreateInput } from "src/core/movies"; +import { scrapedGroupToCreateInput } from "src/core/movies"; function useCreateObject( entityTypeID: string, @@ -123,16 +123,16 @@ export function useCreateScrapedPerformer( return useCreateObject("performer", createNewPerformer); } -export function useCreateScrapedMovie( +export function useCreateScrapedGroup( props: IUseCreateNewObjectProps ) { const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props; - const [createMovie] = useMovieCreate(); + const [createGroup] = useMovieCreate(); - async function createNewMovie(toCreate: GQL.ScrapedMovie) { - const input = scrapedMovieToCreateInput(toCreate); + async function createNewGroup(toCreate: GQL.ScrapedMovie) { + const input = scrapedGroupToCreateInput(toCreate); - const result = await createMovie({ + const result = await createGroup({ variables: { input: input }, }); @@ -150,14 +150,14 @@ export function useCreateScrapedMovie( // remove the object from the list const newObjectsClone = newObjects.concat(); const pIndex = newObjectsClone.findIndex((p) => p.name === toCreate.name); - if (pIndex === -1) throw new Error("Could not find movie to remove"); + if (pIndex === -1) throw new Error("Could not find group to remove"); newObjectsClone.splice(pIndex, 1); setNewObjects(newObjectsClone); } - return useCreateObject("movie", createNewMovie); + return useCreateObject("group", createNewGroup); } export function useCreateScrapedTag( diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index 201bb5a3d..e989c886d 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -26,7 +26,7 @@ import { faTableColumns } from "@fortawesome/free-solid-svg-icons"; import { TagIDSelect } from "../Tags/TagSelect"; import { StudioIDSelect } from "../Studios/StudioSelect"; import { GalleryIDSelect } from "../Galleries/GallerySelect"; -import { MovieIDSelect } from "../Movies/MovieSelect"; +import { GroupIDSelect } from "../Movies/MovieSelect"; import { SceneIDSelect } from "../Scenes/SceneSelect"; export type SelectObject = { @@ -44,7 +44,7 @@ interface ITypeProps { | "scene_tags" | "performer_tags" | "scenes" - | "movies" + | "groups" | "galleries"; } interface IFilterProps { @@ -364,8 +364,8 @@ export const StudioSelect: React.FC< return ; }; -export const MovieSelect: React.FC = (props) => { - return ; +export const GroupSelect: React.FC = (props) => { + return ; }; export const TagSelect: React.FC< @@ -382,8 +382,8 @@ export const FilterSelect: React.FC = (props) => { return ; case "scenes": return ; - case "movies": - return ; + case "groups": + return ; case "galleries": return ; default: diff --git a/ui/v2.5/src/components/Shared/TagLink.tsx b/ui/v2.5/src/components/Shared/TagLink.tsx index f2fe7c49f..d01de3eac 100644 --- a/ui/v2.5/src/components/Shared/TagLink.tsx +++ b/ui/v2.5/src/components/Shared/TagLink.tsx @@ -71,25 +71,25 @@ export const PerformerLink: React.FC = ({ ); }; -interface IMovieLinkProps { - movie: INamedObject; +interface IGroupLinkProps { + group: INamedObject; linkType?: "scene"; className?: string; } -export const MovieLink: React.FC = ({ - movie, +export const GroupLink: React.FC = ({ + group, linkType = "scene", className, }) => { const link = useMemo(() => { switch (linkType) { case "scene": - return NavUtils.makeMovieScenesUrl(movie); + return NavUtils.makeGroupScenesUrl(group); } - }, [movie, linkType]); + }, [group, linkType]); - const title = movie.name || ""; + const title = group.name || ""; return ( @@ -197,7 +197,7 @@ interface ITagLinkProps { | "image" | "details" | "performer" - | "movie" + | "group" | "studio"; className?: string; hoverPlacement?: Placement; @@ -225,8 +225,8 @@ export const TagLink: React.FC = ({ return NavUtils.makeTagGalleriesUrl(tag); case "image": return NavUtils.makeTagImagesUrl(tag); - case "movie": - return NavUtils.makeTagMoviesUrl(tag); + case "group": + return NavUtils.makeTagGroupsUrl(tag); case "details": return NavUtils.makeTagUrl(tag.id ?? ""); } diff --git a/ui/v2.5/src/components/Stats.tsx b/ui/v2.5/src/components/Stats.tsx index f177aa461..608afc0e2 100644 --- a/ui/v2.5/src/components/Stats.tsx +++ b/ui/v2.5/src/components/Stats.tsx @@ -53,7 +53,7 @@ export const Stats: React.FC = () => {

    - +

    diff --git a/ui/v2.5/src/components/Studios/StudioCard.tsx b/ui/v2.5/src/components/Studios/StudioCard.tsx index 1c1e5e6ee..62604555e 100644 --- a/ui/v2.5/src/components/Studios/StudioCard.tsx +++ b/ui/v2.5/src/components/Studios/StudioCard.tsx @@ -142,15 +142,15 @@ export const StudioCard: React.FC = ({ ); } - function maybeRenderMoviesPopoverButton() { + function maybeRenderGroupsPopoverButton() { if (!studio.movie_count) return; return ( ); } @@ -199,7 +199,7 @@ export const StudioCard: React.FC = ({
    {maybeRenderScenesPopoverButton()} - {maybeRenderMoviesPopoverButton()} + {maybeRenderGroupsPopoverButton()} {maybeRenderImagesPopoverButton()} {maybeRenderGalleriesPopoverButton()} {maybeRenderPerformersPopoverButton()} diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 35052b091..870db812c 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -31,7 +31,7 @@ import { CompressedStudioDetailsPanel, StudioDetailsPanel, } from "./StudioDetailsPanel"; -import { StudioMoviesPanel } from "./StudioMoviesPanel"; +import { StudioGroupsPanel } from "./StudioMoviesPanel"; import { faTrashAlt, faLink, @@ -63,7 +63,7 @@ const validTabs = [ "galleries", "images", "performers", - "movies", + "groups", "childstudios", ] as const; type TabKey = (typeof validTabs)[number]; @@ -108,7 +108,7 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { (showAllCounts ? studio.image_count_all : studio.image_count) ?? 0; const performerCount = (showAllCounts ? studio.performer_count_all : studio.performer_count) ?? 0; - const movieCount = + const groupCount = (showAllCounts ? studio.movie_count_all : studio.movie_count) ?? 0; const populatedDefaultTab = useMemo(() => { @@ -120,8 +120,8 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { ret = "images"; } else if (performerCount != 0) { ret = "performers"; - } else if (movieCount != 0) { - ret = "movies"; + } else if (groupCount != 0) { + ret = "groups"; } else if (studio.child_studios.length != 0) { ret = "childstudios"; } @@ -133,7 +133,7 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { galleryCount, imageCount, performerCount, - movieCount, + groupCount, studio, ]); @@ -437,19 +437,19 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { /> - {intl.formatMessage({ id: "movies" })} + {intl.formatMessage({ id: "groups" })} } > - + = ({ +export const StudioGroupsPanel: React.FC = ({ active, studio, }) => { const filterHook = useStudioFilterHook(studio); return ( - ); }; diff --git a/ui/v2.5/src/components/Tags/TagCard.tsx b/ui/v2.5/src/components/Tags/TagCard.tsx index 424f8c5f5..770e0bb91 100644 --- a/ui/v2.5/src/components/Tags/TagCard.tsx +++ b/ui/v2.5/src/components/Tags/TagCard.tsx @@ -236,15 +236,15 @@ export const TagCard: React.FC = ({ ); } - function maybeRenderMoviesPopoverButton() { + function maybeRenderGroupsPopoverButton() { if (!tag.movie_count) return; return ( ); } @@ -258,7 +258,7 @@ export const TagCard: React.FC = ({ {maybeRenderScenesPopoverButton()} {maybeRenderImagesPopoverButton()} {maybeRenderGalleriesPopoverButton()} - {maybeRenderMoviesPopoverButton()} + {maybeRenderGroupsPopoverButton()} {maybeRenderSceneMarkersPopoverButton()} {maybeRenderPerformersPopoverButton()} {maybeRenderStudiosPopoverButton()} diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index c80473db8..51a334c11 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -42,7 +42,7 @@ import { import { DetailImage } from "src/components/Shared/DetailImage"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; -import { TagMoviesPanel } from "./TagMoviesPanel"; +import { TagGroupsPanel } from "./TagMoviesPanel"; interface IProps { tag: GQL.TagDataFragment; @@ -59,7 +59,7 @@ const validTabs = [ "scenes", "images", "galleries", - "movies", + "groups", "markers", "performers", "studios", @@ -105,7 +105,7 @@ const TagPage: React.FC = ({ tag, tabKey }) => { (showAllCounts ? tag.image_count_all : tag.image_count) ?? 0; const galleryCount = (showAllCounts ? tag.gallery_count_all : tag.gallery_count) ?? 0; - const movieCount = + const groupCount = (showAllCounts ? tag.movie_count_all : tag.movie_count) ?? 0; const sceneMarkerCount = (showAllCounts ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0; @@ -121,8 +121,8 @@ const TagPage: React.FC = ({ tag, tabKey }) => { ret = "images"; } else if (galleryCount != 0) { ret = "galleries"; - } else if (movieCount != 0) { - ret = "movies"; + } else if (groupCount != 0) { + ret = "groups"; } else if (sceneMarkerCount != 0) { ret = "markers"; } else if (performerCount != 0) { @@ -140,7 +140,7 @@ const TagPage: React.FC = ({ tag, tabKey }) => { sceneMarkerCount, performerCount, studioCount, - movieCount, + groupCount, ]); const setTabKey = useCallback( @@ -484,19 +484,19 @@ const TagPage: React.FC = ({ tag, tabKey }) => { - {intl.formatMessage({ id: "movies" })} + {intl.formatMessage({ id: "groups" })} } > - + = ({ active, tag }) => { const filterHook = useTagFilterHook(tag); - return ; + return ; }; diff --git a/ui/v2.5/src/core/config.ts b/ui/v2.5/src/core/config.ts index 3e7585df7..e8f829061 100644 --- a/ui/v2.5/src/core/config.ts +++ b/ui/v2.5/src/core/config.ts @@ -143,7 +143,7 @@ export function generateDefaultFrontPageContent(intl: IntlShape) { return [ recentlyReleased(intl, FilterMode.Scenes, "scenes"), recentlyAdded(intl, FilterMode.Studios, "studios"), - recentlyReleased(intl, FilterMode.Movies, "movies"), + recentlyReleased(intl, FilterMode.Movies, "groups"), recentlyAdded(intl, FilterMode.Performers, "performers"), recentlyReleased(intl, FilterMode.Galleries, "galleries"), ]; @@ -156,8 +156,8 @@ export function generatePremadeFrontPageContent(intl: IntlShape) { recentlyReleased(intl, FilterMode.Galleries, "galleries"), recentlyAdded(intl, FilterMode.Galleries, "galleries"), recentlyAdded(intl, FilterMode.Images, "images"), - recentlyReleased(intl, FilterMode.Movies, "movies"), - recentlyAdded(intl, FilterMode.Movies, "movies"), + recentlyReleased(intl, FilterMode.Movies, "groups"), + recentlyAdded(intl, FilterMode.Movies, "groups"), recentlyAdded(intl, FilterMode.Studios, "studios"), recentlyAdded(intl, FilterMode.Performers, "performers"), ]; diff --git a/ui/v2.5/src/core/movies.ts b/ui/v2.5/src/core/movies.ts index 470de21ee..1183785f1 100644 --- a/ui/v2.5/src/core/movies.ts +++ b/ui/v2.5/src/core/movies.ts @@ -1,7 +1,7 @@ import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; -export const scrapedMovieToCreateInput = (toCreate: GQL.ScrapedMovie) => { +export const scrapedGroupToCreateInput = (toCreate: GQL.ScrapedMovie) => { const input: GQL.MovieCreateInput = { name: toCreate.name ?? "", url: toCreate.url, diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 2e9587f96..df3cd9e24 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -54,7 +54,7 @@ body { } } -#movie-page, +#group-page, #performer-page, #studio-page, #tag-page { @@ -83,7 +83,7 @@ dd { display: none; } - .movie-name, + .group-name, .performer-name, .studio-name, .tag-name { @@ -93,7 +93,7 @@ dd { .sticky.detail-header-group { padding: 1rem 2.5rem; - a.movie-name, + a.group-name, a.performer-name, a.studio-name, a.tag-name { @@ -313,7 +313,7 @@ dd { justify-content: center; padding: 0 1rem; - .movie-images { + .group-images { height: 100%; } @@ -322,7 +322,7 @@ dd { height: auto; padding: 0; - .movie-images { + .group-images { .img { max-width: 100%; } @@ -335,18 +335,18 @@ dd { transition: 0.5s; } - .movie-images img { + .group-images img { @media (max-width: 576px) { max-width: 100%; } } } -#movie-page .detail-header-image .movie-images img { +#group-page .detail-header-image .group-images img { max-width: 13rem; } -#movie-page .detail-header-image img, +#group-page .detail-header-image img, #performer-page .detail-header-image img, #tag-page .detail-header-image img { border-radius: 0.5rem; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 805eb9b50..5145ea829 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -798,9 +798,9 @@ "countables": { "files": "{count, plural, one {File} other {Files}}", "galleries": "{count, plural, one {Gallery} other {Galleries}}", + "groups": "{count, plural, one {Group} other {Groups}}", "images": "{count, plural, one {Image} other {Images}}", "markers": "{count, plural, one {Marker} other {Markers}}", - "movies": "{count, plural, one {Movie} other {Movies}}", "performers": "{count, plural, one {Performer} other {Performers}}", "scenes": "{count, plural, one {Scene} other {Scenes}}", "studios": "{count, plural, one {Studio} other {Studios}}", @@ -1060,6 +1060,10 @@ "TRANSGENDER_FEMALE": "Transgender Female", "TRANSGENDER_MALE": "Transgender Male" }, + "group": "Group", + "group_count": "Group Count", + "group_scene_number": "Scene Number", + "groups": "Groups", "hair_color": "Hair Colour", "handy_connection_status": { "connecting": "Connecting", @@ -1117,10 +1121,6 @@ }, "megabits_per_second": "{value} mbps", "metadata": "Metadata", - "movie": "Movie", - "movie_count": "Movie Count", - "movie_scene_number": "Scene Number", - "movies": "Movies", "name": "Name", "new": "New", "none": "None", 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 90874b70a..6be7d6040 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -173,7 +173,7 @@ export type InputType = | "performer_tags" | "scenes" | "scene_tags" - | "movies" + | "groups" | "galleries" | undefined; diff --git a/ui/v2.5/src/models/list-filter/criteria/movies.ts b/ui/v2.5/src/models/list-filter/criteria/movies.ts index 547fc40b6..391fdb1d2 100644 --- a/ui/v2.5/src/models/list-filter/criteria/movies.ts +++ b/ui/v2.5/src/models/list-filter/criteria/movies.ts @@ -1,9 +1,9 @@ import { ILabeledIdCriterion, ILabeledIdCriterionOption } from "./criterion"; -const inputType = "movies"; +const inputType = "groups"; export const MoviesCriterionOption = new ILabeledIdCriterionOption( - "movies", + "groups", "movies", false, inputType, diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index c25ee9766..9a8e3f5e1 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -47,7 +47,6 @@ const sortByOptions = [ "resume_time", "play_duration", "play_count", - "movie_scene_number", "interactive", "interactive_speed", "perceptual_similarity", @@ -59,6 +58,10 @@ const sortByOptions = [ messageID: "o_count", value: "o_counter", }, + { + messageID: "group_scene_number", + value: "movie_scene_number", + }, ]); const displayModeOptions = [ DisplayMode.Grid, diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index 51df9ed89..dc84a9676 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -36,7 +36,7 @@ const sortByOptions = ["name", "random"] value: "scenes_count", }, { - messageID: "movie_count", + messageID: "group_count", value: "movies_count", }, { @@ -62,7 +62,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("gallery_count"), createMandatoryNumberCriterionOption("performer_count"), createMandatoryNumberCriterionOption("studio_count"), - createMandatoryNumberCriterionOption("movie_count"), + createMandatoryNumberCriterionOption("movie_count", "group_count"), createMandatoryNumberCriterionOption("marker_count"), ParentTagsCriterionOption, new MandatoryNumberCriterionOption("parent_tag_count", "parent_count"), diff --git a/ui/v2.5/src/pluginApi.d.ts b/ui/v2.5/src/pluginApi.d.ts index ae292ceee..9d3aad40f 100644 --- a/ui/v2.5/src/pluginApi.d.ts +++ b/ui/v2.5/src/pluginApi.d.ts @@ -693,12 +693,12 @@ declare namespace PluginApi { function makePerformerScenesUrl(...args: any[]): any; function makePerformerImagesUrl(...args: any[]): any; function makePerformerGalleriesUrl(...args: any[]): any; - function makePerformerMoviesUrl(...args: any[]): any; + function makePerformerGroupsUrl(...args: any[]): any; function makePerformersCountryUrl(...args: any[]): any; function makeStudioScenesUrl(...args: any[]): any; function makeStudioImagesUrl(...args: any[]): any; function makeStudioGalleriesUrl(...args: any[]): any; - function makeStudioMoviesUrl(...args: any[]): any; + function makeStudioGroupsUrl(...args: any[]): any; function makeStudioPerformersUrl(...args: any[]): any; function makeTagUrl(...args: any[]): any; function makeParentTagsUrl(...args: any[]): any; @@ -710,7 +710,7 @@ declare namespace PluginApi { function makeTagImagesUrl(...args: any[]): any; function makeScenesPHashMatchUrl(...args: any[]): any; function makeSceneMarkerUrl(...args: any[]): any; - function makeMovieScenesUrl(...args: any[]): any; + function makeGroupScenesUrl(...args: any[]): any; function makeChildStudiosUrl(...args: any[]): any; function makeGalleryImagesUrl(...args: any[]): any; } diff --git a/ui/v2.5/src/utils/bulkUpdate.ts b/ui/v2.5/src/utils/bulkUpdate.ts index 22b5d3a68..9856c9336 100644 --- a/ui/v2.5/src/utils/bulkUpdate.ts +++ b/ui/v2.5/src/utils/bulkUpdate.ts @@ -81,11 +81,11 @@ export function getAggregateTagIds(state: { tags: IHasID[] }[]) { return getAggregateIds(sortedLists); } -interface IMovie { +interface IGroup { movie: IHasID; } -export function getAggregateMovieIds(state: { movies: IMovie[] }[]) { +export function getAggregateGroupIds(state: { movies: IGroup[] }[]) { const sortedLists = state.map((o) => o.movies.map((oo) => oo.movie.id).sort() ); diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index 864618fd4..c246d699a 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -103,7 +103,7 @@ const makePerformerGalleriesUrl = ( return `/galleries?${filter.makeQueryParameters()}`; }; -const makePerformerMoviesUrl = ( +const makePerformerGroupsUrl = ( performer: Partial, extraPerformer?: ILabeledId, extraCriteria?: Criterion[] @@ -121,7 +121,7 @@ const makePerformerMoviesUrl = ( filter.criteria.push(criterion); addExtraCriteria(filter.criteria, extraCriteria); - return `/movies?${filter.makeQueryParameters()}`; + return `/groups?${filter.makeQueryParameters()}`; }; const makePerformersCountryUrl = ( @@ -174,7 +174,7 @@ const makeStudioGalleriesUrl = (studio: Partial) => { return `/galleries?${filter.makeQueryParameters()}`; }; -const makeStudioMoviesUrl = (studio: Partial) => { +const makeStudioGroupsUrl = (studio: Partial) => { if (!studio.id) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Movies, undefined); const criterion = new StudiosCriterion(); @@ -184,7 +184,7 @@ const makeStudioMoviesUrl = (studio: Partial) => { depth: 0, }; filter.criteria.push(criterion); - return `/movies?${filter.makeQueryParameters()}`; + return `/groups?${filter.makeQueryParameters()}`; }; const makeStudioPerformersUrl = (studio: Partial) => { @@ -211,12 +211,12 @@ const makeChildStudiosUrl = (studio: Partial) => { return `/studios?${filter.makeQueryParameters()}`; }; -const makeMovieScenesUrl = (movie: Partial) => { - if (!movie.id) return "#"; +const makeGroupScenesUrl = (group: Partial) => { + if (!group.id) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); const criterion = new MoviesCriterion(); criterion.value = [ - { id: movie.id, label: movie.name || `Movie ${movie.id}` }, + { id: group.id, label: group.name || `Group ${group.id}` }, ]; filter.criteria.push(criterion); return `/scenes?${filter.makeQueryParameters()}`; @@ -298,8 +298,8 @@ const makeTagImagesUrl = (tag: INamedObject) => { return `/images?${makeTagFilter(GQL.FilterMode.Images, tag)}`; }; -const makeTagMoviesUrl = (tag: INamedObject) => { - return `/movies?${makeTagFilter(GQL.FilterMode.Movies, tag)}`; +const makeTagGroupsUrl = (tag: INamedObject) => { + return `/groups?${makeTagFilter(GQL.FilterMode.Movies, tag)}`; }; type SceneMarkerDataFragment = Pick & { @@ -349,13 +349,13 @@ const makeDirectorScenesUrl = (director: string) => { return `/scenes?${filter.makeQueryParameters()}`; }; -const makeDirectorMoviesUrl = (director: string) => { +const makeDirectorGroupsUrl = (director: string) => { if (director.length == 0) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Movies, undefined); filter.criteria.push( stringEqualsCriterion(createStringCriterionOption("director"), director) ); - return `/movies?${filter.makeQueryParameters()}`; + return `/groups?${filter.makeQueryParameters()}`; }; const makePhotographerGalleriesUrl = (photographer: string) => { @@ -401,12 +401,12 @@ const NavUtils = { makePerformerScenesUrl, makePerformerImagesUrl, makePerformerGalleriesUrl, - makePerformerMoviesUrl, + makePerformerGroupsUrl, makePerformersCountryUrl, makeStudioScenesUrl, makeStudioImagesUrl, makeStudioGalleriesUrl, - makeStudioMoviesUrl, + makeStudioGroupsUrl: makeStudioGroupsUrl, makeStudioPerformersUrl, makeTagUrl, makeParentTagsUrl, @@ -417,16 +417,16 @@ const NavUtils = { makeTagStudiosUrl, makeTagGalleriesUrl, makeTagImagesUrl, - makeTagMoviesUrl, + makeTagGroupsUrl, makeScenesPHashMatchUrl, makeSceneMarkerUrl, - makeMovieScenesUrl, + makeGroupScenesUrl, makeChildStudiosUrl, makeGalleryImagesUrl, makeDirectorScenesUrl, makePhotographerGalleriesUrl, makePhotographerImagesUrl, - makeDirectorMoviesUrl, + makeDirectorGroupsUrl, }; export default NavUtils; From 48035061ec65f27a191e68226487ccc77827cfb4 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 19 Jun 2024 19:52:33 +1000 Subject: [PATCH 17/28] Fix identify clearing parent studio when merging (#4993) * Refactor ScrapedStudio.ToPartial signature * Add unit test * Don't clear parent studio during ToPartial --- internal/identify/studio.go | 6 +- internal/manager/task_stash_box_tag.go | 12 +-- pkg/models/model_scraped_item.go | 8 +- pkg/models/model_scraped_item_test.go | 120 +++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 14 deletions(-) diff --git a/internal/identify/studio.go b/internal/identify/studio.go index d05967bc4..51bcaf2ee 100644 --- a/internal/identify/studio.go +++ b/internal/identify/studio.go @@ -46,17 +46,17 @@ func createMissingStudio(ctx context.Context, endpoint string, w models.StudioRe return nil, err } - studioPartial := s.Parent.ToPartial(s.Parent.StoredID, endpoint, nil, existingStashIDs) + studioPartial := s.Parent.ToPartial(*s.Parent.StoredID, endpoint, nil, existingStashIDs) parentImage, err := s.Parent.GetImage(ctx, nil) if err != nil { return nil, err } - if err := studio.ValidateModify(ctx, *studioPartial, w); err != nil { + if err := studio.ValidateModify(ctx, studioPartial, w); err != nil { return nil, err } - _, err = w.UpdatePartial(ctx, *studioPartial) + _, err = w.UpdatePartial(ctx, studioPartial) if err != nil { return nil, err } diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index 298b58e27..8bb399601 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -311,13 +311,13 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode return err } - partial := s.ToPartial(s.StoredID, t.box.Endpoint, excluded, existingStashIDs) + partial := s.ToPartial(*s.StoredID, t.box.Endpoint, excluded, existingStashIDs) - if err := studio.ValidateModify(ctx, *partial, qb); err != nil { + if err := studio.ValidateModify(ctx, partial, qb); err != nil { return err } - if _, err := qb.UpdatePartial(ctx, *partial); err != nil { + if _, err := qb.UpdatePartial(ctx, partial); err != nil { return err } @@ -435,13 +435,13 @@ func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent * return err } - partial := parent.ToPartial(parent.StoredID, t.box.Endpoint, excluded, existingStashIDs) + partial := parent.ToPartial(*parent.StoredID, t.box.Endpoint, excluded, existingStashIDs) - if err := studio.ValidateModify(ctx, *partial, qb); err != nil { + if err := studio.ValidateModify(ctx, partial, qb); err != nil { return err } - if _, err := qb.UpdatePartial(ctx, *partial); err != nil { + if _, err := qb.UpdatePartial(ctx, partial); err != nil { return err } diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index cb383c082..e5bbdc8dd 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -62,9 +62,9 @@ func (s *ScrapedStudio) GetImage(ctx context.Context, excluded map[string]bool) return nil, nil } -func (s *ScrapedStudio) ToPartial(id *string, endpoint string, excluded map[string]bool, existingStashIDs []StashID) *StudioPartial { +func (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[string]bool, existingStashIDs []StashID) StudioPartial { ret := NewStudioPartial() - ret.ID, _ = strconv.Atoi(*id) + ret.ID, _ = strconv.Atoi(id) if s.Name != "" && !excluded["name"] { ret.Name = NewOptionalString(s.Name) @@ -82,8 +82,6 @@ func (s *ScrapedStudio) ToPartial(id *string, endpoint string, excluded map[stri ret.ParentID = NewOptionalInt(parentID) } } - } else { - ret.ParentID = NewOptionalIntPtr(nil) } if s.RemoteSiteID != nil && endpoint != "" { @@ -97,7 +95,7 @@ func (s *ScrapedStudio) ToPartial(id *string, endpoint string, excluded map[stri }) } - return &ret + return ret } // A performer from a scraping operation... diff --git a/pkg/models/model_scraped_item_test.go b/pkg/models/model_scraped_item_test.go index a6e42f2fd..4093192fa 100644 --- a/pkg/models/model_scraped_item_test.go +++ b/pkg/models/model_scraped_item_test.go @@ -249,3 +249,123 @@ func Test_scrapedToPerformerInput(t *testing.T) { }) } } + +func TestScrapedStudio_ToPartial(t *testing.T) { + var ( + id = 1000 + idStr = strconv.Itoa(id) + storedID = "storedID" + parentStoredID = 2000 + parentStoredIDStr = strconv.Itoa(parentStoredID) + name = "name" + url = "url" + remoteSiteID = "remoteSiteID" + endpoint = "endpoint" + image = "image" + images = []string{image} + + existingEndpoint = "existingEndpoint" + existingStashID = StashID{"existingStashID", existingEndpoint} + existingStashIDs = []StashID{existingStashID} + ) + + fullStudio := ScrapedStudio{ + StoredID: &storedID, + Name: name, + URL: &url, + Parent: &ScrapedStudio{ + StoredID: &parentStoredIDStr, + }, + Image: &image, + Images: images, + RemoteSiteID: &remoteSiteID, + } + + type args struct { + id string + endpoint string + excluded map[string]bool + existingStashIDs []StashID + } + + stdArgs := args{ + id: idStr, + endpoint: endpoint, + excluded: map[string]bool{}, + existingStashIDs: existingStashIDs, + } + + excludeAll := map[string]bool{ + "name": true, + "url": true, + "parent": true, + } + + tests := []struct { + name string + o ScrapedStudio + args args + want StudioPartial + }{ + { + "full no exclusions", + fullStudio, + stdArgs, + StudioPartial{ + ID: id, + Name: NewOptionalString(name), + URL: NewOptionalString(url), + ParentID: NewOptionalInt(parentStoredID), + StashIDs: &UpdateStashIDs{ + StashIDs: append(existingStashIDs, StashID{ + Endpoint: endpoint, + StashID: remoteSiteID, + }), + Mode: RelationshipUpdateModeSet, + }, + }, + }, + { + "exclude all", + fullStudio, + args{ + id: idStr, + excluded: excludeAll, + }, + StudioPartial{ + ID: id, + }, + }, + { + "overwrite stash id", + fullStudio, + args{ + id: idStr, + excluded: excludeAll, + endpoint: existingEndpoint, + existingStashIDs: existingStashIDs, + }, + StudioPartial{ + ID: id, + StashIDs: &UpdateStashIDs{ + StashIDs: []StashID{{ + Endpoint: existingEndpoint, + StashID: remoteSiteID, + }}, + Mode: RelationshipUpdateModeSet, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := tt.o + got := s.ToPartial(tt.args.id, tt.args.endpoint, tt.args.excluded, tt.args.existingStashIDs) + + // unset updatedAt - we don't need to compare it + got.UpdatedAt = OptionalTime{} + + assert.Equal(t, tt.want, got) + }) + } +} From 205b24499bb9136eb263eec67de6c3529a145da3 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 21 Jun 2024 16:15:59 +1000 Subject: [PATCH 18/28] Fix key for tagger scenes (#5000) --- ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx index c8b1c43d9..ab6bd226e 100755 --- a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx @@ -264,7 +264,7 @@ export const Tagger: React.FC = ({ scenes, queue }) => {
    {filteredScenes.map((s, i) => ( Date: Mon, 24 Jun 2024 01:03:29 -0500 Subject: [PATCH 19/28] Address resize loop (#5004) --- .../src/components/Shared/GridCard/GridCard.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx b/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx index 911064a94..1d1a37528 100644 --- a/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx +++ b/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx @@ -42,9 +42,9 @@ interface IDimension { height: number; } -export const useContainerDimensions = < - T extends HTMLElement = HTMLDivElement ->(): [MutableRefObject, IDimension] => { +export const useContainerDimensions = ( + sensitivityThreshold = 20 +): [MutableRefObject, IDimension] => { const target = useRef(null); const [dimension, setDimension] = useState({ width: 0, @@ -53,7 +53,14 @@ export const useContainerDimensions = < useResizeObserver(target, (entry) => { const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0]; - setDimension({ width, height }); + let difference = Math.abs(dimension.width - width); + // Only adjust when width changed by a significant margin. This addresses the cornercase that sees + // the dimensions toggle back and forward when the window is adjusted perfectly such that overflow + // is trigger then immediable disabled because of a resize event then continues this loop endlessly. + // the scrollbar size varies between platforms. Windows is apparently around 17 pixels. + if (difference > sensitivityThreshold) { + setDimension({ width, height }); + } }); return [target, dimension]; From b7f938531b5da06926425ec8f4bc7cc374d9f69f Mon Sep 17 00:00:00 2001 From: dogwithakeyboard <128322708+dogwithakeyboard@users.noreply.github.com> Date: Thu, 27 Jun 2024 01:12:39 +0100 Subject: [PATCH 20/28] Check for null disambiguation on validate (#5019) --- pkg/performer/validate.go | 12 ++++++++---- pkg/performer/validate_test.go | 10 +++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pkg/performer/validate.go b/pkg/performer/validate.go index 0106490cf..68f7a8ef5 100644 --- a/pkg/performer/validate.go +++ b/pkg/performer/validate.go @@ -102,11 +102,15 @@ func validateName(ctx context.Context, name string, disambig string, existingID }, } + modifier := models.CriterionModifierIsNull + if disambig != "" { - performerFilter.Disambiguation = &models.StringCriterionInput{ - Value: disambig, - Modifier: models.CriterionModifierEquals, - } + modifier = models.CriterionModifierEquals + } + + performerFilter.Disambiguation = &models.StringCriterionInput{ + Value: disambig, + Modifier: modifier, } if existingID == nil { diff --git a/pkg/performer/validate_test.go b/pkg/performer/validate_test.go index 778459f17..33f4b1cec 100644 --- a/pkg/performer/validate_test.go +++ b/pkg/performer/validate_test.go @@ -15,6 +15,9 @@ func nameFilter(n string) *models.PerformerFilterType { Value: n, Modifier: models.CriterionModifierEquals, }, + Disambiguation: &models.StringCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, } } @@ -41,13 +44,6 @@ func TestValidateName(t *testing.T) { newName = "new name" newDisambig = "new disambiguation" ) - // existing1 := models.Performer{ - // Name: name1, - // } - // existing2 := models.Performer{ - // Name: name2, - // Disambiguation: disambig, - // } pp := 1 findFilter := &models.FindFilterType{ From e116775d60329a13d8eeda84e8de75f40879b57c Mon Sep 17 00:00:00 2001 From: dogwithakeyboard <128322708+dogwithakeyboard@users.noreply.github.com> Date: Thu, 27 Jun 2024 01:12:39 +0100 Subject: [PATCH 21/28] Check for null disambiguation on validate (#5019) --- pkg/performer/validate.go | 12 ++++++++---- pkg/performer/validate_test.go | 10 +++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pkg/performer/validate.go b/pkg/performer/validate.go index 0106490cf..68f7a8ef5 100644 --- a/pkg/performer/validate.go +++ b/pkg/performer/validate.go @@ -102,11 +102,15 @@ func validateName(ctx context.Context, name string, disambig string, existingID }, } + modifier := models.CriterionModifierIsNull + if disambig != "" { - performerFilter.Disambiguation = &models.StringCriterionInput{ - Value: disambig, - Modifier: models.CriterionModifierEquals, - } + modifier = models.CriterionModifierEquals + } + + performerFilter.Disambiguation = &models.StringCriterionInput{ + Value: disambig, + Modifier: modifier, } if existingID == nil { diff --git a/pkg/performer/validate_test.go b/pkg/performer/validate_test.go index 778459f17..33f4b1cec 100644 --- a/pkg/performer/validate_test.go +++ b/pkg/performer/validate_test.go @@ -15,6 +15,9 @@ func nameFilter(n string) *models.PerformerFilterType { Value: n, Modifier: models.CriterionModifierEquals, }, + Disambiguation: &models.StringCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, } } @@ -41,13 +44,6 @@ func TestValidateName(t *testing.T) { newName = "new name" newDisambig = "new disambiguation" ) - // existing1 := models.Performer{ - // Name: name1, - // } - // existing2 := models.Performer{ - // Name: name2, - // Disambiguation: disambig, - // } pp := 1 findFilter := &models.FindFilterType{ From 2a373a25ca36aff1bac7f375fe61cb69aa4cd3c0 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 27 Jun 2024 09:26:37 +1000 Subject: [PATCH 22/28] Update changelog --- ui/v2.5/src/docs/en/Changelog/v0260.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/v2.5/src/docs/en/Changelog/v0260.md b/ui/v2.5/src/docs/en/Changelog/v0260.md index a3d89d676..8ef0c3551 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0260.md +++ b/ui/v2.5/src/docs/en/Changelog/v0260.md @@ -22,6 +22,11 @@ * Changed umask when creating config file to exclude user write (CVE-2024-32233) ([#4866](https://github.com/stashapp/stash/pull/4866)) ### 🐛 Bug fixes +* **[0.26.2]** Fixed issue where performer could not be created without disambiguation if a performer with the same name and populated disambiguation exists. ([#5019](https://github.com/stashapp/stash/pull/5019)) +* **[0.26.2]** Fix resize loop in grid views. ([#5004](https://github.com/stashapp/stash/pull/5004)) +* **[0.26.2]** Fix query field values duplicating in tagger view when scene list is updated. ([#5000](https://github.com/stashapp/stash/pull/5000)) +* **[0.26.2]** Fix identify clearing parent studio when merging studio field. ([#4993](https://github.com/stashapp/stash/pull/4993)) +* **[0.26.2]** Fix manually selected studio not being applied during scrape. ([#4953](https://github.com/stashapp/stash/pull/4953)) * **[0.26.1]** Fixed identify task defaults not displaying correctly. ([#4931](https://github.com/stashapp/stash/pull/4931)) * **[0.26.1]** Fixed issue where full hardware transcoding did not work where a filter was not required. ([#4934](https://github.com/stashapp/stash/pull/4934)) * **[0.26.1]** Fixed new performer tags not displaying correctly in the performer scrape dialog. ([#4943](https://github.com/stashapp/stash/pull/4943)) From 4244bd0b18a55441192617c59659a7187f967fdd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Jul 2024 11:09:58 +1000 Subject: [PATCH 23/28] Bump golang.org/x/image from 0.16.0 to 0.18.0 (#5021) Bumps [golang.org/x/image](https://github.com/golang/image) from 0.16.0 to 0.18.0. - [Commits](https://github.com/golang/image/compare/v0.16.0...v0.18.0) --- updated-dependencies: - dependency-name: golang.org/x/image dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 9 +++++---- go.sum | 26 +++++++++++++------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index 3056e6a95..67a6f0183 100644 --- a/go.mod +++ b/go.mod @@ -51,11 +51,11 @@ require ( github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e github.com/zencoder/go-dash/v3 v3.0.2 golang.org/x/crypto v0.23.0 - golang.org/x/image v0.16.0 + golang.org/x/image v0.18.0 golang.org/x/net v0.25.0 golang.org/x/sys v0.20.0 golang.org/x/term v0.20.0 - golang.org/x/text v0.15.0 + golang.org/x/text v0.16.0 gopkg.in/guregu/null.v4 v4.0.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -110,8 +110,9 @@ require ( github.com/urfave/cli/v2 v2.8.1 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/mod v0.12.0 // indirect - golang.org/x/tools v0.13.0 // indirect + golang.org/x/mod v0.17.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6fe894ecf..b808cbd74 100644 --- a/go.sum +++ b/go.sum @@ -196,9 +196,9 @@ github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= @@ -300,8 +300,8 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -729,8 +729,8 @@ golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMk golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw= -golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs= +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -758,8 +758,8 @@ golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -840,8 +840,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -952,8 +952,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1019,8 +1019,8 @@ golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From dc3ce2b414cfabbbeaddc2acaf763752cf0126a1 Mon Sep 17 00:00:00 2001 From: barraged1 <49034318+barraged1@users.noreply.github.com> Date: Sun, 30 Jun 2024 21:18:20 -0400 Subject: [PATCH 24/28] updating scrapedPerformerToCreateInput to pass Disambiguation (#5029) --- ui/v2.5/src/core/performers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/v2.5/src/core/performers.ts b/ui/v2.5/src/core/performers.ts index 83a62eac3..455ada9f9 100644 --- a/ui/v2.5/src/core/performers.ts +++ b/ui/v2.5/src/core/performers.ts @@ -92,6 +92,7 @@ export const scrapedPerformerToCreateInput = ( name: toCreate.name ?? "", gender: stringToGender(toCreate.gender), birthdate: toCreate.birthdate, + disambiguation: toCreate.disambiguation, ethnicity: toCreate.ethnicity, country: toCreate.country, eye_color: toCreate.eye_color, From 436ae0a02742e58c9ef99c2eaa56a27eedc0e27f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Jul 2024 11:18:54 +1000 Subject: [PATCH 25/28] Bump ws from 8.16.0 to 8.17.1 in /ui/v2.5 (#4980) Bumps [ws](https://github.com/websockets/ws) from 8.16.0 to 8.17.1. - [Release notes](https://github.com/websockets/ws/releases) - [Commits](https://github.com/websockets/ws/compare/8.16.0...8.17.1) --- updated-dependencies: - dependency-name: ws dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- ui/v2.5/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/yarn.lock b/ui/v2.5/yarn.lock index ca0e9cbf7..e76d7694c 100644 --- a/ui/v2.5/yarn.lock +++ b/ui/v2.5/yarn.lock @@ -8194,9 +8194,9 @@ write-json-file@^4.3.0: write-file-atomic "^3.0.0" ws@^8.12.0, ws@^8.13.0, ws@^8.15.0: - version "8.16.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4" - integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ== + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== xtend@^4.0.0: version "4.0.2" From 4cca3b298d69903d957855ee637174d988c14420 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 1 Jul 2024 11:19:38 +1000 Subject: [PATCH 26/28] Add Opus as supported audio for mp4 (#5030) --- pkg/ffmpeg/browser.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/ffmpeg/browser.go b/pkg/ffmpeg/browser.go index 5e34a5f14..d8bcc0b4f 100644 --- a/pkg/ffmpeg/browser.go +++ b/pkg/ffmpeg/browser.go @@ -20,7 +20,7 @@ var validForHevc = []Container{Mp4} var validAudioForMkv = []ProbeAudioCodec{Aac, Mp3, Vorbis, Opus} var validAudioForWebm = []ProbeAudioCodec{Vorbis, Opus} -var validAudioForMp4 = []ProbeAudioCodec{Aac, Mp3} +var validAudioForMp4 = []ProbeAudioCodec{Aac, Mp3, Opus} var ( // ErrUnsupportedVideoCodecForBrowser is returned when the video codec is not supported for browser streaming. From 70250c93f16349cec6bef25dd7b8e803dc0e1616 Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Mon, 1 Jul 2024 09:15:21 +0300 Subject: [PATCH 27/28] Update translation instance (#5031) Replace (incomplete) flag names with SVG banner. --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 27830b31b..32be39c1e 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,11 @@ Stash can pull metadata (performers, tags, descriptions, studios, and more) dire [StashDB](http://stashdb.org) is the canonical instance of our open source metadata API, [stash-box](https://github.com/stashapp/stash-box). # Translation -[![Translate](https://hosted.weblate.org/widget/stashapp/stash/svg-badge.svg)](https://hosted.weblate.org/engage/stashapp/) -🇧🇷 🇨🇳 🇩🇰 🇳🇱 🇬🇧 🇪🇪 🇫🇮 🇫🇷 🇩🇪 🇮🇹 🇯🇵 🇰🇷 🇵🇱 🇷🇺 🇪🇸 🇸🇪 🇹🇼 🇹🇷 +[![Translate](https://translate.codeberg.org/widget/stash/stash/svg-badge.svg)](https://translate.codeberg.org/engage/stash/) -Stash is available in 25 languages (so far!) and it could be in your language too. We use Weblate to coordinate community translations. If you want to help us translate Stash into your language, you can make an account at [Stash's Weblate](https://hosted.weblate.org/projects/stashapp/stash/) to get started contributing new languages or improving existing ones. Thanks! +Stash is available in 32 languages (so far!) and it could be in your language too. We use Weblate to coordinate community translations. If you want to help us translate Stash into your language, you can make an account at [Codeberg's Weblate](https://translate.codeberg.org.org/projects/stash/stash/) to get started contributing new languages or improving existing ones. Thanks! + +[![Translation status](https://translate.codeberg.org/widget/stash/stash/multi-auto.svg)](https://translate.codeberg.org/engage/stash/) # Support (FAQ) From f477b996b5674500303a33321c8a798383d0958c Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Mon, 1 Jul 2024 17:35:46 +0300 Subject: [PATCH 28/28] Update README.md [skip ci] Fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 32be39c1e..8c35c134c 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Stash can pull metadata (performers, tags, descriptions, studios, and more) dire # Translation [![Translate](https://translate.codeberg.org/widget/stash/stash/svg-badge.svg)](https://translate.codeberg.org/engage/stash/) -Stash is available in 32 languages (so far!) and it could be in your language too. We use Weblate to coordinate community translations. If you want to help us translate Stash into your language, you can make an account at [Codeberg's Weblate](https://translate.codeberg.org.org/projects/stash/stash/) to get started contributing new languages or improving existing ones. Thanks! +Stash is available in 32 languages (so far!) and it could be in your language too. We use Weblate to coordinate community translations. If you want to help us translate Stash into your language, you can make an account at [Codeberg's Weblate](https://translate.codeberg.org/projects/stash/stash/) to get started contributing new languages or improving existing ones. Thanks! [![Translation status](https://translate.codeberg.org/widget/stash/stash/multi-auto.svg)](https://translate.codeberg.org/engage/stash/)