diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index 32857dd80f1..583ae74ca4c 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -114,6 +114,15 @@ fragment ConfigDLNAData on ConfigDLNAResult { videoSortOrder } +fragment ConfigHSPData on ConfigHSPResult { + enabled + favoriteTagId + writeFavorites + writeRatings + writeTags + writeDeletes +} + fragment ConfigScrapingData on ConfigScrapingResult { scraperUserAgent scraperCertCheck @@ -212,6 +221,9 @@ fragment ConfigData on ConfigResult { dlna { ...ConfigDLNAData } + hsp { + ...ConfigHSPData + } scraping { ...ConfigScrapingData } diff --git a/graphql/documents/mutations/config.graphql b/graphql/documents/mutations/config.graphql index dfd53ed757b..05810cdc523 100644 --- a/graphql/documents/mutations/config.graphql +++ b/graphql/documents/mutations/config.graphql @@ -24,6 +24,12 @@ mutation ConfigureDLNA($input: ConfigDLNAInput!) { } } +mutation ConfigureHSP($input: ConfigHSPInput!) { + configureHSP(input: $input) { + ...ConfigHSPData + } +} + mutation ConfigureScraping($input: ConfigScrapingInput!) { configureScraping(input: $input) { ...ConfigScrapingData diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 9c35d103f17..d0e10b8b0f1 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -322,6 +322,7 @@ type Mutation { configureGeneral(input: ConfigGeneralInput!): ConfigGeneralResult! configureInterface(input: ConfigInterfaceInput!): ConfigInterfaceResult! configureDLNA(input: ConfigDLNAInput!): ConfigDLNAResult! + configureHSP(input: ConfigHSPInput!): ConfigHSPResult! configureScraping(input: ConfigScrapingInput!): ConfigScrapingResult! configureDefaults( input: ConfigDefaultSettingsInput! diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 8f439a98823..5200379a02b 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -481,6 +481,36 @@ type ConfigDLNAResult { videoSortOrder: String! } +input ConfigHSPInput { + "True if HSP Api should be enabled by default" + enabled: Boolean + "ID of the favorite tag" + favoriteTagId: Int + "True if writing favorites to HSP Api should be enabled" + writeFavorites: Boolean + "True if writing ratings to HSP Api should be enabled" + writeRatings: Boolean + "True if writing tags to HSP Api should be enabled" + writeTags: Boolean + "True if writing deletes to HSP Api should be enabled" + writeDeletes: Boolean +} + +type ConfigHSPResult { + "True if HSP Api should be enabled by default" + enabled: Boolean! + "ID of the favorite tag" + favoriteTagId: Int! + "True if writing favorites to HSP Api should be enabled" + writeFavorites: Boolean! + "True if writing ratings to HSP Api should be enabled" + writeRatings: Boolean! + "True if writing tags to HSP Api should be enabled" + writeTags: Boolean! + "True if writing deletes to HSP Api should be enabled" + writeDeletes: Boolean! +} + input ConfigScrapingInput { "Scraper user agent string" scraperUserAgent: String @@ -532,6 +562,7 @@ type ConfigResult { general: ConfigGeneralResult! interface: ConfigInterfaceResult! dlna: ConfigDLNAResult! + hsp: ConfigHSPResult! scraping: ConfigScrapingResult! defaults: ConfigDefaultSettingsResult! ui: Map! diff --git a/internal/api/authentication.go b/internal/api/authentication.go index 94b5328f5bf..854bbb1d512 100644 --- a/internal/api/authentication.go +++ b/internal/api/authentication.go @@ -26,7 +26,7 @@ const ( func allowUnauthenticated(r *http.Request) bool { // #2715 - allow access to UI files - return strings.HasPrefix(r.URL.Path, loginEndpoint) || r.URL.Path == logoutEndpoint || r.URL.Path == "/css" || strings.HasPrefix(r.URL.Path, "/assets") + return strings.HasPrefix(r.URL.Path, loginEndpoint) || r.URL.Path == logoutEndpoint || r.URL.Path == "/css" || strings.HasPrefix(r.URL.Path, "/assets") || strings.HasPrefix(r.URL.Path, "/heresphere") } func authenticateHandler() func(http.Handler) http.Handler { @@ -84,7 +84,7 @@ func authenticateHandler() func(http.Handler) http.Handler { return } - prefix := getProxyPrefix(r) + prefix := manager.GetProxyPrefix(r) // otherwise redirect to the login page returnURL := url.URL{ diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index e4a01f83072..c8e9657a271 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -542,6 +542,35 @@ func (r *mutationResolver) ConfigureDlna(ctx context.Context, input ConfigDLNAIn return makeConfigDLNAResult(), nil } +func (r *mutationResolver) ConfigureHsp(ctx context.Context, input ConfigHSPInput) (*ConfigHSPResult, error) { + c := config.GetInstance() + + if input.Enabled != nil { + c.Set(config.HSPDefaultEnabled, *input.Enabled) + } + if input.FavoriteTagID != nil { + c.Set(config.HSPFavoriteTag, *input.FavoriteTagID) + } + if input.WriteFavorites != nil { + c.Set(config.HSPWriteFavorites, *input.WriteFavorites) + } + if input.WriteRatings != nil { + c.Set(config.HSPWriteRating, *input.WriteRatings) + } + if input.WriteTags != nil { + c.Set(config.HSPWriteTags, *input.WriteTags) + } + if input.WriteDeletes != nil { + c.Set(config.HSPWriteDeletes, *input.WriteDeletes) + } + + if err := c.Write(); err != nil { + return makeConfigHSPResult(), err + } + + return makeConfigHSPResult(), nil +} + func (r *mutationResolver) ConfigureScraping(ctx context.Context, input ConfigScrapingInput) (*ConfigScrapingResult, error) { c := config.GetInstance() diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index ce50f57f461..2082e23c6ec 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -64,6 +64,7 @@ func makeConfigResult() *ConfigResult { General: makeConfigGeneralResult(), Interface: makeConfigInterfaceResult(), Dlna: makeConfigDLNAResult(), + Hsp: makeConfigHSPResult(), Scraping: makeConfigScrapingResult(), Defaults: makeConfigDefaultsResult(), UI: makeConfigUIResult(), @@ -203,6 +204,19 @@ func makeConfigDLNAResult() *ConfigDLNAResult { } } +func makeConfigHSPResult() *ConfigHSPResult { + config := config.GetInstance() + + return &ConfigHSPResult{ + Enabled: config.GetHSPDefaultEnabled(), + FavoriteTagID: config.GetHSPFavoriteTag(), + WriteFavorites: config.GetHSPWriteFavorites(), + WriteRatings: config.GetHSPWriteRatings(), + WriteTags: config.GetHSPWriteTags(), + WriteDeletes: config.GetHSPWriteDeletes(), + } +} + func makeConfigScrapingResult() *ConfigScrapingResult { config := config.GetInstance() diff --git a/internal/api/server.go b/internal/api/server.go index d563243e2da..11612ec9ae1 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -30,6 +30,7 @@ import ( "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/internal/build" + "github.com/stashapp/stash/internal/heresphere" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/fsutil" @@ -179,7 +180,7 @@ func Initialize() (*Server, error) { r.HandleFunc(gqlEndpoint, gqlHandlerFunc) r.HandleFunc(playgroundEndpoint, func(w http.ResponseWriter, r *http.Request) { setPageSecurityHeaders(w, r, pluginCache.ListPlugins()) - endpoint := getProxyPrefix(r) + gqlEndpoint + endpoint := manager.GetProxyPrefix(r) + gqlEndpoint gqlPlayground.Handler("GraphQL playground", endpoint)(w, r) }) @@ -191,6 +192,7 @@ func Initialize() (*Server, error) { r.Mount("/tag", server.getTagRoutes()) r.Mount("/downloads", server.getDownloadsRoutes()) r.Mount("/plugin", server.getPluginRoutes()) + r.Mount("/heresphere", server.getHeresphereRoutes()) r.HandleFunc("/css", cssHandler(cfg)) r.HandleFunc("/javascript", javascriptHandler(cfg)) @@ -237,7 +239,7 @@ func Initialize() (*Server, error) { } indexHtml := string(data) - prefix := getProxyPrefix(r) + prefix := manager.GetProxyPrefix(r) indexHtml = strings.ReplaceAll(indexHtml, "%COLOR%", themeColor) indexHtml = strings.Replace(indexHtml, ` 0 && apiKey == config.GetInstance().GetAPIKey() +} + +/* + * This auxiliary function adds an auth token to a url + */ +func addApiKey(urlS string) string { + // Parse URL + u, err := url.Parse(urlS) + if err != nil { + // shouldn't happen + panic(err) + } + + // Add apikey if applicable + if config.GetInstance().GetAPIKey() != "" { + v := u.Query() + if !v.Has("apikey") { + v.Set("apikey", config.GetInstance().GetAPIKey()) + } + u.RawQuery = v.Encode() + } + + return u.String() +} + +/* + * This auxiliary writes a library with a fake name upon auth failure + */ +func writeNotAuthorized(w http.ResponseWriter, r *http.Request, msg string) { + // Banner + banner := HeresphereBanner{ + Image: fmt.Sprintf("%s%s", + manager.GetBaseURL(r), + "/apple-touch-icon.png", + ), + Link: fmt.Sprintf("%s%s", + manager.GetBaseURL(r), + "/", + ), + } + // Default video + library := HeresphereIndexEntry{ + Name: msg, + List: []string{}, + } + // Index + idx := HeresphereIndex{ + Access: HeresphereBadLogin, + Banner: banner, + Library: []HeresphereIndexEntry{library}, + } + + // Create a JSON encoder for the response writer + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + if err := enc.Encode(idx); err != nil { + logger.Errorf("Heresphere writeNotAuthorized error: %s\n", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} diff --git a/internal/heresphere/favorite.go b/internal/heresphere/favorite.go new file mode 100644 index 00000000000..83390508af2 --- /dev/null +++ b/internal/heresphere/favorite.go @@ -0,0 +1,88 @@ +package heresphere + +import ( + "context" + "fmt" + "net/http" + + "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scene" +) + +/* + * Searches for favorite tag if it exists, otherwise adds it. + * This adds a tag, which means tags must also be enabled, or it will never be written. + */ +func (rs routes) handleFavoriteTag(ctx context.Context, scn *models.Scene, user *HeresphereAuthReq, ret *scene.UpdateSet) (bool, error) { + tagID := config.GetInstance().GetHSPFavoriteTag() + + favTag, err := func() (*models.Tag, error) { + var tag *models.Tag + var err error + err = rs.withReadTxn(ctx, func(ctx context.Context) error { + tag, err = rs.TagFinder.Find(ctx, tagID) + return err + }) + return tag, err + }() + + if err != nil { + logger.Errorf("Heresphere handleFavoriteTag Tag.Find error: %s\n", err.Error()) + return false, err + } + + if favTag == nil { + return false, nil + } + + favTagVal := HeresphereVideoTag{Name: fmt.Sprintf("Tag:%s", favTag.Name)} + + if user.Tags == nil { + sceneTags := rs.getVideoTags(ctx, scn) + user.Tags = &sceneTags + } + + if *user.IsFavorite { + *user.Tags = append(*user.Tags, favTagVal) + } else { + for i, tag := range *user.Tags { + if tag.Name == favTagVal.Name { + *user.Tags = append((*user.Tags)[:i], (*user.Tags)[i+1:]...) + break + } + } + } + + return true, nil +} + +/* + * This auxiliary function searches for the "favorite" tag + */ +func (rs routes) getVideoFavorite(r *http.Request, scene *models.Scene) bool { + tagIDs, err := func() ([]*models.Tag, error) { + var tags []*models.Tag + var err error + err = rs.withReadTxn(r.Context(), func(ctx context.Context) error { + tags, err = rs.TagFinder.FindBySceneID(ctx, scene.ID) + return err + }) + return tags, err + }() + + if err != nil { + logger.Errorf("Heresphere getVideoFavorite error: %s\n", err.Error()) + return false + } + + favTag := config.GetInstance().GetHSPFavoriteTag() + for _, tag := range tagIDs { + if tag.ID == favTag { + return true + } + } + + return false +} diff --git a/internal/heresphere/filter_structs.go b/internal/heresphere/filter_structs.go new file mode 100644 index 00000000000..61067284594 --- /dev/null +++ b/internal/heresphere/filter_structs.go @@ -0,0 +1,283 @@ +package heresphere + +import ( + "github.com/stashapp/stash/pkg/models" +) + +type StringSingletonInput struct { + Value string `json:"value"` +} + +// Technically the "modifier" in IntCriterionInput is redundant when i do this, meh +type IntCriterionStored struct { + Modifier models.CriterionModifier `json:"modifier"` + Value models.IntCriterionInput `json:"value"` +} + +func (m IntCriterionStored) ToOriginal() *models.IntCriterionInput { + obj := m.Value + obj.Modifier = m.Modifier + return &obj +} + +type DateCriterionStored struct { + Modifier models.CriterionModifier `json:"modifier"` + Value models.DateCriterionInput `json:"value"` +} + +func (m DateCriterionStored) ToOriginal() *models.DateCriterionInput { + obj := m.Value + obj.Modifier = m.Modifier + return &obj +} + +type TimeCriterionStored struct { + Modifier models.CriterionModifier `json:"modifier"` + Value models.TimestampCriterionInput `json:"value"` +} + +func (m TimeCriterionStored) ToOriginal() *models.TimestampCriterionInput { + obj := m.Value + obj.Modifier = m.Modifier + return &obj +} + +type HierarchicalMultiCriterionInputStoredEntry struct { + Id string `json:"id"` + Label string `json:"label"` +} +type HierarchicalMultiCriterionInputStoredAux struct { + Items *[]HierarchicalMultiCriterionInputStoredEntry `json:"items"` + Depth *int `json:"depth"` + Excludes *[]HierarchicalMultiCriterionInputStoredEntry `json:"excludes"` +} +type HierarchicalMultiCriterionInputStored struct { + Modifier models.CriterionModifier `json:"modifier"` + Value HierarchicalMultiCriterionInputStoredAux `json:"value"` +} + +func (m HierarchicalMultiCriterionInputStored) ToHierarchicalCriterion() *models.HierarchicalMultiCriterionInput { + data := &models.HierarchicalMultiCriterionInput{ + Value: []string{}, + Modifier: m.Modifier, + Depth: m.Value.Depth, + Excludes: []string{}, + } + + if m.Value.Items != nil { + for _, entry := range *m.Value.Items { + data.Value = append(data.Value, entry.Id) + } + } + if m.Value.Excludes != nil { + for _, entry := range *m.Value.Excludes { + data.Excludes = append(data.Excludes, entry.Id) + } + } + + return data +} +func (m HierarchicalMultiCriterionInputStored) ToMultiCriterion() *models.MultiCriterionInput { + filled := m.ToHierarchicalCriterion() + return &models.MultiCriterionInput{ + Value: filled.Value, + Modifier: m.Modifier, + Excludes: filled.Excludes, + } +} + +type SceneFilterTypeStored struct { + And *SceneFilterTypeStored `json:"AND"` + Or *SceneFilterTypeStored `json:"OR"` + Not *SceneFilterTypeStored `json:"NOT"` + ID *IntCriterionStored `json:"id"` + Title *models.StringCriterionInput `json:"title"` + Code *models.StringCriterionInput `json:"code"` + Details *models.StringCriterionInput `json:"details"` + Director *models.StringCriterionInput `json:"director"` + // Filter by file oshash + Oshash *models.StringCriterionInput `json:"oshash"` + // Filter by file checksum + Checksum *models.StringCriterionInput `json:"checksum"` + // Filter by file phash + Phash *models.StringCriterionInput `json:"phash"` + // Filter by phash distance + PhashDistance *models.PhashDistanceCriterionInput `json:"phash_distance"` + // Filter by path + Path *models.StringCriterionInput `json:"path"` + // Filter by file count + FileCount *IntCriterionStored `json:"file_count"` + // Filter by rating expressed as 1-100 + Rating100 *IntCriterionStored `json:"rating100"` + // Filter by organized + Organized *StringSingletonInput `json:"organized"` + // Filter by o-counter + OCounter *IntCriterionStored `json:"o_counter"` + // Filter Scenes that have an exact phash match available + Duplicated *models.PHashDuplicationCriterionInput `json:"duplicated"` + // Filter by resolution + Resolution *models.ResolutionCriterionInput `json:"resolution"` + // Filter by video codec + VideoCodec *models.StringCriterionInput `json:"video_codec"` + // Filter by audio codec + AudioCodec *models.StringCriterionInput `json:"audio_codec"` + // Filter by duration (in seconds) + Duration *IntCriterionStored `json:"duration"` + // Filter to only include scenes which have markers. `true` or `false` + HasMarkers *models.StringCriterionInput `json:"has_markers"` + // Filter to only include scenes missing this property + IsMissing *models.StringCriterionInput `json:"is_missing"` + // Filter to only include scenes with this studio + Studios *HierarchicalMultiCriterionInputStored `json:"studios"` + // Filter to only include scenes with this movie + Movies *HierarchicalMultiCriterionInputStored `json:"movies"` + // Filter to only include scenes with these tags + Tags *HierarchicalMultiCriterionInputStored `json:"tags"` + // Filter by tag count + TagCount *IntCriterionStored `json:"tag_count"` + // Filter to only include scenes with performers with these tags + PerformerTags *HierarchicalMultiCriterionInputStored `json:"performer_tags"` + // Filter scenes that have performers that have been favorited + PerformerFavorite *StringSingletonInput `json:"performer_favorite"` + // Filter scenes by performer age at time of scene + PerformerAge *IntCriterionStored `json:"performer_age"` + // Filter to only include scenes with these performers + Performers *HierarchicalMultiCriterionInputStored `json:"performers"` + // Filter by performer count + PerformerCount *IntCriterionStored `json:"performer_count"` + // Filter by StashID + StashID *models.StringCriterionInput `json:"stash_id"` + // Filter by StashID Endpoint + StashIDEndpoint *models.StashIDCriterionInput `json:"stash_id_endpoint"` + // Filter by url + URL *models.StringCriterionInput `json:"url"` + // Filter by interactive + Interactive *StringSingletonInput `json:"interactive"` + // Filter by InteractiveSpeed + InteractiveSpeed *IntCriterionStored `json:"interactive_speed"` + // Filter by captions + Captions *models.StringCriterionInput `json:"captions"` + // Filter by resume time + ResumeTime *IntCriterionStored `json:"resume_time"` + // Filter by play count + PlayCount *IntCriterionStored `json:"play_count"` + // Filter by play duration (in seconds) + PlayDuration *IntCriterionStored `json:"play_duration"` + // Filter by date + Date *DateCriterionStored `json:"date"` + // Filter by created at + CreatedAt *TimeCriterionStored `json:"created_at"` + // Filter by updated at + UpdatedAt *TimeCriterionStored `json:"updated_at"` +} + +func (fsf SceneFilterTypeStored) ToOriginal() *models.SceneFilterType { + model := models.SceneFilterType{ + Title: fsf.Title, + Code: fsf.Code, + Details: fsf.Details, + Director: fsf.Director, + Oshash: fsf.Oshash, + Checksum: fsf.Checksum, + Phash: fsf.Phash, + Path: fsf.Path, + Duplicated: fsf.Duplicated, + Resolution: fsf.Resolution, + VideoCodec: fsf.VideoCodec, + AudioCodec: fsf.AudioCodec, + StashID: fsf.StashID, + StashIDEndpoint: fsf.StashIDEndpoint, + URL: fsf.URL, + Captions: fsf.Captions, + } + + if fsf.And != nil { + model.And = fsf.And.ToOriginal() + } + if fsf.Or != nil { + model.Or = fsf.Or.ToOriginal() + } + if fsf.Not != nil { + model.Not = fsf.Not.ToOriginal() + } + if fsf.ID != nil { + model.ID = fsf.ID.ToOriginal() + } + if fsf.FileCount != nil { + model.FileCount = fsf.FileCount.ToOriginal() + } + if fsf.Rating100 != nil { + model.Rating100 = fsf.Rating100.ToOriginal() + } + if fsf.Organized != nil { + b := fsf.Organized.Value == "true" + model.Organized = &b + } + if fsf.OCounter != nil { + model.OCounter = fsf.OCounter.ToOriginal() + } + if fsf.Duration != nil { + model.Duration = fsf.Duration.ToOriginal() + } + if fsf.HasMarkers != nil { + model.HasMarkers = &fsf.HasMarkers.Value + } + if fsf.IsMissing != nil { + model.IsMissing = &fsf.IsMissing.Value + } + if fsf.Studios != nil { + model.Studios = fsf.Studios.ToHierarchicalCriterion() + } + if fsf.Movies != nil { + model.Movies = fsf.Movies.ToMultiCriterion() + } + if fsf.Tags != nil { + model.Tags = fsf.Tags.ToHierarchicalCriterion() + } + if fsf.TagCount != nil { + model.TagCount = fsf.TagCount.ToOriginal() + } + if fsf.PerformerTags != nil { + model.PerformerTags = fsf.PerformerTags.ToHierarchicalCriterion() + } + if fsf.PerformerFavorite != nil { + b := fsf.PerformerFavorite.Value == "true" + model.PerformerFavorite = &b + } + if fsf.PerformerAge != nil { + model.PerformerAge = fsf.PerformerAge.ToOriginal() + } + if fsf.Performers != nil { + model.Performers = fsf.Performers.ToMultiCriterion() + } + if fsf.PerformerCount != nil { + model.PerformerCount = fsf.PerformerCount.ToOriginal() + } + if fsf.Interactive != nil { + b := fsf.Interactive.Value == "true" + model.Interactive = &b + } + if fsf.InteractiveSpeed != nil { + model.InteractiveSpeed = fsf.InteractiveSpeed.ToOriginal() + } + if fsf.ResumeTime != nil { + model.ResumeTime = fsf.ResumeTime.ToOriginal() + } + if fsf.PlayCount != nil { + model.PlayCount = fsf.PlayCount.ToOriginal() + } + if fsf.PlayDuration != nil { + model.PlayDuration = fsf.PlayDuration.ToOriginal() + } + if fsf.Date != nil { + model.Date = fsf.Date.ToOriginal() + } + if fsf.CreatedAt != nil { + model.CreatedAt = fsf.CreatedAt.ToOriginal() + } + if fsf.UpdatedAt != nil { + model.UpdatedAt = fsf.UpdatedAt.ToOriginal() + } + + return &model +} diff --git a/internal/heresphere/filters.go b/internal/heresphere/filters.go new file mode 100644 index 00000000000..d41c0d30a1b --- /dev/null +++ b/internal/heresphere/filters.go @@ -0,0 +1,122 @@ +package heresphere + +import ( + "context" + "fmt" + + "github.com/mitchellh/mapstructure" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" +) + +func parseObjectFilter(sf *models.SavedFilter) (*models.SceneFilterType, error) { + var result SceneFilterTypeStored + + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + Result: &result, + TagName: "json", + ErrorUnused: false, + ErrorUnset: false, + WeaklyTypedInput: true, + }) + if err != nil { + return nil, fmt.Errorf("error creating decoder: %s", err) + } + + if err := decoder.Decode(sf.ObjectFilter); err != nil { + return nil, fmt.Errorf("error decoding map to struct: %s", err) + } + + return result.ToOriginal(), nil +} + +func (rs routes) getAllFilters(ctx context.Context) (scenesMap map[string][]int, err error) { + scenesMap = make(map[string][]int) // Initialize scenesMap + + savedfilters, err := func() ([]*models.SavedFilter, error) { + var filters []*models.SavedFilter + err = rs.withReadTxn(ctx, func(ctx context.Context) error { + filters, err = rs.FilterFinder.FindByMode(ctx, models.FilterModeScenes) + return err + }) + return filters, err + }() + + if err != nil { + err = fmt.Errorf("heresphere FilterTest SavedFilter.FindByMode error: %s", err.Error()) + return + } + + dfilter, err := func() (*models.SavedFilter, error) { + var filter *models.SavedFilter + err = rs.withReadTxn(ctx, func(ctx context.Context) error { + filter, err = rs.FilterFinder.FindDefault(ctx, models.FilterModeScenes) + return err + }) + return filter, err + }() + + if err != nil { + err = fmt.Errorf("heresphere FilterTest SavedFilter.FindDefault error: %s", err.Error()) + return + } + + dfilter.Name = "Default" + savedfilters = append(savedfilters, dfilter) + + for _, savedfilter := range savedfilters { + filter := savedfilter.FindFilter + sceneFilter, err := parseObjectFilter(savedfilter) + + if err != nil { + logger.Errorf("Heresphere FilterTest parseObjectFilter error: %s\n", err.Error()) + continue + } + + if filter != nil && filter.Q != nil && len(*filter.Q) > 0 { + sceneFilter.Path = &models.StringCriterionInput{ + Modifier: models.CriterionModifierMatchesRegex, + Value: "(?i)" + *filter.Q, + } + } + + // make a copy of the filter if provided, nilling out Q + var queryFilter *models.FindFilterType + if filter != nil { + f := *filter + queryFilter = &f + queryFilter.Q = nil + + page := 0 + perpage := -1 + queryFilter.Page = &page + queryFilter.PerPage = &perpage + } + + var scenes *models.SceneQueryResult + err = rs.withReadTxn(ctx, func(ctx context.Context) error { + var err error + scenes, err = rs.SceneFinder.Query(ctx, models.SceneQueryOptions{ + QueryOptions: models.QueryOptions{ + FindFilter: queryFilter, + Count: false, + }, + SceneFilter: sceneFilter, + TotalDuration: false, + TotalSize: false, + }) + + return err + }) + + if err != nil { + logger.Errorf("Heresphere FilterTest SceneQuery error: %s\n", err.Error()) + continue + } + + name := savedfilter.Name + scenesMap[name] = append(scenesMap[name], scenes.QueryResult.IDs...) + } + + return +} diff --git a/internal/heresphere/interfaces.go b/internal/heresphere/interfaces.go new file mode 100644 index 00000000000..7b1e5bfa88c --- /dev/null +++ b/internal/heresphere/interfaces.go @@ -0,0 +1,69 @@ +package heresphere + +import ( + "context" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/plugin" + "github.com/stashapp/stash/pkg/txn" +) + +// Repository provides access to storage methods for files and folders. +type repository struct { + TxnManager models.TxnManager +} + +func (r *repository) withTxn(ctx context.Context, fn txn.TxnFunc) error { + return txn.WithTxn(ctx, r.TxnManager, fn) +} + +func (r *repository) withReadTxn(ctx context.Context, fn txn.TxnFunc) error { + return txn.WithReadTxn(ctx, r.TxnManager, fn) +} + +type sceneFinder interface { + models.SceneGetter + models.SceneReader + models.SceneWriter +} + +type sceneMarkerFinder interface { + models.SceneMarkerFinder + models.SceneMarkerCreator + models.SceneMarkerReader +} + +type tagFinder interface { + models.TagFinder + models.TagCreator +} + +type fileFinder interface { + models.FileFinder + models.FileReader + models.FileDestroyer +} + +type savedfilterFinder interface { + models.SavedFilterReader +} + +type performerFinder interface { + models.PerformerFinder +} + +type galleryFinder interface { + models.GalleryFinder +} + +type movieFinder interface { + models.MovieFinder +} + +type studioFinder interface { + models.StudioFinder +} + +type hookExecutor interface { + ExecutePostHooks(ctx context.Context, id int, hookType plugin.HookTriggerEnum, input interface{}, inputFields []string) +} diff --git a/internal/heresphere/keys.go b/internal/heresphere/keys.go new file mode 100644 index 00000000000..4e3f90f00c7 --- /dev/null +++ b/internal/heresphere/keys.go @@ -0,0 +1,8 @@ +package heresphere + +type key int + +const ( + sceneKey key = iota + 1 + authKey +) diff --git a/internal/heresphere/media.go b/internal/heresphere/media.go new file mode 100644 index 00000000000..297ba1d36b0 --- /dev/null +++ b/internal/heresphere/media.go @@ -0,0 +1,172 @@ +package heresphere + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "github.com/stashapp/stash/internal/api/urlbuilders" + "github.com/stashapp/stash/internal/manager" + "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/txn" +) + +/* + * Returns the primary media source + */ +func getPrimaryMediaSource(rs routes, r *http.Request, scene *models.Scene) HeresphereVideoMediaSource { + mediaFile := scene.Files.Primary() + if mediaFile == nil { + return HeresphereVideoMediaSource{} // Return empty source if no primary file + } + + sourceUrl := urlbuilders.NewSceneURLBuilder(manager.GetBaseURL(r), scene).GetStreamURL("").String() + sourceUrlWithApiKey := addApiKey(sourceUrl) + + return HeresphereVideoMediaSource{ + Resolution: mediaFile.Height, + Height: mediaFile.Height, + Width: mediaFile.Width, + Size: mediaFile.Size, + Url: sourceUrlWithApiKey, + } +} + +/* + * This auxiliary function gathers a script if applicable + */ +func (rs routes) getVideoScripts(r *http.Request, scene *models.Scene) []HeresphereVideoScript { + processedScripts := []HeresphereVideoScript{} + + primaryFile := scene.Files.Primary() + if primaryFile != nil && primaryFile.Interactive { + processedScript := HeresphereVideoScript{ + Name: "Default script", + Url: addApiKey(urlbuilders.NewSceneURLBuilder(manager.GetBaseURL(r), scene).GetFunscriptURL()), + Rating: 5, + } + processedScripts = append(processedScripts, processedScript) + } + + return processedScripts +} + +/* + * This auxiliary function gathers subtitles if applicable + */ +func (rs routes) getVideoSubtitles(r *http.Request, scene *models.Scene) []HeresphereVideoSubtitle { + processedSubtitles := make([]HeresphereVideoSubtitle, 0) + + primaryFile := scene.Files.Primary() + if primaryFile != nil { + captions, err := func() ([]*models.VideoCaption, error) { + var captions []*models.VideoCaption + var err error + err = rs.withReadTxn(r.Context(), func(ctx context.Context) error { + captions, err = rs.FileFinder.GetCaptions(ctx, primaryFile.ID) + return err + }) + return captions, err + }() + + if err != nil { + logger.Errorf("Heresphere getVideoSubtitles error: %s\n", err.Error()) + return processedSubtitles + } + + for _, caption := range captions { + processedCaption := HeresphereVideoSubtitle{ + Name: caption.Filename, + Language: caption.LanguageCode, + Url: addApiKey(fmt.Sprintf("%s?lang=%s&type=%s", + urlbuilders.NewSceneURLBuilder(manager.GetBaseURL(r), scene).GetCaptionURL(), + caption.LanguageCode, + caption.CaptionType, + )), + } + processedSubtitles = append(processedSubtitles, processedCaption) + } + } + + return processedSubtitles +} + +/* + * Function to get transcoded media sources + */ +func getTranscodedMediaSources(sceneURL string, transcodeSize int, mediaFile *models.VideoFile) map[string][]HeresphereVideoMediaSource { + transcodedSources := make(map[string][]HeresphereVideoMediaSource) + transNames := []string{"HLS", "DASH"} + resRatio := float32(mediaFile.Width) / float32(mediaFile.Height) + + for i, trans := range []string{".m3u8", ".mpd"} { + for _, res := range models.AllStreamingResolutionEnum { + if transcodeSize == 0 || transcodeSize >= res.GetMaxResolution() { + if height := res.GetMaxResolution(); height <= mediaFile.Height { + transcodedUrl, err := url.Parse(sceneURL + trans) + if err != nil { + panic(err) + } + q := transcodedUrl.Query() + q.Add("resolution", res.String()) + transcodedUrl.RawQuery = q.Encode() + + processedEntry := HeresphereVideoMediaSource{ + Resolution: height, + Height: height, + Width: int(resRatio * float32(height)), + Size: 0, + Url: transcodedUrl.String(), + } + + typeName := transNames[i] + transcodedSources[typeName] = append(transcodedSources[typeName], processedEntry) + } + } + } + } + + return transcodedSources +} + +/* + * Main function to gather media information and transcoding options + */ +func (rs routes) getVideoMedia(r *http.Request, scene *models.Scene) []HeresphereVideoMedia { + processedMedia := []HeresphereVideoMedia{} + + if err := txn.WithTxn(r.Context(), rs.TxnManager, func(ctx context.Context) error { + return scene.LoadPrimaryFile(ctx, rs.FileFinder) + }); err != nil { + logger.Errorf("Heresphere getVideoMedia error: %s\n", err.Error()) + return processedMedia + } + + primarySource := getPrimaryMediaSource(rs, r, scene) + if primarySource.Url != "" { + processedMedia = append(processedMedia, HeresphereVideoMedia{ + Name: "direct stream", + Sources: []HeresphereVideoMediaSource{primarySource}, + }) + } + + mediaFile := scene.Files.Primary() + if mediaFile != nil { + sceneURL := urlbuilders.NewSceneURLBuilder(manager.GetBaseURL(r), scene).GetStreamURL(config.GetInstance().GetAPIKey()).String() + transcodeSize := config.GetInstance().GetMaxStreamingTranscodeSize().GetMaxResolution() + transcodedSources := getTranscodedMediaSources(sceneURL, transcodeSize, mediaFile) + + // Reconstruct tables for transcoded sources + for codec, sources := range transcodedSources { + processedMedia = append(processedMedia, HeresphereVideoMedia{ + Name: codec, + Sources: sources, + }) + } + } + + return processedMedia +} diff --git a/internal/heresphere/projections.go b/internal/heresphere/projections.go new file mode 100644 index 00000000000..ce21d30d350 --- /dev/null +++ b/internal/heresphere/projections.go @@ -0,0 +1,116 @@ +package heresphere + +import ( + "strconv" + "strings" + + "github.com/stashapp/stash/pkg/models" +) + +/* + * Reads relevant VR tags and sets projection settings + */ +func findProjectionTagsFromTags(processedScene *HeresphereVideoEntry, tags []HeresphereVideoTag) { + for _, tag := range tags { + tagPre := strings.TrimPrefix(tag.Name, "Tag:") + + // Has degrees tag + if strings.HasSuffix(tagPre, "°") { + deg := strings.TrimSuffix(tagPre, "°") + if s, err := strconv.ParseFloat(deg, 64); err == nil { + processedScene.Fov = s + } + } + // Has VR tag + vrTag, err := getVrTag() + if err == nil && tagPre == vrTag { + if processedScene.Projection == HeresphereProjectionPerspective { + processedScene.Projection = HeresphereProjectionEquirectangular + } + if processedScene.Stereo == HeresphereStereoMono { + processedScene.Stereo = HeresphereStereoSbs + } + } + // Has Fisheye tag + if tagPre == "Fisheye" { + processedScene.Projection = HeresphereProjectionFisheye + if processedScene.Stereo == HeresphereStereoMono { + processedScene.Stereo = HeresphereStereoSbs + } + } + } +} + +/* + * Reads relevant VR strings from a filename and sets projection settings + */ +func findProjectionTagsFromFilename(processedScene *HeresphereVideoEntry, filename string) { + path := strings.ToUpper(filename) + + // Stereo settings + if strings.Contains(path, "_LR") || strings.Contains(path, "_3DH") { + processedScene.Stereo = HeresphereStereoSbs + } + if strings.Contains(path, "_RL") { + processedScene.Stereo = HeresphereStereoSbs + processedScene.IsEyeSwapped = true + } + if strings.Contains(path, "_TB") || strings.Contains(path, "_3DV") { + processedScene.Stereo = HeresphereStereoTB + } + if strings.Contains(path, "_BT") { + processedScene.Stereo = HeresphereStereoTB + processedScene.IsEyeSwapped = true + } + + // Projection settings + if strings.Contains(path, "_EAC360") || strings.Contains(path, "_360EAC") { + processedScene.Projection = HeresphereProjectionEquirectangularCubemap + processedScene.Fov = 360.0 + } + if strings.Contains(path, "_360") { + processedScene.Projection = HeresphereProjectionEquirectangular360 + processedScene.Fov = 360.0 + } + if strings.Contains(path, "_F180") || strings.Contains(path, "_180F") || strings.Contains(path, "_VR180") { + processedScene.Projection = HeresphereProjectionFisheye + processedScene.Fov = 180.0 + } else if strings.Contains(path, "_180") { + processedScene.Projection = HeresphereProjectionEquirectangular + processedScene.Fov = 180.0 + } + if strings.Contains(path, "_MKX200") { + processedScene.Projection = HeresphereProjectionFisheye + processedScene.Fov = 200.0 + processedScene.Lens = HeresphereLensMKX200 + } + if strings.Contains(path, "_MKX220") { + processedScene.Projection = HeresphereProjectionFisheye + processedScene.Fov = 220.0 + processedScene.Lens = HeresphereLensMKX220 + } + if strings.Contains(path, "_FISHEYE") { + processedScene.Projection = HeresphereProjectionFisheye + } + if strings.Contains(path, "_RF52") || strings.Contains(path, "_FISHEYE190") { + processedScene.Projection = HeresphereProjectionFisheye + processedScene.Fov = 190.0 + } + if strings.Contains(path, "_VRCA220") { + processedScene.Projection = HeresphereProjectionFisheye + processedScene.Fov = 220.0 + processedScene.Lens = HeresphereLensVRCA220 + } +} + +/* + * This auxiliary function finds vr projection modes from tags and the filename. + */ +func FindProjectionTags(scene *models.Scene, processedScene *HeresphereVideoEntry) { + findProjectionTagsFromTags(processedScene, processedScene.Tags) + + file := scene.Files.Primary() + if file != nil { + findProjectionTagsFromFilename(processedScene, file.Basename) + } +} diff --git a/internal/heresphere/routes.go b/internal/heresphere/routes.go new file mode 100644 index 00000000000..170c9655fae --- /dev/null +++ b/internal/heresphere/routes.go @@ -0,0 +1,498 @@ +package heresphere + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/stashapp/stash/internal/api/urlbuilders" + "github.com/stashapp/stash/internal/manager" + "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scene" +) + +type HeresphereCustomTag string + +const ( + HeresphereCustomTagInteractive HeresphereCustomTag = "Interactive" + + HeresphereCustomTagPlayCount HeresphereCustomTag = "PlayCount" + HeresphereCustomTagWatched HeresphereCustomTag = "Watched" + + HeresphereCustomTagOrganized HeresphereCustomTag = "Organized" + + HeresphereCustomTagOCounter HeresphereCustomTag = "OCounter" + HeresphereCustomTagOrgasmed HeresphereCustomTag = "Orgasmed" + + HeresphereCustomTagRated HeresphereCustomTag = "Rated" +) + +type routes struct { + repository + SceneFinder sceneFinder + SceneService manager.SceneService + SceneMarkerFinder sceneMarkerFinder + FileFinder fileFinder + TagFinder tagFinder + FilterFinder savedfilterFinder + PerformerFinder performerFinder + GalleryFinder galleryFinder + MovieFinder movieFinder + StudioFinder studioFinder + HookExecutor hookExecutor +} + +func GetRoutes(repo models.Repository) chi.Router { + return routes{ + repository: repository{TxnManager: repo.TxnManager}, + SceneFinder: repo.Scene, + SceneService: manager.GetInstance().SceneService, + SceneMarkerFinder: repo.SceneMarker, + FileFinder: repo.File, + TagFinder: repo.Tag, + FilterFinder: repo.SavedFilter, + PerformerFinder: repo.Performer, + GalleryFinder: repo.Gallery, + MovieFinder: repo.Movie, + StudioFinder: repo.Studio, + HookExecutor: manager.GetInstance().PluginCache, + }.Routes() +} + +/* + * This function provides the possible routes for this api. + */ +func (rs routes) Routes() chi.Router { + r := chi.NewRouter() + + r.Route("/", func(r chi.Router) { + r.Use(rs.heresphereCtx) + + r.Post("/", rs.heresphereIndex) + r.Get("/", rs.heresphereIndex) + r.Head("/", rs.heresphereIndex) + + r.Post("/auth", rs.heresphereLoginToken) + r.Route("/{sceneId}", func(r chi.Router) { + r.Use(rs.heresphereSceneCtx) + + r.Post("/", rs.heresphereVideoData) + r.Get("/", rs.heresphereVideoData) + + r.Post("/event", rs.heresphereVideoEvent) + }) + }) + + return r +} + +var ( + idMap = make(map[string]string) +) + +/* + * This is a video playback event + * Intended for server-sided script playback. + * But since we dont need that, we just use it for timestamps. + */ +func (rs routes) heresphereVideoEvent(w http.ResponseWriter, r *http.Request) { + // Get the scene from the request context + scn := r.Context().Value(sceneKey).(*models.Scene) + + // Decode the JSON request body into the HeresphereVideoEvent struct + var event HeresphereVideoEvent + err := json.NewDecoder(r.Body).Decode(&event) + if err != nil { + // Handle JSON decoding error + logger.Errorf("Heresphere HeresphereVideoEvent decode error: %s\n", err.Error()) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Convert time from milliseconds to seconds + newTime := event.Time / 1000 + newDuration := 0.0 + + // Calculate new duration if necessary + // (if HeresphereEventPlay then its most likely a "skip" event) + if newTime > scn.ResumeTime && event.Event != HeresphereEventPlay { + newDuration += (newTime - scn.ResumeTime) + } + + // Check if the event ID is different from the previous event for the same client + previousID := idMap[r.RemoteAddr] + if previousID != event.Id { + // Update play count and store the new event ID if needed + if b, err := rs.updatePlayCount(r.Context(), scn, event); err != nil { + // Handle updatePlayCount error + logger.Errorf("Heresphere HeresphereVideoEvent updatePlayCount error: %s\n", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } else if b { + idMap[r.RemoteAddr] = event.Id + } + } + + // Update the scene activity with the new time and duration + if err := rs.withTxn(r.Context(), func(ctx context.Context) error { + _, err := rs.SceneFinder.SaveActivity(ctx, scn.ID, &newTime, &newDuration) + return err + }); err != nil { + // Handle SaveActivity error + logger.Errorf("Heresphere HeresphereVideoEvent SaveActivity error: %s\n", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Respond with a successful HTTP status code + w.WriteHeader(http.StatusOK) +} + +/* + * This endpoint is for letting the user update scene data + */ +func (rs routes) heresphereVideoDataUpdate(w http.ResponseWriter, r *http.Request) error { + scn := r.Context().Value(sceneKey).(*models.Scene) + user := r.Context().Value(authKey).(HeresphereAuthReq) + c := config.GetInstance() + shouldUpdate := false + + ret := &scene.UpdateSet{ + ID: scn.ID, + Partial: models.NewScenePartial(), + } + + var b bool + var err error + if user.Rating != nil && c.GetHSPWriteRatings() { + if b, err = rs.updateRating(user, ret); err != nil { + return err + } + shouldUpdate = b || shouldUpdate + } + + if user.DeleteFile != nil && *user.DeleteFile && c.GetHSPWriteDeletes() { + if _, err = rs.handleDeleteScene(r.Context(), scn); err != nil { + return err + } + return fmt.Errorf("file was deleted") + } + + if user.IsFavorite != nil && c.GetHSPWriteFavorites() { + if b, err = rs.handleFavoriteTag(r.Context(), scn, &user, ret); err != nil { + return err + } + shouldUpdate = b || shouldUpdate + } + + if user.Tags != nil && c.GetHSPWriteTags() { + if b, err = rs.handleTags(r.Context(), scn, &user, ret); err != nil { + return err + } + shouldUpdate = b || shouldUpdate + } + + if shouldUpdate { + if err := rs.withTxn(r.Context(), func(ctx context.Context) error { + _, err := ret.Update(ctx, rs.SceneFinder) + return err + }); err != nil { + return err + } + + return nil + } + return nil +} + +/* + * This endpoint provides the main libraries that are available to browse. + */ +func (rs routes) heresphereIndex(w http.ResponseWriter, r *http.Request) { + // Banner + banner := HeresphereBanner{ + Image: fmt.Sprintf("%s%s", manager.GetBaseURL(r), "/apple-touch-icon.png"), + Link: fmt.Sprintf("%s%s", manager.GetBaseURL(r), "/"), + } + + // Index + libraryObj := HeresphereIndex{ + Access: HeresphereMember, + Banner: banner, + Library: []HeresphereIndexEntry{}, + } + + // Add filters + parsedFilters, err := rs.getAllFilters(r.Context()) + if err == nil { + var keys []string + for key := range parsedFilters { + keys = append(keys, key) + } + + sort.Strings(keys) + + for _, key := range keys { + value := parsedFilters[key] + sceneUrls := make([]string, len(value)) + + for idx, sceneID := range value { + sceneUrls[idx] = addApiKey(fmt.Sprintf("%s/heresphere/%d", manager.GetBaseURL(r), sceneID)) + } + + libraryObj.Library = append(libraryObj.Library, HeresphereIndexEntry{ + Name: key, + List: sceneUrls, + }) + } + } else { + logger.Warnf("Heresphere HeresphereIndex getAllFilters error: %s\n", err.Error()) + } + + // Set response headers and encode JSON + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + if err := enc.Encode(libraryObj); err != nil { + logger.Errorf("Heresphere HeresphereIndex encode error: %s\n", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +/* + * This endpoint provides a single scenes full information. + */ +func (rs routes) heresphereVideoData(w http.ResponseWriter, r *http.Request) { + user := r.Context().Value(authKey).(HeresphereAuthReq) + c := config.GetInstance() + + // Update request + if err := rs.heresphereVideoDataUpdate(w, r); err != nil { + logger.Errorf("Heresphere HeresphereVideoData HeresphereVideoDataUpdate error: %s\n", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Fetch scene + scene := r.Context().Value(sceneKey).(*models.Scene) + + // Load relationships + processedScene := HeresphereVideoEntry{} + if err := rs.withReadTxn(r.Context(), func(ctx context.Context) error { + return scene.LoadRelationships(ctx, rs.SceneFinder) + }); err != nil { + logger.Errorf("Heresphere HeresphereVideoData LoadRelationships error: %s\n", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Create scene + processedScene = HeresphereVideoEntry{ + Access: HeresphereMember, + Title: scene.GetTitle(), + Description: scene.Details, + ThumbnailImage: addApiKey(urlbuilders.NewSceneURLBuilder(manager.GetBaseURL(r), scene).GetScreenshotURL()), + ThumbnailVideo: addApiKey(urlbuilders.NewSceneURLBuilder(manager.GetBaseURL(r), scene).GetStreamPreviewURL()), + DateAdded: scene.CreatedAt.Format("2006-01-02"), + Duration: 0.0, + Rating: 0, + Favorites: 0, + Comments: scene.OCounter, + IsFavorite: rs.getVideoFavorite(r, scene), + Projection: HeresphereProjectionPerspective, + Stereo: HeresphereStereoMono, + IsEyeSwapped: false, + Fov: 180.0, + Lens: HeresphereLensLinear, + CameraIPD: 6.5, + EventServer: addApiKey(fmt.Sprintf("%s/heresphere/%d/event", + manager.GetBaseURL(r), + scene.ID, + )), + Scripts: rs.getVideoScripts(r, scene), + Subtitles: rs.getVideoSubtitles(r, scene), + Tags: rs.getVideoTags(r.Context(), scene), + Media: []HeresphereVideoMedia{}, + WriteFavorite: c.GetHSPWriteFavorites(), + WriteRating: c.GetHSPWriteRatings(), + WriteTags: c.GetHSPWriteTags(), + WriteHSP: false, + } + + // Find projection options + FindProjectionTags(scene, &processedScene) + + // Additional info + if user.NeedsMediaSource != nil && *user.NeedsMediaSource { + processedScene.Media = rs.getVideoMedia(r, scene) + } + if scene.Date != nil { + processedScene.DateReleased = scene.Date.Format("2006-01-02") + } + if scene.Rating != nil { + fiveScale := models.Rating100To5F(*scene.Rating) + processedScene.Rating = fiveScale + } + if processedScene.IsFavorite { + processedScene.Favorites++ + } + if scene.Files.PrimaryLoaded() { + file_ids := scene.Files.Primary() + if file_ids != nil { + if val := manager.HandleFloat64(file_ids.Duration * 1000.0); val != nil { + processedScene.Duration = *val + } + } + } + + // Create a JSON encoder for the response writer + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + if err := enc.Encode(processedScene); err != nil { + logger.Errorf("Heresphere HeresphereVideoData encode error: %s\n", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +/* + * This endpoint function allows the user to login and receive a token if successful. + */ +func (rs routes) heresphereLoginToken(w http.ResponseWriter, r *http.Request) { + user := r.Context().Value(authKey).(HeresphereAuthReq) + + // Try login + if basicLogin(user.Username, user.Password) { + writeNotAuthorized(w, r, "Invalid credentials") + return + } + + // Fetch key + key := config.GetInstance().GetAPIKey() + if len(key) == 0 { + writeNotAuthorized(w, r, "Missing auth key!") + return + } + + // Generate auth response + auth := &HeresphereAuthResp{ + AuthToken: key, + Access: HeresphereMember, + } + + // Create a JSON encoder for the response writer + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + if err := enc.Encode(auth); err != nil { + logger.Errorf("Heresphere HeresphereLoginToken encode error: %s\n", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +/* + * This context function finds the applicable scene from the request and stores it. + */ +func (rs routes) heresphereSceneCtx(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Get sceneId + sceneID, err := strconv.Atoi(chi.URLParam(r, "sceneId")) + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + // Resolve scene + var scene *models.Scene + _ = rs.withReadTxn(r.Context(), func(ctx context.Context) error { + qb := rs.SceneFinder + scene, _ = qb.Find(ctx, sceneID) + + if scene != nil { + // A valid scene should have a attached video + if err := scene.LoadPrimaryFile(ctx, rs.FileFinder); err != nil { + if !errors.Is(err, context.Canceled) { + logger.Errorf("error loading primary file for scene %d: %v", sceneID, err) + } + // set scene to nil so that it doesn't try to use the primary file + scene = nil + } + } + + return nil + }) + if scene == nil { + http.Error(w, http.StatusText(404), 404) + return + } + + ctx := context.WithValue(r.Context(), sceneKey, scene) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +/* + * This context function finds if the authentication is correct, otherwise rejects the request. + */ +func (rs routes) heresphereCtx(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Add JSON Header (using Add uses camel case and makes it invalid because "Json") + w.Header()["HereSphere-JSON-Version"] = []string{strconv.Itoa(HeresphereJsonVersion)} + + // Only if enabled + if !config.GetInstance().GetHSPDefaultEnabled() { + writeNotAuthorized(w, r, "HereSphere API not enabled!") + return + } + + // Read HTTP Body + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "can't read body", http.StatusBadRequest) + return + } + + // Make the Body re-readable (afaik only /event uses this) + r.Body = io.NopCloser(bytes.NewBuffer(body)) + + // Auth enabled and not has valid credentials (TLDR: needs to be blocked) + isAuth := config.GetInstance().HasCredentials() && !HeresphereHasValidToken(r) + + // Default request + user := HeresphereAuthReq{} + + // Attempt decode, and if err and invalid auth, fail + if err := json.Unmarshal(body, &user); err != nil && isAuth { + writeNotAuthorized(w, r, "Not logged in!") + return + } + + // If empty, fill as true + if user.NeedsMediaSource == nil { + needsMedia := true + user.NeedsMediaSource = &needsMedia + } + + // If invalid creds, only allow auth endpoint + if isAuth && !strings.HasPrefix(r.URL.Path, "/heresphere/auth") { + writeNotAuthorized(w, r, "Unauthorized!") + return + } + + ctx := context.WithValue(r.Context(), authKey, user) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/internal/heresphere/schema.go b/internal/heresphere/schema.go new file mode 100644 index 00000000000..af3d803da89 --- /dev/null +++ b/internal/heresphere/schema.go @@ -0,0 +1,164 @@ +package heresphere + +// Based on HereSphere_JSON_API_Version_1.txt + +const HeresphereJsonVersion = 1 + +const ( + HeresphereGuest = 0 + HeresphereMember = 1 + HeresphereBadLogin = -1 +) + +type HeresphereProjection string + +const ( + HeresphereProjectionEquirectangular HeresphereProjection = "equirectangular" + HeresphereProjectionPerspective HeresphereProjection = "perspective" + HeresphereProjectionEquirectangular360 HeresphereProjection = "equirectangular360" + HeresphereProjectionFisheye HeresphereProjection = "fisheye" + HeresphereProjectionCubemap HeresphereProjection = "cubemap" + HeresphereProjectionEquirectangularCubemap HeresphereProjection = "equiangularCubemap" +) + +type HeresphereStereo string + +const ( + HeresphereStereoMono HeresphereStereo = "mono" + HeresphereStereoSbs HeresphereStereo = "sbs" + HeresphereStereoTB HeresphereStereo = "tb" +) + +type HeresphereLens string + +const ( + HeresphereLensLinear HeresphereLens = "Linear" + HeresphereLensMKX220 HeresphereLens = "MKX220" + HeresphereLensMKX200 HeresphereLens = "MKX200" + HeresphereLensVRCA220 HeresphereLens = "VRCA220" +) + +type HeresphereEventType int + +const ( + HeresphereEventOpen HeresphereEventType = 0 + HeresphereEventPlay HeresphereEventType = 1 + HeresphereEventPause HeresphereEventType = 2 + HeresphereEventClose HeresphereEventType = 3 +) + +const HeresphereAuthHeader = "auth-token" + +type HeresphereAuthResp struct { + AuthToken string `json:"auth-token"` + Access int `json:"access"` +} + +type HeresphereBanner struct { + Image string `json:"image"` + Link string `json:"link"` +} +type HeresphereIndexEntry struct { + Name string `json:"name"` + List []string `json:"list"` +} +type HeresphereIndex struct { + Access int `json:"access"` + Banner HeresphereBanner `json:"banner"` + Library []HeresphereIndexEntry `json:"library"` +} +type HeresphereVideoScript struct { + Name string `json:"name"` + Url string `json:"url"` + Rating float64 `json:"rating,omitempty"` +} +type HeresphereVideoSubtitle struct { + Name string `json:"name"` + Language string `json:"language"` + Url string `json:"url"` +} +type HeresphereVideoTag struct { + Name string `json:"name"` + Start float64 `json:"start,omitempty"` + End float64 `json:"end,omitempty"` + Track int `json:"track,omitempty"` + Rating float64 `json:"rating,omitempty"` +} +type HeresphereVideoMediaSource struct { + Resolution int `json:"resolution"` + Height int `json:"height"` + Width int `json:"width"` + // In bytes + Size int64 `json:"size"` + Url string `json:"url"` +} +type HeresphereVideoMedia struct { + // Media type (h265 etc.) + Name string `json:"name"` + Sources []HeresphereVideoMediaSource `json:"sources"` +} +type HeresphereVideoEntry struct { + Access int `json:"access"` + Title string `json:"title"` + Description string `json:"description"` + ThumbnailImage string `json:"thumbnailImage"` + ThumbnailVideo string `json:"thumbnailVideo,omitempty"` + DateReleased string `json:"dateReleased,omitempty"` + DateAdded string `json:"dateAdded,omitempty"` + Duration float64 `json:"duration,omitempty"` + Rating float64 `json:"rating,omitempty"` + Favorites int `json:"favorites"` + Comments int `json:"comments"` + IsFavorite bool `json:"isFavorite"` + Projection HeresphereProjection `json:"projection"` + Stereo HeresphereStereo `json:"stereo"` + IsEyeSwapped bool `json:"isEyeSwapped"` + Fov float64 `json:"fov,omitempty"` + Lens HeresphereLens `json:"lens"` + CameraIPD float64 `json:"cameraIPD"` + Hsp string `json:"hsp,omitempty"` + EventServer string `json:"eventServer,omitempty"` + Scripts []HeresphereVideoScript `json:"scripts,omitempty"` + Subtitles []HeresphereVideoSubtitle `json:"subtitles,omitempty"` + Tags []HeresphereVideoTag `json:"tags,omitempty"` + Media []HeresphereVideoMedia `json:"media,omitempty"` + WriteFavorite bool `json:"writeFavorite"` + WriteRating bool `json:"writeRating"` + WriteTags bool `json:"writeTags"` + WriteHSP bool `json:"writeHSP"` +} +type HeresphereVideoEntryShort struct { + Link string `json:"link"` + Title string `json:"title"` + DateReleased string `json:"dateReleased,omitempty"` + DateAdded string `json:"dateAdded,omitempty"` + Duration float64 `json:"duration,omitempty"` + Rating float64 `json:"rating,omitempty"` + Favorites int `json:"favorites"` + Comments int `json:"comments"` + IsFavorite bool `json:"isFavorite"` + Tags []HeresphereVideoTag `json:"tags"` +} +type HeresphereScanIndex struct { + ScanData []HeresphereVideoEntryShort `json:"scanData"` +} +type HeresphereAuthReq struct { + Username string `json:"username"` + Password string `json:"password"` + NeedsMediaSource *bool `json:"needsMediaSource,omitempty"` + IsFavorite *bool `json:"isFavorite,omitempty"` + Rating *float64 `json:"rating,omitempty"` + Tags *[]HeresphereVideoTag `json:"tags,omitempty"` + HspBase64 *string `json:"hsp,omitempty"` + DeleteFile *bool `json:"deleteFile,omitempty"` +} +type HeresphereVideoEvent struct { + Username string `json:"username"` + Id string `json:"id"` + Title string `json:"title"` + Event HeresphereEventType `json:"event"` + Time float64 `json:"time"` + Speed float64 `json:"speed"` + Utc float64 `json:"utc"` + ConnectionKey string `json:"connectionKey"` +} diff --git a/internal/heresphere/tags_read.go b/internal/heresphere/tags_read.go new file mode 100644 index 00000000000..7b97606f73c --- /dev/null +++ b/internal/heresphere/tags_read.go @@ -0,0 +1,304 @@ +package heresphere + +import ( + "context" + "fmt" + "strconv" + + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" +) + +/* + * This auxiliary function gathers various tags from the scene to feed the api. + */ +func (rs routes) getVideoTags(ctx context.Context, scene *models.Scene) []HeresphereVideoTag { + processedTags := []HeresphereVideoTag{} + + if err := rs.withReadTxn(ctx, func(ctx context.Context) error { + err := scene.LoadRelationships(ctx, rs.SceneFinder) + + processedTags = append(processedTags, rs.generateMarkerTags(ctx, scene)...) + processedTags = append(processedTags, rs.generateTagTags(ctx, scene)...) + processedTags = append(processedTags, rs.generatePerformerTags(ctx, scene)...) + processedTags = append(processedTags, rs.generateGalleryTags(ctx, scene)...) + processedTags = append(processedTags, rs.generateMovieTags(ctx, scene)...) + processedTags = append(processedTags, rs.generateStudioTag(ctx, scene)...) + processedTags = append(processedTags, rs.generateInteractiveTag(scene)...) + processedTags = append(processedTags, rs.generateDirectorTag(scene)...) + processedTags = append(processedTags, rs.generateRatingTag(scene)...) + processedTags = append(processedTags, rs.generateWatchedTag(scene)...) + processedTags = append(processedTags, rs.generateOrganizedTag(scene)...) + processedTags = append(processedTags, rs.generateRatedTag(scene)...) + processedTags = append(processedTags, rs.generateOrgasmedTag(scene)...) + processedTags = append(processedTags, rs.generatePlayCountTag(scene)...) + processedTags = append(processedTags, rs.generateOCounterTag(scene)...) + + return err + }); err != nil { + logger.Errorf("Heresphere getVideoTags generate tags error: %s\n", err.Error()) + } + + return processedTags +} +func (rs routes) generateMarkerTags(ctx context.Context, scene *models.Scene) []HeresphereVideoTag { + // Generate marker tags + tags := []HeresphereVideoTag{} + + markIDs, err := rs.SceneMarkerFinder.FindBySceneID(ctx, scene.ID) + if err != nil { + logger.Errorf("Heresphere generateMarkerTags SceneMarker.FindBySceneID error: %s\n", err.Error()) + return tags + } + + for _, mark := range markIDs { + tagName := mark.Title + + if ret, err := rs.TagFinder.Find(ctx, mark.PrimaryTagID); err == nil { + if len(tagName) == 0 { + tagName = ret.Name + } else { + tagName = fmt.Sprintf("%s - %s", tagName, ret.Name) + } + } + + tags = append(tags, HeresphereVideoTag{ + Name: fmt.Sprintf("Marker:%s", tagName), + Start: mark.Seconds * 1000, + End: (mark.Seconds + 60) * 1000, + }) + } + + return tags +} +func (rs routes) generateTagTags(ctx context.Context, scene *models.Scene) []HeresphereVideoTag { + // Generate tag tags + tags := []HeresphereVideoTag{} + + tagIDs, err := rs.TagFinder.FindBySceneID(ctx, scene.ID) + if err != nil { + logger.Errorf("Heresphere generateTagTags Tag.FindBySceneID error: %s\n", err.Error()) + return tags + } + + for _, tag := range tagIDs { + tags = append(tags, HeresphereVideoTag{ + Name: fmt.Sprintf("Tag:%s", tag.Name), + }) + } + + return tags +} + +func (rs routes) generatePerformerTags(ctx context.Context, scene *models.Scene) []HeresphereVideoTag { + // Generate performer tags + tags := []HeresphereVideoTag{} + + perfIDs, err := rs.PerformerFinder.FindBySceneID(ctx, scene.ID) + if err != nil { + logger.Errorf("Heresphere generatePerformerTags Performer.FindBySceneID error: %s\n", err.Error()) + return tags + } + + hasFavPerformer := false + for _, perf := range perfIDs { + tags = append(tags, HeresphereVideoTag{ + Name: fmt.Sprintf("Performer:%s", perf.Name), + }) + hasFavPerformer = hasFavPerformer || perf.Favorite + } + + tags = append(tags, HeresphereVideoTag{ + Name: fmt.Sprintf("HasFavoritedPerformer:%s", strconv.FormatBool(hasFavPerformer)), + }) + + return tags +} + +func (rs routes) generateGalleryTags(ctx context.Context, scene *models.Scene) []HeresphereVideoTag { + // Generate gallery tags + tags := []HeresphereVideoTag{} + + if scene.GalleryIDs.Loaded() { + galleries, err := rs.GalleryFinder.FindMany(ctx, scene.GalleryIDs.List()) + if err != nil { + logger.Errorf("Heresphere generateGalleryTags Gallery.FindMany error: %s\n", err.Error()) + return tags + } + + for _, gallery := range galleries { + tags = append(tags, HeresphereVideoTag{ + Name: fmt.Sprintf("Gallery:%s", gallery.Title), + }) + } + } + + return tags +} + +func (rs routes) generateMovieTags(ctx context.Context, scene *models.Scene) []HeresphereVideoTag { + // Generate movie tags + tags := []HeresphereVideoTag{} + + if scene.Movies.Loaded() { + lst := scene.Movies.List() + idx := make([]int, 0, len(lst)) + for _, movie := range lst { + idx = append(idx, movie.MovieID) + } + + movies, err := rs.MovieFinder.FindMany(ctx, idx) + if err != nil { + logger.Errorf("Heresphere generateMovieTags Movie.FindMany error: %s\n", err.Error()) + return tags + } + + for _, movie := range movies { + tags = append(tags, HeresphereVideoTag{ + Name: fmt.Sprintf("Movie:%s", movie.Name), + }) + } + } + + return tags +} + +func (rs routes) generateStudioTag(ctx context.Context, scene *models.Scene) []HeresphereVideoTag { + // Generate studio tag + tags := []HeresphereVideoTag{} + + if scene.StudioID != nil { + studio, err := rs.StudioFinder.Find(ctx, *scene.StudioID) + if err != nil { + logger.Errorf("Heresphere generateStudioTag Studio.Find error: %s\n", err.Error()) + return tags + } + + tags = append(tags, HeresphereVideoTag{ + Name: fmt.Sprintf("Studio:%s", studio.Name), + }) + } + + return tags +} + +func (rs routes) generateInteractiveTag(scene *models.Scene) []HeresphereVideoTag { + // Generate interactive tag + tags := []HeresphereVideoTag{} + + primaryFile := scene.Files.Primary() + if primaryFile != nil { + tags = append(tags, HeresphereVideoTag{ + Name: fmt.Sprintf("%s:%s", + string(HeresphereCustomTagInteractive), + strconv.FormatBool(primaryFile.Interactive), + ), + }) + + if primaryFile.Interactive { + funSpeed := 0 + if primaryFile.InteractiveSpeed != nil { + funSpeed = *primaryFile.InteractiveSpeed + } + tags = append(tags, HeresphereVideoTag{ + Name: fmt.Sprintf("Funspeed:%d", + funSpeed, + ), + }) + } + } + + return tags +} + +func (rs routes) generateDirectorTag(scene *models.Scene) []HeresphereVideoTag { + // Generate director tag + tags := []HeresphereVideoTag{} + + if len(scene.Director) > 0 { + tags = append(tags, HeresphereVideoTag{ + Name: fmt.Sprintf("Director:%s", scene.Director), + }) + } + + return tags +} + +func (rs routes) generateRatingTag(scene *models.Scene) []HeresphereVideoTag { + // Generate rating tag + tags := []HeresphereVideoTag{} + + if scene.Rating != nil { + tags = append(tags, HeresphereVideoTag{ + Name: fmt.Sprintf("Rating:%d", + models.Rating100To5(*scene.Rating), + ), + }) + } + + return tags +} + +func (rs routes) generateWatchedTag(scene *models.Scene) []HeresphereVideoTag { + // Generate watched tag + return []HeresphereVideoTag{ + { + Name: fmt.Sprintf("%s:%s", + string(HeresphereCustomTagWatched), + strconv.FormatBool(scene.PlayCount > 0), + ), + }, + } +} + +func (rs routes) generateOrganizedTag(scene *models.Scene) []HeresphereVideoTag { + // Generate organized tag + return []HeresphereVideoTag{ + { + Name: fmt.Sprintf("%s:%s", + string(HeresphereCustomTagOrganized), + strconv.FormatBool(scene.Organized), + ), + }, + } +} + +func (rs routes) generateRatedTag(scene *models.Scene) []HeresphereVideoTag { + // Generate rated tag + return []HeresphereVideoTag{ + { + Name: fmt.Sprintf("%s:%s", + string(HeresphereCustomTagRated), + strconv.FormatBool(scene.Rating != nil), + ), + }, + } +} + +func (rs routes) generateOrgasmedTag(scene *models.Scene) []HeresphereVideoTag { + // Generate orgasmed tag + return []HeresphereVideoTag{ + { + Name: fmt.Sprintf("%s:%s", + string(HeresphereCustomTagOrgasmed), + strconv.FormatBool(scene.OCounter > 0), + ), + }, + } +} + +func (rs routes) generatePlayCountTag(scene *models.Scene) []HeresphereVideoTag { + return []HeresphereVideoTag{ + { + Name: fmt.Sprintf("%s:%d", string(HeresphereCustomTagPlayCount), scene.PlayCount), + }, + } +} + +func (rs routes) generateOCounterTag(scene *models.Scene) []HeresphereVideoTag { + return []HeresphereVideoTag{ + { + Name: fmt.Sprintf("%s:%d", string(HeresphereCustomTagOCounter), scene.OCounter), + }, + } +} diff --git a/internal/heresphere/tags_write.go b/internal/heresphere/tags_write.go new file mode 100644 index 00000000000..59361c91b97 --- /dev/null +++ b/internal/heresphere/tags_write.go @@ -0,0 +1,298 @@ +package heresphere + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scene" +) + +/* + * Processes tags and updates scene tags if applicable + */ +func (rs routes) handleTags(ctx context.Context, scn *models.Scene, user *HeresphereAuthReq, ret *scene.UpdateSet) (bool, error) { + // Search input tags and add/create any new ones + var tagIDs []int + var perfIDs []int + + for _, tagI := range *user.Tags { + // If missing + if len(tagI.Name) == 0 { + continue + } + + // FUTURE IMPROVEMENT: Switch to CutPrefix as it's nicer (1.20+) + // FUTURE IMPROVEMENT: Consider batching searches + if rs.handleAddTag(ctx, tagI, &tagIDs) { + continue + } + if rs.handleAddPerformer(ctx, tagI, &perfIDs) { + continue + } + if rs.handleAddMarker(ctx, tagI, scn) { + continue + } + if rs.handleAddStudio(ctx, tagI, scn, ret) { + continue + } + if rs.handleAddDirector(ctx, tagI, scn, ret) { + continue + } + + // Custom + if rs.handleSetWatched(ctx, tagI, scn, ret) { + continue + } + if rs.handleSetOrganized(ctx, tagI, scn, ret) { + continue + } + if rs.handleSetRated(ctx, tagI, scn, ret) { + continue + } + if rs.handleSetPlayCount(ctx, tagI, scn, ret) { + continue + } + if rs.handleSetOCount(ctx, tagI, scn, ret) { + continue + } + } + + // Update tags + ret.Partial.TagIDs = &models.UpdateIDs{ + IDs: tagIDs, + Mode: models.RelationshipUpdateModeSet, + } + // Update performers + ret.Partial.PerformerIDs = &models.UpdateIDs{ + IDs: perfIDs, + Mode: models.RelationshipUpdateModeSet, + } + + return true, nil +} + +func (rs routes) handleAddTag(ctx context.Context, tag HeresphereVideoTag, tagIDs *[]int) bool { + if !strings.HasPrefix(tag.Name, "Tag:") { + return false + } + + after := strings.TrimPrefix(tag.Name, "Tag:") + var err error + var tagMod *models.Tag + if err := rs.withReadTxn(ctx, func(ctx context.Context) error { + // Search for tag + tagMod, err = rs.TagFinder.FindByName(ctx, after, true) + return err + }); err != nil { + fmt.Printf("Heresphere handleTags Tag.FindByName error: %s\n", err.Error()) + tagMod = nil + } + + if tagMod != nil { + *tagIDs = append(*tagIDs, tagMod.ID) + } + + return true +} +func (rs routes) handleAddPerformer(ctx context.Context, tag HeresphereVideoTag, perfIDs *[]int) bool { + if !strings.HasPrefix(tag.Name, "Performer:") { + return false + } + + after := strings.TrimPrefix(tag.Name, "Performer:") + var err error + var tagMod *models.Performer + if err := rs.withReadTxn(ctx, func(ctx context.Context) error { + var tagMods []*models.Performer + + // Search for performer + if tagMods, err = rs.PerformerFinder.FindByNames(ctx, []string{after}, true); err == nil && len(tagMods) > 0 { + tagMod = tagMods[0] + } + + return err + }); err != nil { + fmt.Printf("Heresphere handleTags Performer.FindByNames error: %s\n", err.Error()) + tagMod = nil + } + + if tagMod != nil { + *perfIDs = append(*perfIDs, tagMod.ID) + } + + return true +} +func (rs routes) handleAddMarker(ctx context.Context, tag HeresphereVideoTag, scene *models.Scene) bool { + if !strings.HasPrefix(tag.Name, "Marker:") { + return false + } + + after := strings.TrimPrefix(tag.Name, "Marker:") + var tagId *string + + if err := rs.withReadTxn(ctx, func(ctx context.Context) error { + var err error + var markerResult []*models.MarkerStringsResultType + searchType := "count" + + // Search for marker + if markerResult, err = rs.SceneMarkerFinder.GetMarkerStrings(ctx, &after, &searchType); len(markerResult) > 0 { + tagId = &markerResult[0].ID + + // Search for tag + if markers, err := rs.SceneMarkerFinder.FindBySceneID(ctx, scene.ID); err == nil { + i, err := strconv.Atoi(*tagId) + if err == nil { + // Note: Currently we search if a marker exists. + // If it doesn't, create it. + // This also means that markers CANNOT be deleted using the api. + for _, marker := range markers { + if marker.Seconds == tag.Start && + marker.SceneID == scene.ID && + marker.PrimaryTagID == i { + tagId = nil + } + } + } + } + } + + return err + }); err != nil || tagId != nil { + // Create marker + i, e := strconv.Atoi(*tagId) + if e == nil { + currentTime := time.Now() + newMarker := models.SceneMarker{ + Title: "", + Seconds: tag.Start, + PrimaryTagID: i, + SceneID: scene.ID, + CreatedAt: currentTime, + UpdatedAt: currentTime, + } + + if err := rs.withTxn(ctx, func(ctx context.Context) error { + return rs.SceneMarkerFinder.Create(ctx, &newMarker) + }); err != nil { + logger.Errorf("Heresphere handleTags SceneMarker.Create error: %s\n", err.Error()) + } + } + } + + return true +} +func (rs routes) handleAddStudio(ctx context.Context, tag HeresphereVideoTag, scene *models.Scene, ret *scene.UpdateSet) bool { + if !strings.HasPrefix(tag.Name, "Studio:") { + return false + } + + after := strings.TrimPrefix(tag.Name, "Studio:") + + var err error + var tagMod *models.Studio + if err := rs.withReadTxn(ctx, func(ctx context.Context) error { + // Search for performer + tagMod, err = rs.StudioFinder.FindByName(ctx, after, true) + return err + }); err == nil { + ret.Partial.StudioID.Set = true + ret.Partial.StudioID.Value = tagMod.ID + } + + return true +} +func (rs routes) handleAddDirector(ctx context.Context, tag HeresphereVideoTag, scene *models.Scene, ret *scene.UpdateSet) bool { + if !strings.HasPrefix(tag.Name, "Director:") { + return false + } + + after := strings.TrimPrefix(tag.Name, "Director:") + ret.Partial.Director.Set = true + ret.Partial.Director.Value = after + + return true +} + +// Will be overwritten if PlayCount tag is updated +func (rs routes) handleSetWatched(ctx context.Context, tag HeresphereVideoTag, scene *models.Scene, ret *scene.UpdateSet) bool { + prefix := string(HeresphereCustomTagWatched) + ":" + if !strings.HasPrefix(tag.Name, prefix) { + return false + } + + after := strings.TrimPrefix(tag.Name, prefix) + if b, err := strconv.ParseBool(after); err == nil { + // Plays chicken + if b && scene.PlayCount == 0 { + ret.Partial.PlayCount.Set = true + ret.Partial.PlayCount.Value = 1 + } else if !b { + ret.Partial.PlayCount.Set = true + ret.Partial.PlayCount.Value = 0 + } + } + + return true +} +func (rs routes) handleSetOrganized(ctx context.Context, tag HeresphereVideoTag, scene *models.Scene, ret *scene.UpdateSet) bool { + prefix := string(HeresphereCustomTagOrganized) + ":" + if !strings.HasPrefix(tag.Name, prefix) { + return false + } + + after := strings.TrimPrefix(tag.Name, prefix) + if b, err := strconv.ParseBool(after); err == nil { + ret.Partial.Organized.Set = true + ret.Partial.Organized.Value = b + } + + return true +} +func (rs routes) handleSetRated(ctx context.Context, tag HeresphereVideoTag, scene *models.Scene, ret *scene.UpdateSet) bool { + prefix := string(HeresphereCustomTagRated) + ":" + if !strings.HasPrefix(tag.Name, prefix) { + return false + } + + after := strings.TrimPrefix(tag.Name, prefix) + if b, err := strconv.ParseBool(after); err == nil && !b { + ret.Partial.Rating.Set = true + ret.Partial.Rating.Null = true + } + + return true +} +func (rs routes) handleSetPlayCount(ctx context.Context, tag HeresphereVideoTag, scene *models.Scene, ret *scene.UpdateSet) bool { + prefix := string(HeresphereCustomTagPlayCount) + ":" + if !strings.HasPrefix(tag.Name, prefix) { + return false + } + + after := strings.TrimPrefix(tag.Name, prefix) + if numRes, err := strconv.Atoi(after); err == nil { + ret.Partial.PlayCount.Set = true + ret.Partial.PlayCount.Value = numRes + } + + return true +} +func (rs routes) handleSetOCount(ctx context.Context, tag HeresphereVideoTag, scene *models.Scene, ret *scene.UpdateSet) bool { + prefix := string(HeresphereCustomTagOCounter) + ":" + if !strings.HasPrefix(tag.Name, prefix) { + return false + } + + after := strings.TrimPrefix(tag.Name, prefix) + if numRes, err := strconv.Atoi(after); err == nil { + ret.Partial.OCounter.Set = true + ret.Partial.OCounter.Value = numRes + } + + return true +} diff --git a/internal/heresphere/update.go b/internal/heresphere/update.go new file mode 100644 index 00000000000..d75086afa56 --- /dev/null +++ b/internal/heresphere/update.go @@ -0,0 +1,96 @@ +package heresphere + +import ( + "context" + "strconv" + + "github.com/stashapp/stash/internal/manager" + "github.com/stashapp/stash/pkg/file" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/plugin" + "github.com/stashapp/stash/pkg/scene" +) + +/* + * Modifies the scene rating + */ +func (rs routes) updateRating(user HeresphereAuthReq, ret *scene.UpdateSet) (bool, error) { + rating := models.Rating5To100F(*user.Rating) + ret.Partial.Rating.Value = rating + ret.Partial.Rating.Set = true + return true, nil +} + +/* + * Modifies the scene PlayCount + */ +func (rs routes) updatePlayCount(ctx context.Context, scn *models.Scene, event HeresphereVideoEvent) (bool, error) { + if per, err := getMinPlayPercent(); err == nil { + newTime := event.Time / 1000 + file := scn.Files.Primary() + + if file != nil && newTime/file.Duration > float64(per)/100.0 { + ret := &scene.UpdateSet{ + ID: scn.ID, + Partial: models.NewScenePartial(), + } + ret.Partial.PlayCount.Set = true + ret.Partial.PlayCount.Value = scn.PlayCount + 1 + + err := rs.withTxn(ctx, func(ctx context.Context) error { + _, err := ret.Update(ctx, rs.SceneFinder) + return err + }) + return err == nil, err + } + } + + return false, nil +} + +/* + * Deletes the scene's primary file + */ +func (rs routes) handleDeleteScene(ctx context.Context, scn *models.Scene) (bool, error) { + err := rs.withTxn(ctx, func(ctx context.Context) error { + // Construct scene deletion + deleteFile := true + deleteGenerated := true + input := models.ScenesDestroyInput{ + Ids: []string{strconv.Itoa(scn.ID)}, + DeleteFile: &deleteFile, + DeleteGenerated: &deleteGenerated, + } + + // Construct file deleter + fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm() + fileDeleter := &scene.FileDeleter{ + Deleter: file.NewDeleter(), + FileNamingAlgo: fileNamingAlgo, + Paths: manager.GetInstance().Paths, + } + + // Kill running streams + manager.KillRunningStreams(scn, fileNamingAlgo) + + // Destroy scene + if err := rs.SceneService.Destroy(ctx, scn, fileDeleter, deleteGenerated, deleteFile); err != nil { + fileDeleter.Rollback() + return err + } + + // Commit deletion + fileDeleter.Commit() + + // Plugin callback + rs.HookExecutor.ExecutePostHooks(ctx, scn.ID, plugin.SceneDestroyPost, plugin.ScenesDestroyInput{ + ScenesDestroyInput: input, + Checksum: scn.Checksum, + OSHash: scn.OSHash, + Path: scn.Path, + }, nil) + + return nil + }) + return err == nil, err +} diff --git a/internal/heresphere/utils.go b/internal/heresphere/utils.go new file mode 100644 index 00000000000..d3be21217e2 --- /dev/null +++ b/internal/heresphere/utils.go @@ -0,0 +1,30 @@ +package heresphere + +import ( + "fmt" + + "github.com/stashapp/stash/internal/manager/config" +) + +/* + * Finds the selected VR Tag string + */ +func getVrTag() (varTag string, err error) { + // Find setting + varTag = config.GetInstance().GetUIVRTag() + if len(varTag) == 0 { + err = fmt.Errorf("zero length vr tag") + } + return +} + +/* + * Finds the selected minimum play percentage value + */ +func getMinPlayPercent() (per int, err error) { + per = config.GetInstance().GetUIMinPlayPercent() + if per < 0 { + err = fmt.Errorf("unset minimum play percent") + } + return +} diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index e0ce11c297d..3f8fef18c6c 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -231,6 +231,14 @@ const ( DLNAVideoSortOrder = "dlna.video_sort_order" dlnaVideoSortOrderDefault = "title" + // HSP options + HSPDefaultEnabled = "hsp.default_enabled" + HSPFavoriteTag = "hsp.favorite_tag" + HSPWriteFavorites = "hsp.write_favorites" + HSPWriteRating = "hsp.write_rating" + HSPWriteTags = "hsp.write_tags" + HSPWriteDeletes = "hsp.write_deletes" + // Logging options LogFile = "logFile" LogOut = "logOut" @@ -1176,6 +1184,23 @@ func (i *Config) GetUIConfiguration() map[string]interface{} { return fromSnakeCaseMap(v) } +func (i *Config) GetUIVRTag() string { + cfgMap := i.GetUIConfiguration() + if val, ok := cfgMap["vrTag"]; ok { + return val.(string) + } + + return "" +} +func (i *Config) GetUIMinPlayPercent() int { + cfgMap := i.GetUIConfiguration() + if val, ok := cfgMap["minimumPlayPercent"]; ok { + return val.(int) + } + + return -1 +} + func (i *Config) SetUIConfiguration(v map[string]interface{}) { i.Lock() defer i.Unlock() @@ -1457,6 +1482,36 @@ func (i *Config) GetVideoSortOrder() string { return ret } +// GetHSPDefaultEnabled returns true if the HSP Api is enabled by default. +func (i *Config) GetHSPDefaultEnabled() bool { + return i.getBool(HSPDefaultEnabled) +} + +// GetHSPFavoriteTag returns the favorites tag id +func (i *Config) GetHSPFavoriteTag() int { + return i.getInt(HSPFavoriteTag) +} + +// GetHSPWriteFavorites returns if favorites should be written +func (i *Config) GetHSPWriteFavorites() bool { + return i.getBool(HSPWriteFavorites) +} + +// GetHSPWriteRatings returns if ratings should be written +func (i *Config) GetHSPWriteRatings() bool { + return i.getBool(HSPWriteRating) +} + +// GetHSPWriteTags returns if tags should be written +func (i *Config) GetHSPWriteTags() bool { + return i.getBool(HSPWriteTags) +} + +// GetHSPWriteDeletes returns if deletions should happen +func (i *Config) GetHSPWriteDeletes() bool { + return i.getBool(HSPWriteDeletes) +} + // GetLogFile returns the filename of the file to output logs to. // An empty string means that file logging will be disabled. func (i *Config) GetLogFile() string { diff --git a/internal/manager/http.go b/internal/manager/http.go new file mode 100644 index 00000000000..4cb13c21833 --- /dev/null +++ b/internal/manager/http.go @@ -0,0 +1,30 @@ +package manager + +import ( + "net/http" + "strings" + + "github.com/stashapp/stash/internal/manager/config" +) + +func GetProxyPrefix(r *http.Request) string { + return strings.TrimRight(r.Header.Get("X-Forwarded-Prefix"), "/") +} + +// Returns stash's baseurl +func GetBaseURL(r *http.Request) string { + scheme := "http" + if strings.Compare("https", r.URL.Scheme) == 0 || r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + prefix := GetProxyPrefix(r) + + baseURL := scheme + "://" + r.Host + prefix + + externalHost := config.GetInstance().GetExternalHost() + if externalHost != "" { + baseURL = externalHost + prefix + } + + return baseURL +} diff --git a/internal/manager/json_utils.go b/internal/manager/json_utils.go index c90c9502942..4daf2889dc4 100644 --- a/internal/manager/json_utils.go +++ b/internal/manager/json_utils.go @@ -1,6 +1,7 @@ package manager import ( + "math" "path/filepath" "github.com/stashapp/stash/pkg/models/jsonschema" @@ -42,3 +43,13 @@ func (jp *jsonUtils) saveGallery(fn string, gallery *jsonschema.Gallery) error { func (jp *jsonUtils) saveFile(fn string, file jsonschema.DirEntry) error { return jsonschema.SaveFileFile(filepath.Join(jp.json.Files, fn), file) } + +// #1572 - Inf and NaN values cause the JSON marshaller to fail +// Return nil for these values +func HandleFloat64(v float64) *float64 { + if math.IsInf(v, 0) || math.IsNaN(v) { + return nil + } + + return &v +} diff --git a/pkg/models/rating.go b/pkg/models/rating.go index 66219b50a62..6529d0bc018 100644 --- a/pkg/models/rating.go +++ b/pkg/models/rating.go @@ -62,8 +62,15 @@ func Rating100To5(rating100 int) int { val := math.Round((float64(rating100) / 20)) return int(math.Max(minRating5, math.Min(maxRating5, val))) } +func Rating100To5F(rating100 int) float64 { + val := math.Round((float64(rating100) / 20.0)) + return math.Max(minRating5, math.Min(maxRating5, val)) +} // Rating5To100 converts a 1-5 rating to a 1-100 rating func Rating5To100(rating5 int) int { return int(math.Max(minRating100, math.Min(maxRating100, float64(rating5*20)))) } +func Rating5To100F(rating5 float64) int { + return int(math.Max(minRating100, math.Min(maxRating100, float64(rating5*20.0)))) +} diff --git a/pkg/models/relationships.go b/pkg/models/relationships.go index 2c2bc60b10b..379c18ba568 100644 --- a/pkg/models/relationships.go +++ b/pkg/models/relationships.go @@ -1,6 +1,10 @@ package models -import "context" +import ( + "context" + + "github.com/stashapp/stash/pkg/sliceutil" +) type SceneIDLoader interface { GetSceneIDs(ctx context.Context, relatedID int) ([]int, error) @@ -86,7 +90,14 @@ func (r RelatedIDs) List() []int { func (r *RelatedIDs) Add(ids ...int) { r.mustLoaded() - r.list = append(r.list, ids...) + r.list = sliceutil.AppendUniques(r.list, ids) +} + +// Remove removes the provided ids to the list. Panics if the relationship has not been loaded. +func (r *RelatedIDs) Remove(ids ...int) { + r.mustLoaded() + + r.list = sliceutil.Exclude(r.list, ids) } func (r *RelatedIDs) load(fn func() ([]int, error)) error { diff --git a/pkg/session/session.go b/pkg/session/session.go index d5218155f96..bb6d9fd2ade 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -62,6 +62,13 @@ func NewStore(c SessionConfig) *Store { return ret } +func (s *Store) LoginPlain(username string, password string) error { + if !s.config.ValidateCredentials(username, password) { + return &InvalidCredentialsError{Username: username} + } + return nil +} + func (s *Store) Login(w http.ResponseWriter, r *http.Request) error { // ignore error - we want a new session regardless newSession, _ := s.sessionStore.Get(r, cookieName) @@ -70,8 +77,8 @@ func (s *Store) Login(w http.ResponseWriter, r *http.Request) error { password := r.FormValue(passwordFormKey) // authenticate the user - if !s.config.ValidateCredentials(username, password) { - return &InvalidCredentialsError{Username: username} + if err := s.LoginPlain(username, password); err != nil { + return err } // since we only have one user, don't leak the name diff --git a/ui/v2.5/src/components/Settings/Inputs.tsx b/ui/v2.5/src/components/Settings/Inputs.tsx index dd7afc2f967..735db7ea348 100644 --- a/ui/v2.5/src/components/Settings/Inputs.tsx +++ b/ui/v2.5/src/components/Settings/Inputs.tsx @@ -9,6 +9,7 @@ import { PatchComponent } from "src/pluginApi"; interface ISetting { id?: string; className?: string; + subElementId?: string; heading?: React.ReactNode; headingID?: string; subHeadingID?: string; @@ -24,6 +25,7 @@ export const Setting: React.FC> = PatchComponent( const { id, className, + subElementId, heading, headingID, subHeadingID, @@ -71,7 +73,7 @@ export const Setting: React.FC> = PatchComponent(

{renderHeading()}

{renderSubHeading()} -
{children}
+
{children}
); } diff --git a/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx b/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx index af226fc57ae..f0790604063 100644 --- a/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsServicesPanel.tsx @@ -19,6 +19,7 @@ import { StringListSetting, StringSetting, SelectSetting, + Setting, } from "./Inputs"; import { useSettings } from "./context"; import { @@ -30,12 +31,20 @@ import { faTimes, faUserClock, } from "@fortawesome/free-solid-svg-icons"; +import { TagSelect } from "../Shared/Select"; export const SettingsServicesPanel: React.FC = () => { const intl = useIntl(); const Toast = useToast(); - const { dlna, loading: configLoading, error, saveDLNA } = useSettings(); + const { + dlna, + hsp, + loading: configLoading, + error, + saveDLNA, + saveHSP, + } = useSettings(); // undefined to hide dialog, true for enable, false for disable const [enableDisable, setEnableDisable] = useState(); @@ -464,6 +473,72 @@ export const SettingsServicesPanel: React.FC = () => { ); }; + const HSPSettingsForm: React.FC = () => { + return ( + <> + + saveHSP({ enabled: v })} + /> + + + + saveHSP({ favoriteTagId: parseInt(items[0]?.id) }) + } + ids={ + hsp.favoriteTagId !== undefined && hsp.favoriteTagId !== null + ? [hsp.favoriteTagId.toString()] + : [] + } + hoverPlacement="right" + /> + + + saveHSP({ writeFavorites: v })} + /> + saveHSP({ writeRatings: v })} + /> + saveHSP({ writeTags: v })} + /> + saveHSP({ writeDeletes: v })} + /> + + + ); + }; + return (
{renderTempEnableDialog()} @@ -499,6 +574,8 @@ export const SettingsServicesPanel: React.FC = () => { + +
); }; diff --git a/ui/v2.5/src/components/Settings/context.tsx b/ui/v2.5/src/components/Settings/context.tsx index 7c010c43e70..c2b26a243c3 100644 --- a/ui/v2.5/src/components/Settings/context.tsx +++ b/ui/v2.5/src/components/Settings/context.tsx @@ -11,6 +11,7 @@ import { useConfiguration, useConfigureDefaults, useConfigureDLNA, + useConfigureHSP, useConfigureGeneral, useConfigureInterface, useConfigurePlugin, @@ -31,6 +32,7 @@ export interface ISettingsContextState { defaults: GQL.ConfigDefaultSettingsInput; scraping: GQL.ConfigScrapingInput; dlna: GQL.ConfigDlnaInput; + hsp: GQL.ConfigHspInput; ui: IUIConfig; plugins: PluginSettings; @@ -42,6 +44,7 @@ export interface ISettingsContextState { saveDefaults: (input: Partial) => void; saveScraping: (input: Partial) => void; saveDLNA: (input: Partial) => void; + saveHSP: (input: Partial) => void; saveUI: (input: Partial) => void; savePluginSettings: (pluginID: string, input: {}) => void; @@ -91,6 +94,10 @@ export const SettingsContext: React.FC = ({ children }) => { const [pendingDLNA, setPendingDLNA] = useState(); const [updateDLNAConfig] = useConfigureDLNA(); + const [hsp, setHSP] = useState({}); + const [pendingHSP, setPendingHSP] = useState(); + const [updateHSPConfig] = useConfigureHSP(); + const [ui, setUI] = useState({}); const [pendingUI, setPendingUI] = useState<{}>(); const [updateUIConfig] = useConfigureUI(); @@ -119,6 +126,7 @@ export const SettingsContext: React.FC = ({ children }) => { setDefaults({ ...withoutTypename(data.configuration.defaults) }); setScraping({ ...withoutTypename(data.configuration.scraping) }); setDLNA({ ...withoutTypename(data.configuration.dlna) }); + setHSP({ ...withoutTypename(data.configuration.hsp) }); setUI(data.configuration.ui); setPlugins(data.configuration.plugins); }, [data, error]); @@ -380,6 +388,52 @@ export const SettingsContext: React.FC = ({ children }) => { }); } + // saves the configuration if no further changes are made after a half second + const saveHSPConfig = useDebounce(async (input: GQL.ConfigHspInput) => { + try { + setUpdateSuccess(undefined); + await updateHSPConfig({ + variables: { + input, + }, + }); + + setPendingHSP(undefined); + onSuccess(); + } catch (e) { + onError(e); + } + }, 500); + + useEffect(() => { + if (!pendingHSP) { + return; + } + + saveHSPConfig(pendingHSP); + }, [pendingHSP, saveHSPConfig]); + + function saveHSP(input: Partial) { + if (!hsp) { + return; + } + + setHSP({ + ...hsp, + ...input, + }); + + setPendingHSP((current) => { + if (!current) { + return input; + } + return { + ...current, + ...input, + }; + }); + } + // saves the configuration if no further changes are made after a half second const saveUIConfig = useDebounce(async (input: IUIConfig) => { try { @@ -502,6 +556,7 @@ export const SettingsContext: React.FC = ({ children }) => { pendingDefaults || pendingScraping || pendingDLNA || + pendingHSP || pendingUI || pendingPlugins ) { @@ -534,6 +589,7 @@ export const SettingsContext: React.FC = ({ children }) => { defaults, scraping, dlna, + hsp, ui, plugins, saveGeneral, @@ -541,6 +597,7 @@ export const SettingsContext: React.FC = ({ children }) => { saveDefaults, saveScraping, saveDLNA, + saveHSP, saveUI, refetch, savePluginSettings, diff --git a/ui/v2.5/src/components/Settings/styles.scss b/ui/v2.5/src/components/Settings/styles.scss index 02caf99ee66..432048acf65 100644 --- a/ui/v2.5/src/components/Settings/styles.scss +++ b/ui/v2.5/src/components/Settings/styles.scss @@ -349,6 +349,11 @@ } } +#hsp-select { + min-width: 175px; + text-align: left; +} + .task-group { padding-top: 0.5rem; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 15ee2868273..17267b9ba96 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -2132,6 +2132,11 @@ export const useAddTempDLNAIP = () => GQL.useAddTempDlnaipMutation(); export const useRemoveTempDLNAIP = () => GQL.useRemoveTempDlnaipMutation(); +export const useConfigureHSP = () => + GQL.useConfigureHspMutation({ + update: updateConfiguration, + }); + export const mutateStopJob = (jobID: string) => client.mutate({ mutation: GQL.StopJobDocument, diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index bb2d5ca0a77..d3ecf2e5194 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -252,6 +252,21 @@ "video_sort_order": "Default Video Sort Order", "video_sort_order_desc": "Order to sort videos by default." }, + "hsp": { + "title": "HereSphere API", + "desc": "The 'VR Tag' and 'Minimum Play Percent' from the Interface settings panel are reused for the HSP (HereSphere) API", + "enabled_by_default": "Enabled", + "favorites_tag": "Favorites Tag", + "favorites_tag_desc": "What tag to add when HSP sends a favorites request", + "write_favorites": "Write Favorites", + "write_favorites_desc": "Whether to enable HSP to write the favorite tag to videos", + "write_ratings": "Write Ratings", + "write_ratings_desc": "Whether to enable HSP to write ratings", + "write_tags": "Write Tags", + "write_tags_desc": "Whether to enable HSP to write tags", + "write_deletes": "Allow deletes", + "write_deletes_desc": "Whether to allow scene deletion via HSP API" + }, "general": { "auth": { "api_key": "API Key",