mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
Image improvements (#847)
* Fix image performer filtering * Add performer images tab * Add studio images tab * Rename interface * Add tag images tab * Add path filtering for images * Show image stats on stats page * Fix incorrect scan counts after timeout * Add gallery filters * Relax scene gallery selector
This commit is contained in:
@@ -47,7 +47,9 @@ query ValidGalleriesForScene($scene_id: ID!) {
|
||||
query Stats {
|
||||
stats {
|
||||
scene_count,
|
||||
scene_size_count,
|
||||
scenes_size,
|
||||
image_count,
|
||||
images_size,
|
||||
gallery_count,
|
||||
performer_count,
|
||||
studio_count,
|
||||
|
||||
@@ -111,6 +111,8 @@ input GalleryFilterType {
|
||||
is_zip: Boolean
|
||||
"""Filter by rating"""
|
||||
rating: IntCriterionInput
|
||||
"""Filter by average image resolution"""
|
||||
average_resolution: ResolutionEnum
|
||||
"""Filter to only include scenes with this studio"""
|
||||
studios: MultiCriterionInput
|
||||
"""Filter to only include scenes with these tags"""
|
||||
@@ -133,6 +135,8 @@ input TagFilterType {
|
||||
}
|
||||
|
||||
input ImageFilterType {
|
||||
"""Filter by path"""
|
||||
path: StringCriterionInput
|
||||
"""Filter by rating"""
|
||||
rating: IntCriterionInput
|
||||
"""Filter by o-counter"""
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
type StatsResultType {
|
||||
scene_count: Int!
|
||||
scene_size_count: String!
|
||||
scenes_size: Int!
|
||||
image_count: Int!
|
||||
images_size: Int!
|
||||
gallery_count: Int!
|
||||
performer_count: Int!
|
||||
studio_count: Int!
|
||||
|
||||
@@ -117,7 +117,10 @@ func (r *queryResolver) ValidGalleriesForScene(ctx context.Context, scene_id *st
|
||||
func (r *queryResolver) Stats(ctx context.Context) (*models.StatsResultType, error) {
|
||||
scenesQB := models.NewSceneQueryBuilder()
|
||||
scenesCount, _ := scenesQB.Count()
|
||||
scenesSizeCount, _ := scenesQB.SizeCount()
|
||||
scenesSize, _ := scenesQB.Size()
|
||||
imageQB := models.NewImageQueryBuilder()
|
||||
imageCount, _ := imageQB.Count()
|
||||
imageSize, _ := imageQB.Size()
|
||||
galleryQB := models.NewGalleryQueryBuilder()
|
||||
galleryCount, _ := galleryQB.Count()
|
||||
performersQB := models.NewPerformerQueryBuilder()
|
||||
@@ -130,7 +133,9 @@ func (r *queryResolver) Stats(ctx context.Context) (*models.StatsResultType, err
|
||||
tagsCount, _ := tagsQB.Count()
|
||||
return &models.StatsResultType{
|
||||
SceneCount: scenesCount,
|
||||
SceneSizeCount: scenesSizeCount,
|
||||
ScenesSize: int(scenesSize),
|
||||
ImageCount: imageCount,
|
||||
ImagesSize: int(imageSize),
|
||||
GalleryCount: galleryCount,
|
||||
PerformerCount: performersCount,
|
||||
StudioCount: studiosCount,
|
||||
|
||||
@@ -115,7 +115,8 @@ func (s *singleton) neededScan() (total *int, newFiles *int) {
|
||||
})
|
||||
|
||||
if err == timeoutErr {
|
||||
break
|
||||
// timeout should return nil counts
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -203,6 +203,8 @@ func (qb *GalleryQueryBuilder) Query(galleryFilter *GalleryFilterType, findFilte
|
||||
}
|
||||
|
||||
query.handleStringCriterionInput(galleryFilter.Path, "galleries.path")
|
||||
query.handleIntCriterionInput(galleryFilter.Rating, "galleries.rating")
|
||||
qb.handleAverageResolutionFilter(&query, galleryFilter.AverageResolution)
|
||||
|
||||
if isMissingFilter := galleryFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" {
|
||||
switch *isMissingFilter {
|
||||
@@ -265,6 +267,48 @@ func (qb *GalleryQueryBuilder) Query(galleryFilter *GalleryFilterType, findFilte
|
||||
return galleries, countResult
|
||||
}
|
||||
|
||||
func (qb *GalleryQueryBuilder) handleAverageResolutionFilter(query *queryBuilder, resolutionFilter *ResolutionEnum) {
|
||||
if resolutionFilter == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if resolution := resolutionFilter.String(); resolutionFilter.IsValid() {
|
||||
var low int
|
||||
var high int
|
||||
|
||||
switch resolution {
|
||||
case "LOW":
|
||||
high = 480
|
||||
case "STANDARD":
|
||||
low = 480
|
||||
high = 720
|
||||
case "STANDARD_HD":
|
||||
low = 720
|
||||
high = 1080
|
||||
case "FULL_HD":
|
||||
low = 1080
|
||||
high = 2160
|
||||
case "FOUR_K":
|
||||
low = 2160
|
||||
}
|
||||
|
||||
havingClause := ""
|
||||
if low != 0 {
|
||||
havingClause = "avg(images.height) >= " + strconv.Itoa(low)
|
||||
}
|
||||
if high != 0 {
|
||||
if havingClause != "" {
|
||||
havingClause += " AND "
|
||||
}
|
||||
havingClause += "avg(images.height) < " + strconv.Itoa(high)
|
||||
}
|
||||
|
||||
if havingClause != "" {
|
||||
query.addHaving(havingClause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *GalleryQueryBuilder) getGallerySort(findFilter *FindFilterType) string {
|
||||
var sort string
|
||||
var direction string
|
||||
|
||||
@@ -153,6 +153,44 @@ func verifyGalleriesPath(t *testing.T, pathCriterion models.StringCriterionInput
|
||||
}
|
||||
}
|
||||
|
||||
func TestGalleryQueryRating(t *testing.T) {
|
||||
const rating = 3
|
||||
ratingCriterion := models.IntCriterionInput{
|
||||
Value: rating,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
}
|
||||
|
||||
verifyGalleriesRating(t, ratingCriterion)
|
||||
|
||||
ratingCriterion.Modifier = models.CriterionModifierNotEquals
|
||||
verifyGalleriesRating(t, ratingCriterion)
|
||||
|
||||
ratingCriterion.Modifier = models.CriterionModifierGreaterThan
|
||||
verifyGalleriesRating(t, ratingCriterion)
|
||||
|
||||
ratingCriterion.Modifier = models.CriterionModifierLessThan
|
||||
verifyGalleriesRating(t, ratingCriterion)
|
||||
|
||||
ratingCriterion.Modifier = models.CriterionModifierIsNull
|
||||
verifyGalleriesRating(t, ratingCriterion)
|
||||
|
||||
ratingCriterion.Modifier = models.CriterionModifierNotNull
|
||||
verifyGalleriesRating(t, ratingCriterion)
|
||||
}
|
||||
|
||||
func verifyGalleriesRating(t *testing.T, ratingCriterion models.IntCriterionInput) {
|
||||
sqb := models.NewGalleryQueryBuilder()
|
||||
galleryFilter := models.GalleryFilterType{
|
||||
Rating: &ratingCriterion,
|
||||
}
|
||||
|
||||
galleries, _ := sqb.Query(&galleryFilter, nil)
|
||||
|
||||
for _, gallery := range galleries {
|
||||
verifyInt64(t, gallery.Rating, ratingCriterion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGalleryQueryIsMissingScene(t *testing.T) {
|
||||
qb := models.NewGalleryQueryBuilder()
|
||||
isMissing := "scene"
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/stashapp/stash/pkg/database"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
const imageTable = "images"
|
||||
@@ -234,12 +233,8 @@ func (qb *ImageQueryBuilder) Count() (int, error) {
|
||||
return runCountQuery(buildCountQuery("SELECT images.id FROM images"), nil)
|
||||
}
|
||||
|
||||
func (qb *ImageQueryBuilder) SizeCount() (string, error) {
|
||||
sum, err := runSumQuery("SELECT SUM(size) as sum FROM images", nil)
|
||||
if err != nil {
|
||||
return "0 B", err
|
||||
}
|
||||
return utils.HumanizeBytes(sum), err
|
||||
func (qb *ImageQueryBuilder) Size() (uint64, error) {
|
||||
return runSumQuery("SELECT SUM(size) as sum FROM images", nil)
|
||||
}
|
||||
|
||||
func (qb *ImageQueryBuilder) CountByStudioID(studioID int) (int, error) {
|
||||
@@ -283,6 +278,8 @@ func (qb *ImageQueryBuilder) Query(imageFilter *ImageFilterType, findFilter *Fin
|
||||
query.addArg(thisArgs...)
|
||||
}
|
||||
|
||||
query.handleStringCriterionInput(imageFilter.Path, "images.path")
|
||||
|
||||
if rating := imageFilter.Rating; rating != nil {
|
||||
clause, count := getIntCriterionWhereClause("images.rating", *imageFilter.Rating)
|
||||
query.addWhere(clause)
|
||||
|
||||
@@ -106,6 +106,34 @@ func imageQueryQ(t *testing.T, sqb models.ImageQueryBuilder, q string, expectedI
|
||||
assert.Len(t, images, totalImages)
|
||||
}
|
||||
|
||||
func TestImageQueryPath(t *testing.T) {
|
||||
const imageIdx = 1
|
||||
imagePath := getImageStringValue(imageIdx, "Path")
|
||||
|
||||
pathCriterion := models.StringCriterionInput{
|
||||
Value: imagePath,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
}
|
||||
|
||||
verifyImagePath(t, pathCriterion)
|
||||
|
||||
pathCriterion.Modifier = models.CriterionModifierNotEquals
|
||||
verifyImagePath(t, pathCriterion)
|
||||
}
|
||||
|
||||
func verifyImagePath(t *testing.T, pathCriterion models.StringCriterionInput) {
|
||||
sqb := models.NewImageQueryBuilder()
|
||||
imageFilter := models.ImageFilterType{
|
||||
Path: &pathCriterion,
|
||||
}
|
||||
|
||||
images, _ := sqb.Query(&imageFilter, nil)
|
||||
|
||||
for _, image := range images {
|
||||
verifyString(t, image.Path, pathCriterion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageQueryRating(t *testing.T) {
|
||||
const rating = 3
|
||||
ratingCriterion := models.IntCriterionInput{
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/stashapp/stash/pkg/database"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
const sceneTable = "scenes"
|
||||
@@ -242,12 +241,8 @@ func (qb *SceneQueryBuilder) Count() (int, error) {
|
||||
return runCountQuery(buildCountQuery("SELECT scenes.id FROM scenes"), nil)
|
||||
}
|
||||
|
||||
func (qb *SceneQueryBuilder) SizeCount() (string, error) {
|
||||
sum, err := runSumQuery("SELECT SUM(size) as sum FROM scenes", nil)
|
||||
if err != nil {
|
||||
return "0 B", err
|
||||
}
|
||||
return utils.HumanizeBytes(sum), err
|
||||
func (qb *SceneQueryBuilder) Size() (uint64, error) {
|
||||
return runSumQuery("SELECT SUM(size) as sum FROM scenes", nil)
|
||||
}
|
||||
|
||||
func (qb *SceneQueryBuilder) CountByStudioID(studioID int) (int, error) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/user"
|
||||
@@ -188,29 +187,6 @@ func IsZipFileUncompressed(path string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// humanize code taken from https://github.com/dustin/go-humanize and adjusted
|
||||
|
||||
func logn(n, b float64) float64 {
|
||||
return math.Log(n) / math.Log(b)
|
||||
}
|
||||
|
||||
// HumanizeBytes returns a human readable bytes string of a uint
|
||||
func HumanizeBytes(s uint64) string {
|
||||
sizes := []string{"B", "KB", "MB", "GB", "TB", "PB", "EB"}
|
||||
if s < 10 {
|
||||
return fmt.Sprintf("%d B", s)
|
||||
}
|
||||
e := math.Floor(logn(float64(s), 1024))
|
||||
suffix := sizes[int(e)]
|
||||
val := math.Floor(float64(s)/math.Pow(1024, e)*10+0.5) / 10
|
||||
f := "%.0f %s"
|
||||
if val < 10 {
|
||||
f = "%.1f %s"
|
||||
}
|
||||
|
||||
return fmt.Sprintf(f, val, suffix)
|
||||
}
|
||||
|
||||
// WriteFile writes file to path creating parent directories if needed
|
||||
func WriteFile(path string, file []byte) error {
|
||||
pathErr := EnsureDirAll(filepath.Dir(path))
|
||||
|
||||
@@ -21,6 +21,7 @@ import FsLightbox from "fslightbox-react";
|
||||
import { PerformerDetailsPanel } from "./PerformerDetailsPanel";
|
||||
import { PerformerOperationsPanel } from "./PerformerOperationsPanel";
|
||||
import { PerformerScenesPanel } from "./PerformerScenesPanel";
|
||||
import { PerformerImagesPanel } from "./PerformerImagesPanel";
|
||||
|
||||
interface IPerformerParams {
|
||||
id?: string;
|
||||
@@ -57,7 +58,10 @@ export const Performer: React.FC = () => {
|
||||
const [deletePerformer] = usePerformerDestroy();
|
||||
|
||||
const activeTabKey =
|
||||
tab === "scenes" || tab === "edit" || tab === "operations"
|
||||
tab === "scenes" ||
|
||||
tab === "images" ||
|
||||
tab === "edit" ||
|
||||
tab === "operations"
|
||||
? tab
|
||||
: "details";
|
||||
const setActiveTabKey = (newTab: string | null) => {
|
||||
@@ -152,6 +156,9 @@ export const Performer: React.FC = () => {
|
||||
<Tab eventKey="scenes" title="Scenes">
|
||||
<PerformerScenesPanel performer={performer} />
|
||||
</Tab>
|
||||
<Tab eventKey="images" title="Images">
|
||||
<PerformerImagesPanel performer={performer} />
|
||||
</Tab>
|
||||
<Tab eventKey="edit" title="Edit">
|
||||
<PerformerDetailsPanel
|
||||
performer={performer}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { ImageList } from "src/components/Images/ImageList";
|
||||
import { performerFilterHook } from "src/core/performers";
|
||||
|
||||
interface IPerformerImagesPanel {
|
||||
performer: Partial<GQL.PerformerDataFragment>;
|
||||
}
|
||||
|
||||
export const PerformerImagesPanel: React.FC<IPerformerImagesPanel> = ({
|
||||
performer,
|
||||
}) => {
|
||||
return <ImageList filterHook={performerFilterHook(performer)} />;
|
||||
};
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { PerformersCriterion } from "src/models/list-filter/criteria/performers";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { SceneList } from "src/components/Scenes/SceneList";
|
||||
import { performerFilterHook } from "src/core/performers";
|
||||
|
||||
interface IPerformerDetailsProps {
|
||||
performer: Partial<GQL.PerformerDataFragment>;
|
||||
@@ -11,37 +10,5 @@ interface IPerformerDetailsProps {
|
||||
export const PerformerScenesPanel: React.FC<IPerformerDetailsProps> = ({
|
||||
performer,
|
||||
}) => {
|
||||
function filterHook(filter: ListFilterModel) {
|
||||
const performerValue = { id: performer.id!, label: performer.name! };
|
||||
// if performers is already present, then we modify it, otherwise add
|
||||
let performerCriterion = filter.criteria.find((c) => {
|
||||
return c.type === "performers";
|
||||
}) as PerformersCriterion;
|
||||
|
||||
if (
|
||||
performerCriterion &&
|
||||
(performerCriterion.modifier === GQL.CriterionModifier.IncludesAll ||
|
||||
performerCriterion.modifier === GQL.CriterionModifier.Includes)
|
||||
) {
|
||||
// add the performer if not present
|
||||
if (
|
||||
!performerCriterion.value.find((p) => {
|
||||
return p.id === performer.id;
|
||||
})
|
||||
) {
|
||||
performerCriterion.value.push(performerValue);
|
||||
}
|
||||
|
||||
performerCriterion.modifier = GQL.CriterionModifier.IncludesAll;
|
||||
} else {
|
||||
// overwrite
|
||||
performerCriterion = new PerformersCriterion();
|
||||
performerCriterion.value = [performerValue];
|
||||
filter.criteria.push(performerCriterion);
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
return <SceneList filterHook={filterHook} />;
|
||||
return <SceneList filterHook={performerFilterHook(performer)} />;
|
||||
};
|
||||
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
useAllPerformersForFilter,
|
||||
useMarkerStrings,
|
||||
useScrapePerformerList,
|
||||
useValidGalleriesForScene,
|
||||
useTagCreate,
|
||||
useStudioCreate,
|
||||
usePerformerCreate,
|
||||
useFindGalleries,
|
||||
} from "src/core/StashService";
|
||||
import { useToast } from "src/hooks";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { FilterMode } from "src/models/list-filter/types";
|
||||
|
||||
type ValidTypes =
|
||||
| GQL.SlimPerformerDataFragment
|
||||
@@ -90,12 +92,24 @@ const getSelectedValues = (selectedItems: ValueType<Option>) =>
|
||||
: [];
|
||||
|
||||
export const SceneGallerySelect: React.FC<ISceneGallerySelect> = (props) => {
|
||||
const { data, loading } = useValidGalleriesForScene(props.sceneId);
|
||||
const galleries = data?.validGalleriesForScene ?? [];
|
||||
const items = (galleries.length > 0
|
||||
? [{ path: "None", id: "0" }, ...galleries]
|
||||
: []
|
||||
).map((g) => ({ label: g.title ?? "", value: g.id }));
|
||||
const [query, setQuery] = React.useState<string>("");
|
||||
const { data, loading } = useFindGalleries(getFilter());
|
||||
|
||||
const galleries = data?.findGalleries.galleries ?? [];
|
||||
const items = galleries.map((g) => ({
|
||||
label: g.title ?? g.path ?? "",
|
||||
value: g.id,
|
||||
}));
|
||||
|
||||
function getFilter() {
|
||||
const ret = new ListFilterModel(FilterMode.Galleries);
|
||||
ret.searchTerm = query;
|
||||
return ret;
|
||||
}
|
||||
|
||||
const onInputChange = debounce((input: string) => {
|
||||
setQuery(input);
|
||||
}, 500);
|
||||
|
||||
const onChange = (selectedItems: ValueType<Option>) => {
|
||||
const selectedItem = getSelectedValues(selectedItems)[0];
|
||||
@@ -110,8 +124,8 @@ export const SceneGallerySelect: React.FC<ISceneGallerySelect> = (props) => {
|
||||
|
||||
return (
|
||||
<SelectComponent
|
||||
className="input-control"
|
||||
onChange={onChange}
|
||||
onInputChange={onInputChange}
|
||||
isLoading={loading}
|
||||
items={items}
|
||||
selectedOptions={selectedOptions}
|
||||
|
||||
@@ -3,26 +3,27 @@ import { useStats } from "src/core/StashService";
|
||||
import { FormattedMessage, FormattedNumber } from "react-intl";
|
||||
import { LoadingIndicator } from "src/components/Shared";
|
||||
import Changelog from "src/components/Changelog/Changelog";
|
||||
import { TextUtils } from "src/utils";
|
||||
|
||||
export const Stats: React.FC = () => {
|
||||
const { data, error, loading } = useStats();
|
||||
|
||||
if (error) return <span>{error.message}</span>;
|
||||
if (loading || !data) return <LoadingIndicator />;
|
||||
|
||||
if (error) return <span>error.message</span>;
|
||||
|
||||
const size = data.stats.scene_size_count.split(" ");
|
||||
const scenesSize = TextUtils.fileSize(data.stats.scenes_size);
|
||||
const imagesSize = TextUtils.fileSize(data.stats.images_size);
|
||||
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<div className="col col-sm-8 m-sm-auto row stats">
|
||||
<div className="stats-element">
|
||||
<p className="title">
|
||||
<FormattedNumber value={parseFloat(size[0])} />
|
||||
{` ${size[1]}`}
|
||||
<FormattedNumber value={Math.floor(scenesSize.size)} />
|
||||
{` ${TextUtils.formatFileSizeUnit(scenesSize.unit)}`}
|
||||
</p>
|
||||
<p className="heading">
|
||||
<FormattedMessage id="library-size" defaultMessage="Library size" />
|
||||
<FormattedMessage id="scenes-size" defaultMessage="Scenes size" />
|
||||
</p>
|
||||
</div>
|
||||
<div className="stats-element">
|
||||
@@ -33,6 +34,25 @@ export const Stats: React.FC = () => {
|
||||
<FormattedMessage id="scenes" defaultMessage="Scenes" />
|
||||
</p>
|
||||
</div>
|
||||
<div className="stats-element">
|
||||
<p className="title">
|
||||
<FormattedNumber value={Math.floor(imagesSize.size)} />
|
||||
{` ${TextUtils.formatFileSizeUnit(imagesSize.unit)}`}
|
||||
</p>
|
||||
<p className="heading">
|
||||
<FormattedMessage id="images-size" defaultMessage="Images size" />
|
||||
</p>
|
||||
</div>
|
||||
<div className="stats-element">
|
||||
<p className="title">
|
||||
<FormattedNumber value={data.stats.image_count} />
|
||||
</p>
|
||||
<p className="heading">
|
||||
<FormattedMessage id="images" defaultMessage="Images" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col col-sm-8 m-sm-auto row stats">
|
||||
<div className="stats-element">
|
||||
<p className="title">
|
||||
<FormattedNumber value={data.stats.movie_count} />
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "src/components/Shared";
|
||||
import { useToast } from "src/hooks";
|
||||
import { StudioScenesPanel } from "./StudioScenesPanel";
|
||||
import { StudioImagesPanel } from "./StudioImagesPanel";
|
||||
import { StudioChildrenPanel } from "./StudioChildrenPanel";
|
||||
|
||||
interface IStudioParams {
|
||||
@@ -195,7 +196,8 @@ export const Studio: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const activeTabKey = tab === "childstudios" ? tab : "scenes";
|
||||
const activeTabKey =
|
||||
tab === "childstudios" || tab === "images" ? tab : "scenes";
|
||||
const setActiveTabKey = (newTab: string | null) => {
|
||||
if (tab !== newTab) {
|
||||
const tabParam = newTab === "scenes" ? "" : `/${newTab}`;
|
||||
@@ -290,6 +292,9 @@ export const Studio: React.FC = () => {
|
||||
<Tab eventKey="scenes" title="Scenes">
|
||||
<StudioScenesPanel studio={studio} />
|
||||
</Tab>
|
||||
<Tab eventKey="images" title="Images">
|
||||
<StudioImagesPanel studio={studio} />
|
||||
</Tab>
|
||||
<Tab eventKey="childstudios" title="Child Studios">
|
||||
<StudioChildrenPanel studio={studio} />
|
||||
</Tab>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { studioFilterHook } from "src/core/studios";
|
||||
import { ImageList } from "src/components/Images/ImageList";
|
||||
|
||||
interface IStudioImagesPanel {
|
||||
studio: Partial<GQL.StudioDataFragment>;
|
||||
}
|
||||
|
||||
export const StudioImagesPanel: React.FC<IStudioImagesPanel> = ({ studio }) => {
|
||||
return <ImageList filterHook={studioFilterHook(studio)} />;
|
||||
};
|
||||
@@ -1,45 +1,12 @@
|
||||
import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { SceneList } from "src/components/Scenes/SceneList";
|
||||
import { studioFilterHook } from "src/core/studios";
|
||||
|
||||
interface IStudioScenesPanel {
|
||||
studio: Partial<GQL.StudioDataFragment>;
|
||||
}
|
||||
|
||||
export const StudioScenesPanel: React.FC<IStudioScenesPanel> = ({ studio }) => {
|
||||
function filterHook(filter: ListFilterModel) {
|
||||
const studioValue = { id: studio.id!, label: studio.name! };
|
||||
// if studio is already present, then we modify it, otherwise add
|
||||
let studioCriterion = filter.criteria.find((c) => {
|
||||
return c.type === "studios";
|
||||
}) as StudiosCriterion;
|
||||
|
||||
if (
|
||||
studioCriterion &&
|
||||
(studioCriterion.modifier === GQL.CriterionModifier.IncludesAll ||
|
||||
studioCriterion.modifier === GQL.CriterionModifier.Includes)
|
||||
) {
|
||||
// add the studio if not present
|
||||
if (
|
||||
!studioCriterion.value.find((p) => {
|
||||
return p.id === studio.id;
|
||||
})
|
||||
) {
|
||||
studioCriterion.value.push(studioValue);
|
||||
}
|
||||
|
||||
studioCriterion.modifier = GQL.CriterionModifier.IncludesAll;
|
||||
} else {
|
||||
// overwrite
|
||||
studioCriterion = new StudiosCriterion();
|
||||
studioCriterion.value = [studioValue];
|
||||
filter.criteria.push(studioCriterion);
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
return <SceneList filterHook={filterHook} />;
|
||||
return <SceneList filterHook={studioFilterHook(studio)} />;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { useToast } from "src/hooks";
|
||||
import { TagScenesPanel } from "./TagScenesPanel";
|
||||
import { TagMarkersPanel } from "./TagMarkersPanel";
|
||||
import { TagImagesPanel } from "./TagImagesPanel";
|
||||
|
||||
interface ITabParams {
|
||||
id?: string;
|
||||
@@ -49,7 +50,7 @@ export const Tag: React.FC = () => {
|
||||
const [createTag] = useTagCreate(getTagInput() as GQL.TagUpdateInput);
|
||||
const [deleteTag] = useTagDestroy(getTagInput() as GQL.TagUpdateInput);
|
||||
|
||||
const activeTabKey = tab === "markers" ? tab : "scenes";
|
||||
const activeTabKey = tab === "markers" || tab === "images" ? tab : "scenes";
|
||||
const setActiveTabKey = (newTab: string | null) => {
|
||||
if (tab !== newTab) {
|
||||
const tabParam = newTab === "scenes" ? "" : `/${newTab}`;
|
||||
@@ -246,6 +247,9 @@ export const Tag: React.FC = () => {
|
||||
<Tab eventKey="scenes" title="Scenes">
|
||||
<TagScenesPanel tag={tag} />
|
||||
</Tab>
|
||||
<Tab eventKey="images" title="Images">
|
||||
<TagImagesPanel tag={tag} />
|
||||
</Tab>
|
||||
<Tab eventKey="markers" title="Markers">
|
||||
<TagMarkersPanel tag={tag} />
|
||||
</Tab>
|
||||
|
||||
12
ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx
Normal file
12
ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { tagFilterHook } from "src/core/tags";
|
||||
import { ImageList } from "src/components/Images/ImageList";
|
||||
|
||||
interface ITagImagesPanel {
|
||||
tag: GQL.TagDataFragment;
|
||||
}
|
||||
|
||||
export const TagImagesPanel: React.FC<ITagImagesPanel> = ({ tag }) => {
|
||||
return <ImageList filterHook={tagFilterHook(tag)} />;
|
||||
};
|
||||
@@ -1,45 +1,12 @@
|
||||
import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { SceneList } from "src/components/Scenes/SceneList";
|
||||
import { TagsCriterion } from "src/models/list-filter/criteria/tags";
|
||||
import { tagFilterHook } from "src/core/tags";
|
||||
|
||||
interface ITagScenesPanel {
|
||||
tag: GQL.TagDataFragment;
|
||||
}
|
||||
|
||||
export const TagScenesPanel: React.FC<ITagScenesPanel> = ({ tag }) => {
|
||||
function filterHook(filter: ListFilterModel) {
|
||||
const tagValue = { id: tag.id, label: tag.name };
|
||||
// if tag is already present, then we modify it, otherwise add
|
||||
let tagCriterion = filter.criteria.find((c) => {
|
||||
return c.type === "tags";
|
||||
}) as TagsCriterion;
|
||||
|
||||
if (
|
||||
tagCriterion &&
|
||||
(tagCriterion.modifier === GQL.CriterionModifier.IncludesAll ||
|
||||
tagCriterion.modifier === GQL.CriterionModifier.Includes)
|
||||
) {
|
||||
// add the tag if not present
|
||||
if (
|
||||
!tagCriterion.value.find((p) => {
|
||||
return p.id === tag.id;
|
||||
})
|
||||
) {
|
||||
tagCriterion.value.push(tagValue);
|
||||
}
|
||||
|
||||
tagCriterion.modifier = GQL.CriterionModifier.IncludesAll;
|
||||
} else {
|
||||
// overwrite
|
||||
tagCriterion = new TagsCriterion("tags");
|
||||
tagCriterion.value = [tagValue];
|
||||
filter.criteria.push(tagCriterion);
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
return <SceneList filterHook={filterHook} />;
|
||||
return <SceneList filterHook={tagFilterHook(tag)} />;
|
||||
};
|
||||
|
||||
39
ui/v2.5/src/core/performers.ts
Normal file
39
ui/v2.5/src/core/performers.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { PerformersCriterion } from "src/models/list-filter/criteria/performers";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
|
||||
export const performerFilterHook = (
|
||||
performer: Partial<GQL.PerformerDataFragment>
|
||||
) => {
|
||||
return (filter: ListFilterModel) => {
|
||||
const performerValue = { id: performer.id!, label: performer.name! };
|
||||
// if performers is already present, then we modify it, otherwise add
|
||||
let performerCriterion = filter.criteria.find((c) => {
|
||||
return c.type === "performers";
|
||||
}) as PerformersCriterion;
|
||||
|
||||
if (
|
||||
performerCriterion &&
|
||||
(performerCriterion.modifier === GQL.CriterionModifier.IncludesAll ||
|
||||
performerCriterion.modifier === GQL.CriterionModifier.Includes)
|
||||
) {
|
||||
// add the performer if not present
|
||||
if (
|
||||
!performerCriterion.value.find((p) => {
|
||||
return p.id === performer.id;
|
||||
})
|
||||
) {
|
||||
performerCriterion.value.push(performerValue);
|
||||
}
|
||||
|
||||
performerCriterion.modifier = GQL.CriterionModifier.IncludesAll;
|
||||
} else {
|
||||
// overwrite
|
||||
performerCriterion = new PerformersCriterion();
|
||||
performerCriterion.value = [performerValue];
|
||||
filter.criteria.push(performerCriterion);
|
||||
}
|
||||
|
||||
return filter;
|
||||
};
|
||||
};
|
||||
37
ui/v2.5/src/core/studios.ts
Normal file
37
ui/v2.5/src/core/studios.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
|
||||
export const studioFilterHook = (studio: Partial<GQL.StudioDataFragment>) => {
|
||||
return (filter: ListFilterModel) => {
|
||||
const studioValue = { id: studio.id!, label: studio.name! };
|
||||
// if studio is already present, then we modify it, otherwise add
|
||||
let studioCriterion = filter.criteria.find((c) => {
|
||||
return c.type === "studios";
|
||||
}) as StudiosCriterion;
|
||||
|
||||
if (
|
||||
studioCriterion &&
|
||||
(studioCriterion.modifier === GQL.CriterionModifier.IncludesAll ||
|
||||
studioCriterion.modifier === GQL.CriterionModifier.Includes)
|
||||
) {
|
||||
// add the studio if not present
|
||||
if (
|
||||
!studioCriterion.value.find((p) => {
|
||||
return p.id === studio.id;
|
||||
})
|
||||
) {
|
||||
studioCriterion.value.push(studioValue);
|
||||
}
|
||||
|
||||
studioCriterion.modifier = GQL.CriterionModifier.IncludesAll;
|
||||
} else {
|
||||
// overwrite
|
||||
studioCriterion = new StudiosCriterion();
|
||||
studioCriterion.value = [studioValue];
|
||||
filter.criteria.push(studioCriterion);
|
||||
}
|
||||
|
||||
return filter;
|
||||
};
|
||||
};
|
||||
37
ui/v2.5/src/core/tags.ts
Normal file
37
ui/v2.5/src/core/tags.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { TagsCriterion } from "src/models/list-filter/criteria/tags";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
|
||||
export const tagFilterHook = (tag: GQL.TagDataFragment) => {
|
||||
return (filter: ListFilterModel) => {
|
||||
const tagValue = { id: tag.id, label: tag.name };
|
||||
// if tag is already present, then we modify it, otherwise add
|
||||
let tagCriterion = filter.criteria.find((c) => {
|
||||
return c.type === "tags";
|
||||
}) as TagsCriterion;
|
||||
|
||||
if (
|
||||
tagCriterion &&
|
||||
(tagCriterion.modifier === GQL.CriterionModifier.IncludesAll ||
|
||||
tagCriterion.modifier === GQL.CriterionModifier.Includes)
|
||||
) {
|
||||
// add the tag if not present
|
||||
if (
|
||||
!tagCriterion.value.find((p) => {
|
||||
return p.id === tag.id;
|
||||
})
|
||||
) {
|
||||
tagCriterion.value.push(tagValue);
|
||||
}
|
||||
|
||||
tagCriterion.modifier = GQL.CriterionModifier.IncludesAll;
|
||||
} else {
|
||||
// overwrite
|
||||
tagCriterion = new TagsCriterion("tags");
|
||||
tagCriterion.value = [tagValue];
|
||||
filter.criteria.push(tagCriterion);
|
||||
}
|
||||
|
||||
return filter;
|
||||
};
|
||||
};
|
||||
@@ -10,6 +10,7 @@ export type CriterionType =
|
||||
| "rating"
|
||||
| "o_counter"
|
||||
| "resolution"
|
||||
| "average_resolution"
|
||||
| "duration"
|
||||
| "favorite"
|
||||
| "hasMarkers"
|
||||
@@ -59,6 +60,8 @@ export abstract class Criterion {
|
||||
return "O-Counter";
|
||||
case "resolution":
|
||||
return "Resolution";
|
||||
case "average_resolution":
|
||||
return "Average Resolution";
|
||||
case "duration":
|
||||
return "Duration";
|
||||
case "favorite":
|
||||
|
||||
@@ -73,7 +73,16 @@ export class PerformerIsMissingCriterionOption implements ICriterionOption {
|
||||
|
||||
export class GalleryIsMissingCriterion extends IsMissingCriterion {
|
||||
public type: CriterionType = "galleryIsMissing";
|
||||
public options: string[] = ["scene"];
|
||||
public options: string[] = [
|
||||
"title",
|
||||
"details",
|
||||
"url",
|
||||
"date",
|
||||
"studio",
|
||||
"performers",
|
||||
"tags",
|
||||
"scene",
|
||||
];
|
||||
}
|
||||
|
||||
export class GalleryIsMissingCriterionOption implements ICriterionOption {
|
||||
|
||||
@@ -14,3 +14,13 @@ export class ResolutionCriterionOption implements ICriterionOption {
|
||||
public label: string = Criterion.getLabel("resolution");
|
||||
public value: CriterionType = "resolution";
|
||||
}
|
||||
|
||||
export class AverageResolutionCriterion extends ResolutionCriterion {
|
||||
public type: CriterionType = "average_resolution";
|
||||
public parameterName: string = "average_resolution";
|
||||
}
|
||||
|
||||
export class AverageResolutionCriterionOption extends ResolutionCriterionOption {
|
||||
public label: string = Criterion.getLabel("average_resolution");
|
||||
public value: CriterionType = "average_resolution";
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { NoneCriterion } from "./none";
|
||||
import { PerformersCriterion } from "./performers";
|
||||
import { RatingCriterion } from "./rating";
|
||||
import { ResolutionCriterion } from "./resolution";
|
||||
import { AverageResolutionCriterion, ResolutionCriterion } from "./resolution";
|
||||
import { StudiosCriterion, ParentStudiosCriterion } from "./studios";
|
||||
import { TagsCriterion } from "./tags";
|
||||
import { GenderCriterion } from "./gender";
|
||||
@@ -43,6 +43,8 @@ export function makeCriteria(type: CriterionType = "none") {
|
||||
return new NumberCriterion(type, type);
|
||||
case "resolution":
|
||||
return new ResolutionCriterion();
|
||||
case "average_resolution":
|
||||
return new AverageResolutionCriterion();
|
||||
case "duration":
|
||||
return new DurationCriterion(type, type);
|
||||
case "favorite":
|
||||
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
} from "./criteria/performers";
|
||||
import { RatingCriterion, RatingCriterionOption } from "./criteria/rating";
|
||||
import {
|
||||
AverageResolutionCriterion,
|
||||
AverageResolutionCriterionOption,
|
||||
ResolutionCriterion,
|
||||
ResolutionCriterionOption,
|
||||
} from "./criteria/resolution";
|
||||
@@ -154,6 +156,7 @@ export class ListFilterModel {
|
||||
this.displayModeOptions = [DisplayMode.Grid, DisplayMode.Wall];
|
||||
this.criterionOptions = [
|
||||
new NoneCriterionOption(),
|
||||
ListFilterModel.createCriterionOption("path"),
|
||||
new RatingCriterionOption(),
|
||||
ListFilterModel.createCriterionOption("o_counter"),
|
||||
new ResolutionCriterionOption(),
|
||||
@@ -227,7 +230,12 @@ export class ListFilterModel {
|
||||
this.criterionOptions = [
|
||||
new NoneCriterionOption(),
|
||||
ListFilterModel.createCriterionOption("path"),
|
||||
new RatingCriterionOption(),
|
||||
new AverageResolutionCriterionOption(),
|
||||
new GalleryIsMissingCriterionOption(),
|
||||
new TagsCriterionOption(),
|
||||
new PerformersCriterionOption(),
|
||||
new StudiosCriterionOption(),
|
||||
];
|
||||
break;
|
||||
case FilterMode.SceneMarkers:
|
||||
@@ -645,6 +653,14 @@ export class ListFilterModel {
|
||||
const result: ImageFilterType = {};
|
||||
this.criteria.forEach((criterion) => {
|
||||
switch (criterion.type) {
|
||||
case "path": {
|
||||
const pathCrit = criterion as MandatoryStringCriterion;
|
||||
result.path = {
|
||||
value: pathCrit.value,
|
||||
modifier: pathCrit.modifier,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "rating": {
|
||||
const ratingCrit = criterion as RatingCriterion;
|
||||
result.rating = {
|
||||
@@ -695,7 +711,7 @@ export class ListFilterModel {
|
||||
}
|
||||
case "performers": {
|
||||
const perfCrit = criterion as PerformersCriterion;
|
||||
result.galleries = {
|
||||
result.performers = {
|
||||
value: perfCrit.value.map((perf) => perf.id),
|
||||
modifier: perfCrit.modifier,
|
||||
};
|
||||
@@ -776,9 +792,62 @@ export class ListFilterModel {
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "rating": {
|
||||
const ratingCrit = criterion as RatingCriterion;
|
||||
result.rating = {
|
||||
value: ratingCrit.value,
|
||||
modifier: ratingCrit.modifier,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "average_resolution": {
|
||||
switch ((criterion as AverageResolutionCriterion).value) {
|
||||
case "240p":
|
||||
result.average_resolution = ResolutionEnum.Low;
|
||||
break;
|
||||
case "480p":
|
||||
result.average_resolution = ResolutionEnum.Standard;
|
||||
break;
|
||||
case "720p":
|
||||
result.average_resolution = ResolutionEnum.StandardHd;
|
||||
break;
|
||||
case "1080p":
|
||||
result.average_resolution = ResolutionEnum.FullHd;
|
||||
break;
|
||||
case "4k":
|
||||
result.average_resolution = ResolutionEnum.FourK;
|
||||
break;
|
||||
// no default
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "galleryIsMissing":
|
||||
result.is_missing = (criterion as IsMissingCriterion).value;
|
||||
break;
|
||||
case "tags": {
|
||||
const tagsCrit = criterion as TagsCriterion;
|
||||
result.tags = {
|
||||
value: tagsCrit.value.map((tag) => tag.id),
|
||||
modifier: tagsCrit.modifier,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "performers": {
|
||||
const perfCrit = criterion as PerformersCriterion;
|
||||
result.performers = {
|
||||
value: perfCrit.value.map((perf) => perf.id),
|
||||
modifier: perfCrit.modifier,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "studios": {
|
||||
const studCrit = criterion as StudiosCriterion;
|
||||
result.studios = {
|
||||
value: studCrit.value.map((studio) => studio.id),
|
||||
modifier: studCrit.modifier,
|
||||
};
|
||||
break;
|
||||
}
|
||||
// no default
|
||||
}
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ const Units: Unit[] = [
|
||||
"terabyte",
|
||||
"petabyte",
|
||||
];
|
||||
const shortUnits = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
|
||||
const truncate = (
|
||||
value?: string,
|
||||
@@ -32,7 +33,7 @@ const fileSize = (bytes: number = 0) => {
|
||||
|
||||
let unit = 0;
|
||||
let count = bytes;
|
||||
while (count >= 1024) {
|
||||
while (count >= 1024 && unit + 1 < Units.length) {
|
||||
count /= 1024;
|
||||
unit++;
|
||||
}
|
||||
@@ -43,6 +44,11 @@ const fileSize = (bytes: number = 0) => {
|
||||
};
|
||||
};
|
||||
|
||||
const formatFileSizeUnit = (u: Unit) => {
|
||||
const i = Units.indexOf(u);
|
||||
return shortUnits[i];
|
||||
};
|
||||
|
||||
const secondsToTimestamp = (seconds: number) => {
|
||||
let ret = new Date(seconds * 1000).toISOString().substr(11, 8);
|
||||
|
||||
@@ -141,6 +147,7 @@ const formatDate = (intl: IntlShape, date?: string) => {
|
||||
const TextUtils = {
|
||||
truncate,
|
||||
fileSize,
|
||||
formatFileSizeUnit,
|
||||
secondsToTimestamp,
|
||||
fileNameFromPath,
|
||||
age: getAge,
|
||||
|
||||
Reference in New Issue
Block a user