From a3a86a86a1d13b79394512441f9c2731df78c87e Mon Sep 17 00:00:00 2001 From: o-fl0w Date: Mon, 27 Feb 2023 00:28:35 +0100 Subject: [PATCH] stash v0.18 --- README.md | 21 +- docker-compose.yml | 1 + internal/api/heresphere/http.go | 41 +-- internal/api/heresphere/scan.go | 2 +- internal/api/heresphere/sync.go | 43 +-- internal/api/heresphere/tag.go | 1 + internal/api/heresphere/videodata.go | 11 +- internal/api/internal/legend.go | 1 + internal/config/config.go | 3 + internal/sections/internal/savedfilter.go | 2 +- internal/stash/filter/jsonfilter.go | 253 +++++++++++++----- internal/stash/filter/scenefilter.go | 137 +++------- internal/stash/gql/mutation.graphql | 8 +- internal/stash/gql/query.graphql | 5 +- internal/stash/gql/schema/schema.graphql | 10 + .../stash/gql/schema/types/config.graphql | 8 + .../stash/gql/schema/types/filters.graphql | 125 ++++++++- .../stash/gql/schema/types/gallery.graphql | 22 +- internal/stash/gql/schema/types/image.graphql | 15 +- internal/stash/gql/schema/types/movie.graphql | 20 +- .../stash/gql/schema/types/performer.graphql | 35 ++- internal/stash/gql/schema/types/scene.graphql | 86 +++++- .../stash/gql/schema/types/scraper.graphql | 4 + .../stash/gql/schema/types/studio.graphql | 15 +- 24 files changed, 616 insertions(+), 253 deletions(-) diff --git a/README.md b/README.md index 1b353db..aa14255 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,9 @@ Stash-VR listens on port `9666`, use docker port binding to change local port, e * `HEATMAP_HEIGHT_PX` * Default: 0 (use height of heatmap) * Manually set height of all heatmaps. If not set, height of the heatmap retrieved from Stash will be used, currently 15 by default. +* `DISABLE_PLAY_COUNT` + * Default: `false` + * Disable incrementing Stash play count for scenes. Will otherwise send request to Stash to increment play count when video is played in HereSphere. * `FORCE_HTTPS` * Default: `false` * Force Stash-VR to use HTTPS. Useful as a last resort attempt if you're having issues with Stash-VR behind a reverse proxy. @@ -119,11 +122,7 @@ When the favorite-feature of HereSphere is first used Stash-VR will create a tag **Tip:** Create a filter using that tag, so it shows up in HereSphere for quick access to favorites. #### Rating -HereSphere uses fractions for ratings, i.e. 4.5 is a valid rating. Stash uses whole numbers. -If you set a half star in HereSphere Stash-VR will round up the rating. That is if you set a rating of 3.5 the scene will receive a rating of 4 in Stash. -In other words, click anywhere on a star to set the rating to that amount of stars. - -**Exception:** To remove a rating, rate the scene 0.5 (half a star). +Ratings set in HereSphere will be converted to its equivalent in Stash (4.5 stars => 90). #### O-counter Increment o-count by adding a tag named `!O` (case-insensitive) in `Video Tags`. @@ -179,10 +178,12 @@ When the index page of Stash-VR is loaded Stash-VR will immediately respond with This means if changes are made in Stash and the player refreshed, it will receive the cached version built during the last (previous) request. Just refresh again and the player should receive the latest changes. In other words, refresh twice. -### Stash version -#### Stash v0.17.x -Compatible with Stash-VR version >= v0.5.0 -#### Stash v0.16.1 -Compatible with Stash-VR version <= v0.4.4 +### Stash version compatibility +| Stash-VR | Stash | +|---------|---------| +| v0.6.x | v0.18.x | +| v0.5.x | v0.17.x | +| v0.4.x | v0.16.x | + #### Older Stash versions If you have issues arising from running an older version of Stash the recommended path is to upgrade Stash before attempting a fix. diff --git a/docker-compose.yml b/docker-compose.yml index c576d86..81705f5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: #ALLOW_SYNC_MARKERS: "true" #DISABLE_HEATMAP: "true" #HEATMAP_HEIGHT_PX: 45 + #DISABLE_PLAY_COUNT: "true" #LOG_LEVEL: "debug" #FORCE_HTTPS: "true" \ No newline at end of file diff --git a/internal/api/heresphere/http.go b/internal/api/heresphere/http.go index e193960..8970468 100644 --- a/internal/api/heresphere/http.go +++ b/internal/api/heresphere/http.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "stash-vr/internal/api/internal" + "stash-vr/internal/config" ) type httpHandler struct { @@ -53,25 +54,33 @@ func (h *httpHandler) videoDataHandler(w http.ResponseWriter, req *http.Request) return } - var updateVideoData updateVideoData - err = json.Unmarshal(body, &updateVideoData) + var vdReq videoDataRequest + err = json.Unmarshal(body, &vdReq) if err != nil { - log.Ctx(ctx).Debug().Err(err).Bytes("body", body).Msg("body: unmarshal") - } else { - if updateVideoData.isUpdateRequest() { - update(ctx, h.Client, sceneId, updateVideoData) - w.WriteHeader(http.StatusOK) - return - } - - if updateVideoData.isDeleteRequest() { - destroy(ctx, h.Client, sceneId) - w.WriteHeader(http.StatusOK) - return - } + log.Ctx(ctx).Error().Err(err).Bytes("body", body).Msg("body: unmarshal") + w.WriteHeader(http.StatusInternalServerError) + return + } + + if vdReq.isUpdateRequest() { + update(ctx, h.Client, sceneId, vdReq) + w.WriteHeader(http.StatusOK) + return } - data, err := buildVideoData(ctx, h.Client, baseUrl, sceneId) + if vdReq.isDeleteRequest() { + destroy(ctx, h.Client, sceneId) + w.WriteHeader(http.StatusOK) + return + } + + if vdReq.isPlayRequest() && !config.Get().IsPlayCountDisabled { + incrementPlayCount(ctx, h.Client, sceneId) + } + + var includeMediaSource = vdReq.NeedsMediaSource == nil || *vdReq.NeedsMediaSource + + data, err := buildVideoData(ctx, h.Client, baseUrl, sceneId, includeMediaSource) if err != nil { log.Ctx(ctx).Error().Err(err).Msg("build") w.WriteHeader(http.StatusInternalServerError) diff --git a/internal/api/heresphere/scan.go b/internal/api/heresphere/scan.go index dfe4bab..ce31b75 100644 --- a/internal/api/heresphere/scan.go +++ b/internal/api/heresphere/scan.go @@ -52,7 +52,7 @@ func buildScan(ctx context.Context, client graphql.Client, baseUrl string) (scan DateReleased: part.Date, DateAdded: part.Created_at.Format("2006-01-02"), Duration: part.Files[0].Duration, - Rating: float32(part.Rating), + Rating: float32(part.Rating100) / 20, Favorites: part.O_counter, IsFavorite: ContainsFavoriteTag(part.TagPartsArray), Tags: getTags(part.SceneScanParts), diff --git a/internal/api/heresphere/sync.go b/internal/api/heresphere/sync.go index 4db2725..bb9e8d9 100644 --- a/internal/api/heresphere/sync.go +++ b/internal/api/heresphere/sync.go @@ -4,7 +4,6 @@ import ( "context" "github.com/Khan/genqlient/graphql" "github.com/rs/zerolog/log" - "math" "stash-vr/internal/api/internal" "stash-vr/internal/config" "stash-vr/internal/stash" @@ -12,22 +11,27 @@ import ( "strings" ) -type updateVideoData struct { - Rating *float32 `json:"rating,omitempty"` - IsFavorite *bool `json:"isFavorite,omitempty"` - Tags *[]tag `json:"tags,omitempty"` - DeleteFile *bool `json:"deleteFile,omitempty"` +type videoDataRequest struct { + Rating *float32 `json:"rating,omitempty"` + IsFavorite *bool `json:"isFavorite,omitempty"` + Tags *[]tag `json:"tags,omitempty"` + DeleteFile *bool `json:"deleteFile,omitempty"` + NeedsMediaSource *bool `json:"needsMediaSource,omitempty"` } -func (v updateVideoData) isUpdateRequest() bool { +func (v videoDataRequest) isUpdateRequest() bool { return v.Rating != nil || v.IsFavorite != nil || v.Tags != nil } -func (v updateVideoData) isDeleteRequest() bool { +func (v videoDataRequest) isDeleteRequest() bool { return v.DeleteFile != nil && *v.DeleteFile } -func update(ctx context.Context, client graphql.Client, sceneId string, updateReq updateVideoData) { +func (v videoDataRequest) isPlayRequest() bool { + return v.NeedsMediaSource != nil && *v.NeedsMediaSource +} + +func update(ctx context.Context, client graphql.Client, sceneId string, updateReq videoDataRequest) { log.Ctx(ctx).Debug().Interface("data", updateReq).Msg("Update request") if updateReq.Rating != nil { @@ -97,6 +101,15 @@ func incrementO(ctx context.Context, client graphql.Client, sceneId string) { log.Ctx(ctx).Debug().Interface("O-count", response.SceneIncrementO).Msg("Incremented O-count") } +func incrementPlayCount(ctx context.Context, client graphql.Client, sceneId string) { + response, err := gql.SceneIncrementPlayCount(ctx, client, sceneId) + if err != nil { + log.Ctx(ctx).Warn().Err(err).Msg("Failed to increment play count") + return + } + log.Ctx(ctx).Debug().Interface("Play Count", response.SceneIncrementPlayCount).Msg("Incremented play count") +} + func toggleOrganized(ctx context.Context, client graphql.Client, sceneId string) { newOrganized, err := stash.SceneToggleOrganized(ctx, client, sceneId) if err != nil { @@ -107,15 +120,9 @@ func toggleOrganized(ctx context.Context, client graphql.Client, sceneId string) } func updateRating(ctx context.Context, client graphql.Client, sceneId string, rating float32) { - var newRating int - if rating == 0.5 { - //special case to set zero rating - newRating = 0 - } else { - newRating = int(math.Ceil(float64(rating))) - } + newRating := int(rating * 20) - _, err := gql.SceneUpdateRating(ctx, client, sceneId, newRating) + _, err := gql.SceneUpdateRating100(ctx, client, sceneId, newRating) if err != nil { log.Ctx(ctx).Warn().Err(err).Int("rating", newRating).Msg("Failed to update rating") return @@ -271,7 +278,7 @@ func parseUpdateRequestTags(ctx context.Context, client graphql.Client, tags []t continue } request.performerIds = append(request.performerIds, id) - case isCategorized && (internal.LegendMovie.IsMatch(tagType) || internal.LegendOCount.IsMatch(tagType) || internal.LegendOrganized.IsMatch(tagType)): + case isCategorized && (internal.LegendMovie.IsMatch(tagType) || internal.LegendOCount.IsMatch(tagType) || internal.LegendOrganized.IsMatch(tagType) || internal.LegendPlayCount.IsMatch(tagType)): log.Ctx(ctx).Trace().Str("request", tagReq.Name).Msg("Tag type is reserved, skipping") continue default: diff --git a/internal/api/heresphere/tag.go b/internal/api/heresphere/tag.go index c57b3f7..b24e6b4 100644 --- a/internal/api/heresphere/tag.go +++ b/internal/api/heresphere/tag.go @@ -113,6 +113,7 @@ func getStudio(s gql.SceneScanParts) []tag { func getFields(s gql.SceneScanParts) []tag { tags := []tag{ + {Name: fmt.Sprintf("%s:%d", internal.LegendPlayCount.Short, s.Play_count)}, {Name: fmt.Sprintf("%s:%d", internal.LegendOCount.Short, s.O_counter)}, {Name: fmt.Sprintf("%s:%v", internal.LegendOrganized.Short, s.Organized)}} diff --git a/internal/api/heresphere/videodata.go b/internal/api/heresphere/videodata.go index 42d16e4..588c29e 100644 --- a/internal/api/heresphere/videodata.go +++ b/internal/api/heresphere/videodata.go @@ -54,7 +54,7 @@ type script struct { Url string `json:"url"` } -func buildVideoData(ctx context.Context, client graphql.Client, baseUrl string, sceneId string) (videoData, error) { +func buildVideoData(ctx context.Context, client graphql.Client, baseUrl string, sceneId string, includeMediaSource bool) (videoData, error) { findSceneResponse, err := gql.FindSceneFull(ctx, client, sceneId) if err != nil { return videoData{}, fmt.Errorf("FindSceneFull: %w", err) @@ -87,7 +87,7 @@ func buildVideoData(ctx context.Context, client graphql.Client, baseUrl string, DateReleased: s.Date, DateAdded: s.Created_at.Format("2006-01-02"), Duration: s.SceneScanParts.Files[0].Duration * 1000, - Rating: float32(s.Rating), + Rating: float32(s.Rating100) / 20, Favorites: s.O_counter, WriteFavorite: true, WriteRating: true, @@ -96,7 +96,10 @@ func buildVideoData(ctx context.Context, client graphql.Client, baseUrl string, setIsFavorite(s, &vd) - setStreamSources(ctx, s, &vd) + if includeMediaSource { + setMediaSources(ctx, s, &vd) + } + set3DFormat(s, &vd) setTags(s, &vd) @@ -162,7 +165,7 @@ func set3DFormat(s gql.SceneFullParts, videoData *videoData) { } } -func setStreamSources(ctx context.Context, s gql.SceneFullParts, videoData *videoData) { +func setMediaSources(ctx context.Context, s gql.SceneFullParts, videoData *videoData) { for _, stream := range stash.GetStreams(ctx, s.StreamsParts, true) { e := media{ Name: stream.Name, diff --git a/internal/api/internal/legend.go b/internal/api/internal/legend.go index db7559c..aa31a6d 100644 --- a/internal/api/internal/legend.go +++ b/internal/api/internal/legend.go @@ -9,6 +9,7 @@ var ( LegendMovie = newLegend("/", "Movie") LegendOCount = newLegend("O", "O-Count") LegendOrganized = newLegend("Org", "Organized") + LegendPlayCount = newLegend("P", "PlayCount") ) type Legend struct { diff --git a/internal/config/config.go b/internal/config/config.go index 6dca67e..7284218 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,7 @@ const ( envKeyDisableHeatmap = "DISABLE_HEATMAP" envKeyHeatmapHeightPx = "HEATMAP_HEIGHT_PX" envKeyAllowSyncMarkers = "ALLOW_SYNC_MARKERS" + envKeyDisablePlayCount = "DISABLE_PLAY_COUNT" ) var deprecatedEnvKeys = []string{"ENABLE_GLANCE_MARKERS", "HERESPHERE_QUICK_MARKERS", "HERESPHERE_SYNC_MARKERS", "ENABLE_HEATMAP_DISPLAY"} @@ -34,6 +35,7 @@ type Application struct { ForceHTTPS bool IsHeatmapDisabled bool HeatmapHeightPx int + IsPlayCountDisabled bool } var cfg Application @@ -54,6 +56,7 @@ func Get() Application { ForceHTTPS: getEnvOrDefaultBool(envKeyForceHTTPS, false), IsHeatmapDisabled: getEnvOrDefaultBool(envKeyDisableHeatmap, false), HeatmapHeightPx: getEnvOrDefaultInt(envKeyHeatmapHeightPx, 0), + IsPlayCountDisabled: getEnvOrDefaultBool(envKeyDisablePlayCount, false), } }) return cfg diff --git a/internal/sections/internal/savedfilter.go b/internal/sections/internal/savedfilter.go index 4a695e4..f9c2929 100644 --- a/internal/sections/internal/savedfilter.go +++ b/internal/sections/internal/savedfilter.go @@ -37,7 +37,7 @@ func sectionFromSavedFilterFuncBuilder(ctx context.Context, client graphql.Clien } func sectionFromSavedFilter(ctx context.Context, client graphql.Client, prefix string, savedFilter gql.SavedFilterParts) (section.Section, error) { - filterQuery, err := filter.SavedFilterToSceneFilter(savedFilter) + filterQuery, err := filter.SavedFilterToSceneFilter(ctx, savedFilter) if err != nil { return section.Section{}, fmt.Errorf("SavedFilterToSceneFilter: %w", err) } diff --git a/internal/stash/filter/jsonfilter.go b/internal/stash/filter/jsonfilter.go index d39dcc6..5e116ad 100644 --- a/internal/stash/filter/jsonfilter.go +++ b/internal/stash/filter/jsonfilter.go @@ -14,25 +14,23 @@ type jsonFilter struct { } type jsonCriterion struct { - Modifier string `json:"modifier"` - Type string `json:"type"` - Value interface{} `json:"value"` + Modifier string `json:"modifier"` + Type string `json:"type"` + Value any `json:"value"` } type errUnexpectedType struct { - sourceType string - destinationType string + source any } +type stringAnyMap = map[string]any + func (e errUnexpectedType) Error() string { - return fmt.Sprintf("unexpected type %s is not assertable to %s", e.sourceType, e.destinationType) + return fmt.Sprintf("could not assert '%T' with value='%v'", e.source, e.source) } -func newUnexpectedTypeErr(source any, destinationType string) *errUnexpectedType { - return &errUnexpectedType{ - sourceType: fmt.Sprintf("%T", source), - destinationType: destinationType, - } +func newUnexpectedTypeErr(source any) *errUnexpectedType { + return &errUnexpectedType{source} } func parseJsonCriterion(raw string) (jsonCriterion, error) { @@ -46,23 +44,30 @@ func parseJsonCriterion(raw string) (jsonCriterion, error) { } func (c jsonCriterion) asHierarchicalMultiCriterionInput() (*gql.HierarchicalMultiCriterionInput, error) { - m, ok := c.Value.(map[string]interface{}) - if !ok { - return nil, newUnexpectedTypeErr(c.Value, "map[string]interface{}") + if c.Value == nil { + return &gql.HierarchicalMultiCriterionInput{ + Modifier: gql.CriterionModifier(c.Modifier), + }, nil } - items, ok := m["items"].([]interface{}) + m, ok := c.Value.(stringAnyMap) if !ok { - return nil, newUnexpectedTypeErr(m["items"], "[]interface{}") + return nil, newUnexpectedTypeErr(c.Value) + } + + items, err := getValue[[]any](m, "items") + if err != nil { + return nil, err } + ids := make([]string, len(items)) for i, item := range items { - mid, ok := item.(map[string]interface{}) + mid, ok := item.(stringAnyMap) if !ok { - return nil, newUnexpectedTypeErr(item, "map[string]interface{}") + return nil, newUnexpectedTypeErr(item) } id, ok := mid["id"].(string) if !ok { - return nil, newUnexpectedTypeErr(mid["id"], "string") + return nil, newUnexpectedTypeErr(mid["id"]) } ids[i] = id } @@ -74,9 +79,14 @@ func (c jsonCriterion) asHierarchicalMultiCriterionInput() (*gql.HierarchicalMul } func (c jsonCriterion) asStringCriterionInput() (*gql.StringCriterionInput, error) { + if c.Value == nil { + return &gql.StringCriterionInput{ + Modifier: gql.CriterionModifier(c.Modifier), + }, nil + } s, ok := c.Value.(string) if !ok { - return nil, newUnexpectedTypeErr(c.Value, "string") + return nil, newUnexpectedTypeErr(c.Value) } return &gql.StringCriterionInput{ Value: s, @@ -85,50 +95,49 @@ func (c jsonCriterion) asStringCriterionInput() (*gql.StringCriterionInput, erro } func (c jsonCriterion) asIntCriterionInput() (*gql.IntCriterionInput, error) { - m, ok := c.Value.(map[string]interface{}) + if c.Value == nil { + return &gql.IntCriterionInput{ + Modifier: gql.CriterionModifier(c.Modifier), + }, nil + } + m, ok := c.Value.(stringAnyMap) if !ok { - return nil, newUnexpectedTypeErr(c.Value, "map[string]interface{}") + return nil, newUnexpectedTypeErr(c.Value) } - v, ok := m["value"].(float64) - if !ok { - return nil, newUnexpectedTypeErr(m["value"], "float64") + value, err := getValue[float64](m, "value") + if err != nil { + return nil, newUnexpectedTypeErr(m["value"]) } - var v2 float64 - if m["value2"] != nil { - v2, ok = m["value2"].(float64) - if !ok { - return nil, newUnexpectedTypeErr(m["value2"], "float64") - } + value2, err := getValue[float64](m, "value2") + if err != nil { + return nil, newUnexpectedTypeErr(m["value2"]) } + return &gql.IntCriterionInput{ - Value: int(v), - Value2: int(v2), + Value: int(value), + Value2: int(value2), Modifier: gql.CriterionModifier(c.Modifier), }, nil } func (c jsonCriterion) asBool() (bool, error) { - s, ok := c.Value.(string) + value, ok := c.Value.(string) if !ok { - return false, newUnexpectedTypeErr(c.Value, "string") + return false, newUnexpectedTypeErr(c.Value) } - b, err := strconv.ParseBool(s) + b, err := strconv.ParseBool(value) if err != nil { - return false, fmt.Errorf("failed to parse bool from '%s': %w", s, err) + return false, fmt.Errorf("failed to parse bool from '%s': %w", value, err) } return b, nil } func (c jsonCriterion) asPHashDuplicationCriterionInput() (*gql.PHashDuplicationCriterionInput, error) { - s, ok := c.Value.(string) - if !ok { - return nil, newUnexpectedTypeErr(c.Value, "string") - } - b, err := strconv.ParseBool(s) + b, err := c.asBool() if err != nil { - return nil, fmt.Errorf("failed to parse bool from '%s': %w", s, err) + return nil, err } return &gql.PHashDuplicationCriterionInput{ Duplicated: b, @@ -136,44 +145,49 @@ func (c jsonCriterion) asPHashDuplicationCriterionInput() (*gql.PHashDuplication } func (c jsonCriterion) asResolutionCriterionInput() (*gql.ResolutionCriterionInput, error) { - s, ok := c.Value.(string) + if c.Value == nil { + return &gql.ResolutionCriterionInput{ + Modifier: gql.CriterionModifier(c.Modifier), + }, nil + } + value, ok := c.Value.(string) if !ok { - return nil, newUnexpectedTypeErr(c.Value, "string") + return nil, newUnexpectedTypeErr(c.Value) } - var rs gql.ResolutionEnum + var res gql.ResolutionEnum - switch s { + switch value { case "144p": - rs = gql.ResolutionEnumVeryLow + res = gql.ResolutionEnumVeryLow case "240p": - rs = gql.ResolutionEnumLow + res = gql.ResolutionEnumLow case "360p": - rs = gql.ResolutionEnumR360p + res = gql.ResolutionEnumR360p case "480p": - rs = gql.ResolutionEnumStandard + res = gql.ResolutionEnumStandard case "540p": - rs = gql.ResolutionEnumWebHd + res = gql.ResolutionEnumWebHd case "720p": - rs = gql.ResolutionEnumStandardHd + res = gql.ResolutionEnumStandardHd case "1080p": - rs = gql.ResolutionEnumFullHd + res = gql.ResolutionEnumFullHd case "1440p": - rs = gql.ResolutionEnumQuadHd + res = gql.ResolutionEnumQuadHd case "1920p": - rs = gql.ResolutionEnumVrHd + res = gql.ResolutionEnumVrHd case "4k": - rs = gql.ResolutionEnumFourK + res = gql.ResolutionEnumFourK case "5k": - rs = gql.ResolutionEnumFiveK + res = gql.ResolutionEnumFiveK case "6k": - rs = gql.ResolutionEnumSixK + res = gql.ResolutionEnumSixK case "8k": - rs = gql.ResolutionEnumEightK + res = gql.ResolutionEnumEightK } return &gql.ResolutionCriterionInput{ - Value: rs, + Value: res, Modifier: gql.CriterionModifier(c.Modifier), }, nil } @@ -181,21 +195,27 @@ func (c jsonCriterion) asResolutionCriterionInput() (*gql.ResolutionCriterionInp func (c jsonCriterion) asString() (string, error) { s, ok := c.Value.(string) if !ok { - return "", newUnexpectedTypeErr(c.Value, "string") + return "", newUnexpectedTypeErr(c.Value) } return s, nil } func (c jsonCriterion) asMultiCriterionInput() (*gql.MultiCriterionInput, error) { - cs, ok := c.Value.([]interface{}) + if c.Value == nil { + return &gql.MultiCriterionInput{ + Modifier: gql.CriterionModifier(c.Modifier), + }, nil + } + + value, ok := c.Value.([]any) if !ok { - return nil, newUnexpectedTypeErr(c.Value, "[]interface{}") + return nil, newUnexpectedTypeErr(c.Value) } - ss := make([]string, len(cs)) - for i, v := range cs { + ss := make([]string, len(value)) + for i, v := range value { s, ok := v.(string) if !ok { - return nil, newUnexpectedTypeErr(v, "string") + return nil, newUnexpectedTypeErr(v) } ss[i] = s } @@ -204,3 +224,102 @@ func (c jsonCriterion) asMultiCriterionInput() (*gql.MultiCriterionInput, error) Modifier: gql.CriterionModifier(c.Modifier), }, nil } + +func (c jsonCriterion) asTimestampCriterionInput() (*gql.TimestampCriterionInput, error) { + if c.Value == nil { + return &gql.TimestampCriterionInput{ + Modifier: gql.CriterionModifier(c.Modifier), + }, nil + } + + m, ok := c.Value.(stringAnyMap) + if !ok { + return nil, newUnexpectedTypeErr(c.Value) + } + + value, err := getValue[string](m, "value") + if err != nil { + return nil, err + } + + value2, err := getValue[string](m, "value2") + if err != nil { + return nil, err + } + + return &gql.TimestampCriterionInput{ + Value: value, + Value2: value2, + Modifier: gql.CriterionModifier(c.Modifier), + }, nil +} + +func (c jsonCriterion) asDateCriterionInput() (*gql.DateCriterionInput, error) { + if c.Value == nil { + return &gql.DateCriterionInput{ + Modifier: gql.CriterionModifier(c.Modifier), + }, nil + } + + m, ok := c.Value.(stringAnyMap) + if !ok { + return nil, newUnexpectedTypeErr(c.Value) + } + + value, err := getValue[string](m, "value") + if err != nil { + return nil, err + } + + value2, err := getValue[string](m, "value2") + if err != nil { + return nil, err + } + + return &gql.DateCriterionInput{ + Value: value, + Value2: value2, + Modifier: gql.CriterionModifier(c.Modifier), + }, nil +} + +func (c jsonCriterion) asStashIDCriterionInput() (*gql.StashIDCriterionInput, error) { + if c.Value == nil { + return &gql.StashIDCriterionInput{ + Modifier: gql.CriterionModifier(c.Modifier), + }, nil + } + + m, ok := c.Value.(stringAnyMap) + if !ok { + return nil, newUnexpectedTypeErr(c.Value) + } + + endpoint, err := getValue[string](m, "endpoint") + if err != nil { + return nil, err + } + + stashId, err := getValue[string](m, "stash_id") + if err != nil { + return nil, err + } + + return &gql.StashIDCriterionInput{ + Endpoint: endpoint, + Stash_id: stashId, + Modifier: gql.CriterionModifier(c.Modifier), + }, nil +} + +func getValue[T any](m stringAnyMap, key string) (T, error) { + _, ok := m[key] + if !ok { + return *new(T), nil + } + value, ok := m[key].(T) + if !ok { + return *new(T), newUnexpectedTypeErr(m[key]) + } + return value, nil +} diff --git a/internal/stash/filter/scenefilter.go b/internal/stash/filter/scenefilter.go index 88938da..ba4d3ec 100644 --- a/internal/stash/filter/scenefilter.go +++ b/internal/stash/filter/scenefilter.go @@ -1,8 +1,10 @@ package filter import ( + "context" "encoding/json" "fmt" + "github.com/rs/zerolog/log" "stash-vr/internal/stash/gql" ) @@ -11,26 +13,26 @@ type Filter struct { SceneFilter gql.SceneFilterType } -func SavedFilterToSceneFilter(savedFilter gql.SavedFilterParts) (Filter, error) { +func SavedFilterToSceneFilter(ctx context.Context, savedFilter gql.SavedFilterParts) (Filter, error) { if savedFilter.Mode != gql.FilterModeScenes { return Filter{}, fmt.Errorf("unsupported filter mode") } - filterQuery, err := parseJsonEncodedFilter(savedFilter.Filter) + filterQuery, err := parseJsonEncodedFilter(ctx, savedFilter.Filter) if err != nil { return Filter{}, fmt.Errorf("parseJsonEncodedFilter: %w", err) } return filterQuery, nil } -func parseJsonEncodedFilter(raw string) (Filter, error) { +func parseJsonEncodedFilter(ctx context.Context, raw string) (Filter, error) { var filter jsonFilter err := json.Unmarshal([]byte(raw), &filter) if err != nil { return Filter{}, fmt.Errorf("unmarshal json scene filter '%s': %w", raw, err) } - f, err := parseSceneFilterCriteria(filter.C) + f, err := parseSceneFilterCriteria(ctx, filter.C) if err != nil { return Filter{}, fmt.Errorf("parseSceneFilterCriteria: %w", err) } @@ -46,14 +48,14 @@ func parseJsonEncodedFilter(raw string) (Filter, error) { }, SceneFilter: f}, nil } -func parseSceneFilterCriteria(jsonCriteria []string) (gql.SceneFilterType, error) { +func parseSceneFilterCriteria(ctx context.Context, jsonCriteria []string) (gql.SceneFilterType, error) { f := gql.SceneFilterType{} for _, jsonCriterion := range jsonCriteria { c, err := parseJsonCriterion(jsonCriterion) if err != nil { return gql.SceneFilterType{}, fmt.Errorf("parseJsonCriterion: %w", err) } - err = setSceneFilterCriterion(c, &f) + err = setSceneFilterCriterion(ctx, c, &f) if err != nil { return gql.SceneFilterType{}, fmt.Errorf("setSceneFilterCriterion: %w", err) } @@ -61,168 +63,115 @@ func parseSceneFilterCriteria(jsonCriteria []string) (gql.SceneFilterType, error return f, nil } -func setSceneFilterCriterion(criterion jsonCriterion, sceneFilter *gql.SceneFilterType) error { +func setSceneFilterCriterion(ctx context.Context, criterion jsonCriterion, sceneFilter *gql.SceneFilterType) error { var err error switch criterion.Type { //HierarchicalMultiCriterionInput case "tags": sceneFilter.Tags, err = criterion.asHierarchicalMultiCriterionInput() - if err != nil { - return fmt.Errorf("AsHierarchicalMultiCriterionInput: %w", err) - } case "studios": sceneFilter.Studios, err = criterion.asHierarchicalMultiCriterionInput() - if err != nil { - return fmt.Errorf("AsHierarchicalMultiCriterionInput: %w", err) - } case "performerTags": sceneFilter.Performer_tags, err = criterion.asHierarchicalMultiCriterionInput() - if err != nil { - return fmt.Errorf("AsHierarchicalMultiCriterionInput: %w", err) - } //StringCriterionInput case "title": sceneFilter.Title, err = criterion.asStringCriterionInput() - if err != nil { - return fmt.Errorf("AsStringCriterionInput: %w", err) - } + case "code": + sceneFilter.Code, err = criterion.asStringCriterionInput() case "details": sceneFilter.Details, err = criterion.asStringCriterionInput() - if err != nil { - return fmt.Errorf("AsStringCriterionInput: %w", err) - } + case "director": + sceneFilter.Director, err = criterion.asStringCriterionInput() case "oshash": sceneFilter.Oshash, err = criterion.asStringCriterionInput() - if err != nil { - return fmt.Errorf("AsStringCriterionInput: %w", err) - } case "sceneChecksum": sceneFilter.Checksum, err = criterion.asStringCriterionInput() - if err != nil { - return fmt.Errorf("AsStringCriterionInput: %w", err) - } case "phash": sceneFilter.Phash, err = criterion.asStringCriterionInput() - if err != nil { - return fmt.Errorf("AsStringCriterionInput: %w", err) - } case "path": sceneFilter.Path, err = criterion.asStringCriterionInput() - if err != nil { - return fmt.Errorf("AsStringCriterionInput: %w", err) - } case "stash_id": sceneFilter.Stash_id, err = criterion.asStringCriterionInput() - if err != nil { - return fmt.Errorf("AsStringCriterionInput: %w", err) - } case "url": sceneFilter.Url, err = criterion.asStringCriterionInput() - if err != nil { - return fmt.Errorf("AsStringCriterionInput: %w", err) - } case "captions": sceneFilter.Captions, err = criterion.asStringCriterionInput() - if err != nil { - return fmt.Errorf("AsStringCriterionInput: %w", err) - } //IntCriterionInput + case "id": + sceneFilter.Id, err = criterion.asIntCriterionInput() case "rating": sceneFilter.Rating, err = criterion.asIntCriterionInput() - if err != nil { - return fmt.Errorf("AsIntCriterionInput: %w", err) - } + case "rating100": + sceneFilter.Rating100, err = criterion.asIntCriterionInput() case "o_counter": sceneFilter.O_counter, err = criterion.asIntCriterionInput() - if err != nil { - return fmt.Errorf("AsIntCriterionInput: %w", err) - } case "duration": sceneFilter.Duration, err = criterion.asIntCriterionInput() - if err != nil { - return fmt.Errorf("AsIntCriterionInput: %w", err) - } case "tag_count": sceneFilter.Tag_count, err = criterion.asIntCriterionInput() - if err != nil { - return fmt.Errorf("AsIntCriterionInput: %w", err) - } case "performer_age": sceneFilter.Performer_age, err = criterion.asIntCriterionInput() - if err != nil { - return fmt.Errorf("AsIntCriterionInput: %w", err) - } case "performer_count": sceneFilter.Performer_count, err = criterion.asIntCriterionInput() - if err != nil { - return fmt.Errorf("AsIntCriterionInput: %w", err) - } case "interactive_speed": sceneFilter.Interactive_speed, err = criterion.asIntCriterionInput() - if err != nil { - return fmt.Errorf("AsIntCriterionInput: %w", err) - } case "file_count": sceneFilter.File_count, err = criterion.asIntCriterionInput() - if err != nil { - return fmt.Errorf("AsIntCriterionInput: %w", err) - } + case "resume_time": + sceneFilter.Resume_time, err = criterion.asIntCriterionInput() + case "play_count": + sceneFilter.Play_count, err = criterion.asIntCriterionInput() + case "play_duration": + sceneFilter.Play_duration, err = criterion.asIntCriterionInput() + //bool case "organized": sceneFilter.Organized, err = criterion.asBool() - if err != nil { - return fmt.Errorf("AsBool: %w", err) - } case "performer_favorite": sceneFilter.Performer_favorite, err = criterion.asBool() - if err != nil { - return fmt.Errorf("AsBool: %w", err) - } case "interactive": sceneFilter.Interactive, err = criterion.asBool() - if err != nil { - return fmt.Errorf("AsBool: %w", err) - } //PHashDuplicationCriterionInput case "duplicated": sceneFilter.Duplicated, err = criterion.asPHashDuplicationCriterionInput() - if err != nil { - return fmt.Errorf("AsPHashDuplicationCriterionInput: %w", err) - } //ResolutionCriterionInput case "resolution": sceneFilter.Resolution, err = criterion.asResolutionCriterionInput() - if err != nil { - return fmt.Errorf("AsResolutionCriterionInput: %w", err) - } //string case "hasMarkers": sceneFilter.Has_markers, err = criterion.asString() - if err != nil { - return fmt.Errorf("AsString: %w", err) - } case "sceneIsMissing": sceneFilter.Is_missing, err = criterion.asString() - if err != nil { - return fmt.Errorf("AsString: %w", err) - } //MultiCriterionInput case "movies": sceneFilter.Movies, err = criterion.asMultiCriterionInput() - if err != nil { - return fmt.Errorf("AsMultiCriterionInput: %w", err) - } case "performers": sceneFilter.Performers, err = criterion.asMultiCriterionInput() - if err != nil { - return fmt.Errorf("AsMultiCriterionInput: %w", err) - } + + //TimestampCriterionInput + case "created_at": + sceneFilter.Created_at, err = criterion.asTimestampCriterionInput() + case "updated_at": + sceneFilter.Updated_at, err = criterion.asTimestampCriterionInput() + + //DateCriterionInput + case "date": + sceneFilter.Date, err = criterion.asDateCriterionInput() + case "stash_id_endpoint": + sceneFilter.Stash_id_endpoint, err = criterion.asStashIDCriterionInput() + //StashIDCriterionInput + + default: + log.Ctx(ctx).Warn().Str("type", criterion.Type).Interface("value", criterion.Value).Msg("Ignoring unsupported criterion") + } + if err != nil { + return fmt.Errorf("failed to parse criterion (%v): %w", criterion, err) } return nil } diff --git a/internal/stash/gql/mutation.graphql b/internal/stash/gql/mutation.graphql index dc5ea23..4eabfce 100644 --- a/internal/stash/gql/mutation.graphql +++ b/internal/stash/gql/mutation.graphql @@ -1,7 +1,7 @@ -mutation SceneUpdateRating($id: ID!, $rating: Int) { +mutation SceneUpdateRating100($id: ID!, $rating: Int) { sceneUpdate(input: { id: $id, - rating: $rating + rating100: $rating }){id} } @@ -63,4 +63,8 @@ mutation SceneIncrementO($id: ID!){ mutation SceneUpdateOrganized($id: ID!, $isOrganized: Boolean){ sceneUpdate(input: {id: $id, organized: $isOrganized}){id, organized} +} + +mutation SceneIncrementPlayCount($id: ID!){ + sceneIncrementPlayCount(id: $id) } \ No newline at end of file diff --git a/internal/stash/gql/query.graphql b/internal/stash/gql/query.graphql index 3f4b381..cbcec8c 100644 --- a/internal/stash/gql/query.graphql +++ b/internal/stash/gql/query.graphql @@ -148,7 +148,7 @@ fragment SceneFullParts on Scene{ } fragment SceneScanParts on Scene{ - id, title, rating, created_at, date + id, title, rating100, created_at, date files{ basename, duration } @@ -169,7 +169,8 @@ fragment SceneScanParts on Scene{ movie { name } - } + }, + play_count, o_counter, organized } diff --git a/internal/stash/gql/schema/schema.graphql b/internal/stash/gql/schema/schema.graphql index 15dd669..959e52b 100644 --- a/internal/stash/gql/schema/schema.graphql +++ b/internal/stash/gql/schema/schema.graphql @@ -162,7 +162,9 @@ type Mutation { setup(input: SetupInput!): Boolean! migrate(input: MigrateInput!): Boolean! + sceneCreate(input: SceneCreateInput!): Scene sceneUpdate(input: SceneUpdateInput!): Scene + sceneMerge(input: SceneMergeInput!): Scene bulkSceneUpdate(input: BulkSceneUpdateInput!): [Scene!] sceneDestroy(input: SceneDestroyInput!): Boolean! scenesDestroy(input: ScenesDestroyInput!): Boolean! @@ -175,6 +177,12 @@ type Mutation { """Resets the o-counter for a scene to 0. Returns the new value""" sceneResetO(id: ID!): Int! + """Sets the resume time point (if provided) and adds the provided duration to the scene's play duration""" + sceneSaveActivity(id: ID!, resume_time: Float, playDuration: Float): Boolean! + + """Increments the play count for the scene. Returns the new play count value.""" + sceneIncrementPlayCount(id: ID!): Int! + """Generates screenshot at specified time in seconds. Leave empty to generate default screenshot""" sceneGenerateScreenshot(id: ID!, at: Float): String! @@ -182,6 +190,8 @@ type Mutation { sceneMarkerUpdate(input: SceneMarkerUpdateInput!): SceneMarker sceneMarkerDestroy(id: ID!): Boolean! + sceneAssignFile(input: AssignSceneFileInput!): Boolean! + imageUpdate(input: ImageUpdateInput!): Image bulkImageUpdate(input: BulkImageUpdateInput!): [Image!] imageDestroy(input: ImageDestroyInput!): Boolean! diff --git a/internal/stash/gql/schema/types/config.graphql b/internal/stash/gql/schema/types/config.graphql index 48f2de3..7cd1fea 100644 --- a/internal/stash/gql/schema/types/config.graphql +++ b/internal/stash/gql/schema/types/config.graphql @@ -264,6 +264,10 @@ input ConfigInterfaceInput { css: String cssEnabled: Boolean + """Custom Javascript""" + javascript: String + javascriptEnabled: Boolean + """Custom Locales""" customLocales: String customLocalesEnabled: Boolean @@ -330,6 +334,10 @@ type ConfigInterfaceResult { css: String cssEnabled: Boolean + """Custom Javascript""" + javascript: String + javascriptEnabled: Boolean + """Custom Locales""" customLocales: String customLocalesEnabled: Boolean diff --git a/internal/stash/gql/schema/types/filters.graphql b/internal/stash/gql/schema/types/filters.graphql index 850d46a..b391ef0 100644 --- a/internal/stash/gql/schema/types/filters.graphql +++ b/internal/stash/gql/schema/types/filters.graphql @@ -39,6 +39,14 @@ input PHashDuplicationCriterionInput { distance: Int } +input StashIDCriterionInput { + """If present, this value is treated as a predicate. + That is, it will filter based on stash_ids with the matching endpoint""" + endpoint: String + stash_id: String + modifier: CriterionModifier! +} + input PerformerFilterType { AND: PerformerFilterType OR: PerformerFilterType @@ -60,7 +68,9 @@ input PerformerFilterType { """Filter by eye color""" eye_color: StringCriterionInput """Filter by height""" - height: StringCriterionInput + height: StringCriterionInput @deprecated(reason: "Use height_cm instead") + """Filter by height in cm""" + height_cm: IntCriterionInput """Filter by measurements""" measurements: StringCriterionInput """Filter by fake tits value""" @@ -88,9 +98,13 @@ input PerformerFilterType { """Filter by gallery count""" gallery_count: IntCriterionInput """Filter by StashID""" - stash_id: StringCriterionInput + stash_id: StringCriterionInput @deprecated(reason: "Use stash_id_endpoint instead") + """Filter by StashID""" + stash_id_endpoint: StashIDCriterionInput """Filter by rating""" - rating: IntCriterionInput + rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: IntCriterionInput """Filter by url""" url: StringCriterionInput """Filter by hair color""" @@ -103,6 +117,14 @@ input PerformerFilterType { studios: HierarchicalMultiCriterionInput """Filter by autotag ignore value""" ignore_auto_tag: Boolean + """Filter by birthdate""" + birthdate: DateCriterionInput + """Filter by death date""" + death_date: DateCriterionInput + """Filter by creation time""" + created_at: TimestampCriterionInput + """Filter by last update time""" + updated_at: TimestampCriterionInput } input SceneMarkerFilterType { @@ -114,6 +136,16 @@ input SceneMarkerFilterType { scene_tags: HierarchicalMultiCriterionInput """Filter to only include scene markers with these performers""" performers: MultiCriterionInput + """Filter by creation time""" + created_at: TimestampCriterionInput + """Filter by last update time""" + updated_at: TimestampCriterionInput + """Filter by scene date""" + scene_date: DateCriterionInput + """Filter by cscene reation time""" + scene_created_at: TimestampCriterionInput + """Filter by lscene ast update time""" + scene_updated_at: TimestampCriterionInput } input SceneFilterType { @@ -121,8 +153,11 @@ input SceneFilterType { OR: SceneFilterType NOT: SceneFilterType + id: IntCriterionInput title: StringCriterionInput + code: StringCriterionInput details: StringCriterionInput + director: StringCriterionInput """Filter by file oshash""" oshash: StringCriterionInput @@ -135,7 +170,9 @@ input SceneFilterType { """Filter by file count""" file_count: IntCriterionInput """Filter by rating""" - rating: IntCriterionInput + rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: IntCriterionInput """Filter by organized""" organized: Boolean """Filter by o-counter""" @@ -169,7 +206,9 @@ input SceneFilterType { """Filter by performer count""" performer_count: IntCriterionInput """Filter by StashID""" - stash_id: StringCriterionInput + stash_id: StringCriterionInput @deprecated(reason: "Use stash_id_endpoint instead") + """Filter by StashID""" + stash_id_endpoint: StashIDCriterionInput """Filter by url""" url: StringCriterionInput """Filter by interactive""" @@ -178,6 +217,18 @@ input SceneFilterType { interactive_speed: IntCriterionInput """Filter by captions""" captions: StringCriterionInput + """Filter by resume time""" + resume_time: IntCriterionInput + """Filter by play count""" + play_count: IntCriterionInput + """Filter by play duration (in seconds)""" + play_duration: IntCriterionInput + """Filter by date""" + date: DateCriterionInput + """Filter by creation time""" + created_at: TimestampCriterionInput + """Filter by last update time""" + updated_at: TimestampCriterionInput } input MovieFilterType { @@ -189,7 +240,9 @@ input MovieFilterType { """Filter by duration (in seconds)""" duration: IntCriterionInput """Filter by rating""" - rating: IntCriterionInput + rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: IntCriterionInput """Filter to only include movies with this studio""" studios: HierarchicalMultiCriterionInput """Filter to only include movies missing this property""" @@ -198,6 +251,12 @@ input MovieFilterType { url: StringCriterionInput """Filter to only include movies where performer appears in a scene""" performers: MultiCriterionInput + """Filter by date""" + date: DateCriterionInput + """Filter by creation time""" + created_at: TimestampCriterionInput + """Filter by last update time""" + updated_at: TimestampCriterionInput } input StudioFilterType { @@ -210,11 +269,15 @@ input StudioFilterType { """Filter to only include studios with this parent studio""" parents: MultiCriterionInput """Filter by StashID""" - stash_id: StringCriterionInput + stash_id: StringCriterionInput @deprecated(reason: "Use stash_id_endpoint instead") + """Filter by StashID""" + stash_id_endpoint: StashIDCriterionInput """Filter to only include studios missing this property""" is_missing: String """Filter by rating""" - rating: IntCriterionInput + rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: IntCriterionInput """Filter by scene count""" scene_count: IntCriterionInput """Filter by image count""" @@ -227,6 +290,10 @@ input StudioFilterType { aliases: StringCriterionInput """Filter by autotag ignore value""" ignore_auto_tag: Boolean + """Filter by creation time""" + created_at: TimestampCriterionInput + """Filter by last update time""" + updated_at: TimestampCriterionInput } input GalleryFilterType { @@ -234,6 +301,7 @@ input GalleryFilterType { OR: GalleryFilterType NOT: GalleryFilterType + id: IntCriterionInput title: StringCriterionInput details: StringCriterionInput @@ -248,7 +316,9 @@ input GalleryFilterType { """Filter to include/exclude galleries that were created from zip""" is_zip: Boolean """Filter by rating""" - rating: IntCriterionInput + rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: IntCriterionInput """Filter by organized""" organized: Boolean """Filter by average image resolution""" @@ -273,6 +343,12 @@ input GalleryFilterType { image_count: IntCriterionInput """Filter by url""" url: StringCriterionInput + """Filter by date""" + date: DateCriterionInput + """Filter by creation time""" + created_at: TimestampCriterionInput + """Filter by last update time""" + updated_at: TimestampCriterionInput } input TagFilterType { @@ -286,6 +362,9 @@ input TagFilterType { """Filter by tag aliases""" aliases: StringCriterionInput + """Filter by tag description""" + description: StringCriterionInput + """Filter to only include tags missing this property""" is_missing: String @@ -318,6 +397,12 @@ input TagFilterType { """Filter by autotag ignore value""" ignore_auto_tag: Boolean + + """Filter by creation time""" + created_at: TimestampCriterionInput + + """Filter by last update time""" + updated_at: TimestampCriterionInput } input ImageFilterType { @@ -327,6 +412,8 @@ input ImageFilterType { title: StringCriterionInput + """ Filter by image id""" + id: IntCriterionInput """Filter by file checksum""" checksum: StringCriterionInput """Filter by path""" @@ -334,7 +421,9 @@ input ImageFilterType { """Filter by file count""" file_count: IntCriterionInput """Filter by rating""" - rating: IntCriterionInput + rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: IntCriterionInput """Filter by organized""" organized: Boolean """Filter by o-counter""" @@ -359,6 +448,10 @@ input ImageFilterType { performer_favorite: Boolean """Filter to only include images with these galleries""" galleries: MultiCriterionInput + """Filter by creation time""" + created_at: TimestampCriterionInput + """Filter by last update time""" + updated_at: TimestampCriterionInput } enum CriterionModifier { @@ -415,6 +508,18 @@ input HierarchicalMultiCriterionInput { depth: Int } +input DateCriterionInput { + value: String! + value2: String + modifier: CriterionModifier! +} + +input TimestampCriterionInput { + value: String! + value2: String + modifier: CriterionModifier! +} + enum FilterMode { SCENES, PERFORMERS, diff --git a/internal/stash/gql/schema/types/gallery.graphql b/internal/stash/gql/schema/types/gallery.graphql index 993f5e0..3716b94 100644 --- a/internal/stash/gql/schema/types/gallery.graphql +++ b/internal/stash/gql/schema/types/gallery.graphql @@ -7,7 +7,10 @@ type Gallery { url: String date: String details: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int organized: Boolean! created_at: Time! updated_at: Time! @@ -23,7 +26,7 @@ type Gallery { performers: [Performer!]! """The images in the gallery""" - images: [Image!]! # Resolver + images: [Image!]! @deprecated(reason: "Use findImages") cover: Image } @@ -32,7 +35,10 @@ input GalleryCreateInput { url: String date: String details: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int organized: Boolean scene_ids: [ID!] studio_id: ID @@ -47,7 +53,10 @@ input GalleryUpdateInput { url: String date: String details: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int organized: Boolean scene_ids: [ID!] studio_id: ID @@ -63,7 +72,10 @@ input BulkGalleryUpdateInput { url: String date: String details: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int organized: Boolean scene_ids: BulkUpdateIds studio_id: ID diff --git a/internal/stash/gql/schema/types/image.graphql b/internal/stash/gql/schema/types/image.graphql index 82aa1e4..3eed1ee 100644 --- a/internal/stash/gql/schema/types/image.graphql +++ b/internal/stash/gql/schema/types/image.graphql @@ -2,7 +2,10 @@ type Image { id: ID! checksum: String @deprecated(reason: "Use files.fingerprints") title: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int o_counter: Int organized: Boolean! path: String! @deprecated(reason: "Use files.path") @@ -37,7 +40,10 @@ input ImageUpdateInput { clientMutationId: String id: ID! title: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int organized: Boolean studio_id: ID @@ -52,7 +58,10 @@ input BulkImageUpdateInput { clientMutationId: String ids: [ID!] title: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int organized: Boolean studio_id: ID diff --git a/internal/stash/gql/schema/types/movie.graphql b/internal/stash/gql/schema/types/movie.graphql index 3d100e1..14910c0 100644 --- a/internal/stash/gql/schema/types/movie.graphql +++ b/internal/stash/gql/schema/types/movie.graphql @@ -6,7 +6,10 @@ type Movie { """Duration in seconds""" duration: Int date: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int studio: Studio director: String synopsis: String @@ -26,7 +29,10 @@ input MovieCreateInput { """Duration in seconds""" duration: Int date: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int studio_id: ID director: String synopsis: String @@ -43,7 +49,10 @@ input MovieUpdateInput { aliases: String duration: Int date: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int studio_id: ID director: String synopsis: String @@ -57,7 +66,10 @@ input MovieUpdateInput { input BulkMovieUpdateInput { clientMutationId: String ids: [ID!] - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int studio_id: ID director: String } diff --git a/internal/stash/gql/schema/types/performer.graphql b/internal/stash/gql/schema/types/performer.graphql index e69d52e..651341f 100644 --- a/internal/stash/gql/schema/types/performer.graphql +++ b/internal/stash/gql/schema/types/performer.graphql @@ -19,7 +19,8 @@ type Performer { ethnicity: String country: String eye_color: String - height: String + height: String @deprecated(reason: "Use height_cm") + height_cm: Int measurements: String fake_tits: String career_length: String @@ -36,7 +37,10 @@ type Performer { gallery_count: Int # Resolver scenes: [Scene!]! stash_ids: [StashID!]! - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int details: String death_date: String hair_color: String @@ -55,7 +59,9 @@ input PerformerCreateInput { ethnicity: String country: String eye_color: String - height: String + # height must be parsable into an integer + height: String @deprecated(reason: "Use height_cm") + height_cm: Int measurements: String fake_tits: String career_length: String @@ -69,7 +75,10 @@ input PerformerCreateInput { """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int details: String death_date: String hair_color: String @@ -86,7 +95,9 @@ input PerformerUpdateInput { ethnicity: String country: String eye_color: String - height: String + # height must be parsable into an integer + height: String @deprecated(reason: "Use height_cm") + height_cm: Int measurements: String fake_tits: String career_length: String @@ -100,7 +111,10 @@ input PerformerUpdateInput { """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int details: String death_date: String hair_color: String @@ -117,7 +131,9 @@ input BulkPerformerUpdateInput { ethnicity: String country: String eye_color: String - height: String + # height must be parsable into an integer + height: String @deprecated(reason: "Use height_cm") + height_cm: Int measurements: String fake_tits: String career_length: String @@ -128,7 +144,10 @@ input BulkPerformerUpdateInput { instagram: String favorite: Boolean tag_ids: BulkUpdateIds - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int details: String death_date: String hair_color: String diff --git a/internal/stash/gql/schema/types/scene.graphql b/internal/stash/gql/schema/types/scene.graphql index 13c22cd..7ec2134 100644 --- a/internal/stash/gql/schema/types/scene.graphql +++ b/internal/stash/gql/schema/types/scene.graphql @@ -15,7 +15,7 @@ type ScenePathsType { stream: String # Resolver webp: String # Resolver vtt: String # Resolver - chapters_vtt: String # Resolver + chapters_vtt: String @deprecated sprite: String # Resolver funscript: String # Resolver interactive_heatmap: String # Resolver @@ -37,10 +37,15 @@ type Scene { checksum: String @deprecated(reason: "Use files.fingerprints") oshash: String @deprecated(reason: "Use files.fingerprints") title: String + code: String details: String + director: String url: String date: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int organized: Boolean! o_counter: Int path: String! @deprecated(reason: "Use files.path") @@ -51,6 +56,14 @@ type Scene { created_at: Time! updated_at: Time! file_mod_time: Time + """The last time play count was updated""" + last_played_at: Time + """The time index a scene was left at""" + resume_time: Float + """The total time a scene has spent playing""" + play_duration: Float + """The number ot times a scene has been played""" + play_count: Int file: SceneFileType! @deprecated(reason: "Use files") files: [VideoFile!]! @@ -73,14 +86,46 @@ input SceneMovieInput { scene_index: Int } +input SceneCreateInput { + title: String + code: String + details: String + director: String + url: String + date: String + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int + organized: Boolean + studio_id: ID + gallery_ids: [ID!] + performer_ids: [ID!] + movies: [SceneMovieInput!] + tag_ids: [ID!] + """This should be a URL or a base64 encoded data URL""" + cover_image: String + stash_ids: [StashIDInput!] + + """The first id will be assigned as primary. Files will be reassigned from + existing scenes if applicable. Files must not already be primary for another scene""" + file_ids: [ID!] +} + input SceneUpdateInput { clientMutationId: String id: ID! title: String + code: String details: String + director: String url: String date: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int + o_counter: Int organized: Boolean studio_id: ID gallery_ids: [ID!] @@ -91,6 +136,13 @@ input SceneUpdateInput { cover_image: String stash_ids: [StashIDInput!] + """The time index a scene was left at""" + resume_time: Float + """The total time a scene has spent playing""" + play_duration: Float + """The number ot times a scene has been played""" + play_count: Int + primary_file_id: ID } @@ -109,10 +161,15 @@ input BulkSceneUpdateInput { clientMutationId: String ids: [ID!] title: String + code: String details: String + director: String url: String date: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int organized: Boolean studio_id: ID gallery_ids: BulkUpdateIds @@ -157,10 +214,15 @@ type SceneMovieID { type SceneParserResult { scene: Scene! title: String + code: String details: String + director: String url: String date: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int studio_id: ID gallery_ids: [ID!] performer_ids: [ID!] @@ -183,3 +245,17 @@ type SceneStreamEndpoint { mime_type: String label: String } + +input AssignSceneFileInput { + scene_id: ID! + file_id: ID! +} + +input SceneMergeInput { + """If destination scene has no files, then the primary file of the + first source scene will be assigned as primary""" + source: [ID!]! + destination: ID! + # values defined here will override values in the destination + values: SceneUpdateInput +} diff --git a/internal/stash/gql/schema/types/scraper.graphql b/internal/stash/gql/schema/types/scraper.graphql index fb0f9ce..1230fde 100644 --- a/internal/stash/gql/schema/types/scraper.graphql +++ b/internal/stash/gql/schema/types/scraper.graphql @@ -61,7 +61,9 @@ type ScrapedTag { type ScrapedScene { title: String + code: String details: String + director: String url: String date: String @@ -82,7 +84,9 @@ type ScrapedScene { input ScrapedSceneInput { title: String + code: String details: String + director: String url: String date: String diff --git a/internal/stash/gql/schema/types/studio.graphql b/internal/stash/gql/schema/types/studio.graphql index 7bf4bb3..097ea83 100644 --- a/internal/stash/gql/schema/types/studio.graphql +++ b/internal/stash/gql/schema/types/studio.graphql @@ -13,7 +13,10 @@ type Studio { image_count: Int # Resolver gallery_count: Int # Resolver stash_ids: [StashID!]! - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int details: String created_at: Time! updated_at: Time! @@ -28,7 +31,10 @@ input StudioCreateInput { """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int details: String aliases: [String!] ignore_auto_tag: Boolean @@ -42,7 +48,10 @@ input StudioUpdateInput { """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int details: String aliases: [String!] ignore_auto_tag: Boolean