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:
WithoutPants
2020-10-20 10:11:15 +11:00
committed by GitHub
parent 80199f79f3
commit 8eda72ad89
31 changed files with 463 additions and 169 deletions

View File

@@ -47,7 +47,9 @@ query ValidGalleriesForScene($scene_id: ID!) {
query Stats { query Stats {
stats { stats {
scene_count, scene_count,
scene_size_count, scenes_size,
image_count,
images_size,
gallery_count, gallery_count,
performer_count, performer_count,
studio_count, studio_count,

View File

@@ -111,6 +111,8 @@ input GalleryFilterType {
is_zip: Boolean is_zip: Boolean
"""Filter by rating""" """Filter by rating"""
rating: IntCriterionInput rating: IntCriterionInput
"""Filter by average image resolution"""
average_resolution: ResolutionEnum
"""Filter to only include scenes with this studio""" """Filter to only include scenes with this studio"""
studios: MultiCriterionInput studios: MultiCriterionInput
"""Filter to only include scenes with these tags""" """Filter to only include scenes with these tags"""
@@ -133,6 +135,8 @@ input TagFilterType {
} }
input ImageFilterType { input ImageFilterType {
"""Filter by path"""
path: StringCriterionInput
"""Filter by rating""" """Filter by rating"""
rating: IntCriterionInput rating: IntCriterionInput
"""Filter by o-counter""" """Filter by o-counter"""

View File

@@ -1,6 +1,8 @@
type StatsResultType { type StatsResultType {
scene_count: Int! scene_count: Int!
scene_size_count: String! scenes_size: Int!
image_count: Int!
images_size: Int!
gallery_count: Int! gallery_count: Int!
performer_count: Int! performer_count: Int!
studio_count: Int! studio_count: Int!

View File

@@ -117,7 +117,10 @@ func (r *queryResolver) ValidGalleriesForScene(ctx context.Context, scene_id *st
func (r *queryResolver) Stats(ctx context.Context) (*models.StatsResultType, error) { func (r *queryResolver) Stats(ctx context.Context) (*models.StatsResultType, error) {
scenesQB := models.NewSceneQueryBuilder() scenesQB := models.NewSceneQueryBuilder()
scenesCount, _ := scenesQB.Count() scenesCount, _ := scenesQB.Count()
scenesSizeCount, _ := scenesQB.SizeCount() scenesSize, _ := scenesQB.Size()
imageQB := models.NewImageQueryBuilder()
imageCount, _ := imageQB.Count()
imageSize, _ := imageQB.Size()
galleryQB := models.NewGalleryQueryBuilder() galleryQB := models.NewGalleryQueryBuilder()
galleryCount, _ := galleryQB.Count() galleryCount, _ := galleryQB.Count()
performersQB := models.NewPerformerQueryBuilder() performersQB := models.NewPerformerQueryBuilder()
@@ -130,7 +133,9 @@ func (r *queryResolver) Stats(ctx context.Context) (*models.StatsResultType, err
tagsCount, _ := tagsQB.Count() tagsCount, _ := tagsQB.Count()
return &models.StatsResultType{ return &models.StatsResultType{
SceneCount: scenesCount, SceneCount: scenesCount,
SceneSizeCount: scenesSizeCount, ScenesSize: int(scenesSize),
ImageCount: imageCount,
ImagesSize: int(imageSize),
GalleryCount: galleryCount, GalleryCount: galleryCount,
PerformerCount: performersCount, PerformerCount: performersCount,
StudioCount: studiosCount, StudioCount: studiosCount,

View File

@@ -115,7 +115,8 @@ func (s *singleton) neededScan() (total *int, newFiles *int) {
}) })
if err == timeoutErr { if err == timeoutErr {
break // timeout should return nil counts
return nil, nil
} }
if err != nil { if err != nil {

View File

@@ -203,6 +203,8 @@ func (qb *GalleryQueryBuilder) Query(galleryFilter *GalleryFilterType, findFilte
} }
query.handleStringCriterionInput(galleryFilter.Path, "galleries.path") query.handleStringCriterionInput(galleryFilter.Path, "galleries.path")
query.handleIntCriterionInput(galleryFilter.Rating, "galleries.rating")
qb.handleAverageResolutionFilter(&query, galleryFilter.AverageResolution)
if isMissingFilter := galleryFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { if isMissingFilter := galleryFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" {
switch *isMissingFilter { switch *isMissingFilter {
@@ -265,6 +267,48 @@ func (qb *GalleryQueryBuilder) Query(galleryFilter *GalleryFilterType, findFilte
return galleries, countResult 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 { func (qb *GalleryQueryBuilder) getGallerySort(findFilter *FindFilterType) string {
var sort string var sort string
var direction string var direction string

View File

@@ -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) { func TestGalleryQueryIsMissingScene(t *testing.T) {
qb := models.NewGalleryQueryBuilder() qb := models.NewGalleryQueryBuilder()
isMissing := "scene" isMissing := "scene"

View File

@@ -7,7 +7,6 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/stashapp/stash/pkg/database" "github.com/stashapp/stash/pkg/database"
"github.com/stashapp/stash/pkg/utils"
) )
const imageTable = "images" const imageTable = "images"
@@ -234,12 +233,8 @@ func (qb *ImageQueryBuilder) Count() (int, error) {
return runCountQuery(buildCountQuery("SELECT images.id FROM images"), nil) return runCountQuery(buildCountQuery("SELECT images.id FROM images"), nil)
} }
func (qb *ImageQueryBuilder) SizeCount() (string, error) { func (qb *ImageQueryBuilder) Size() (uint64, error) {
sum, err := runSumQuery("SELECT SUM(size) as sum FROM images", nil) return runSumQuery("SELECT SUM(size) as sum FROM images", nil)
if err != nil {
return "0 B", err
}
return utils.HumanizeBytes(sum), err
} }
func (qb *ImageQueryBuilder) CountByStudioID(studioID int) (int, error) { func (qb *ImageQueryBuilder) CountByStudioID(studioID int) (int, error) {
@@ -283,6 +278,8 @@ func (qb *ImageQueryBuilder) Query(imageFilter *ImageFilterType, findFilter *Fin
query.addArg(thisArgs...) query.addArg(thisArgs...)
} }
query.handleStringCriterionInput(imageFilter.Path, "images.path")
if rating := imageFilter.Rating; rating != nil { if rating := imageFilter.Rating; rating != nil {
clause, count := getIntCriterionWhereClause("images.rating", *imageFilter.Rating) clause, count := getIntCriterionWhereClause("images.rating", *imageFilter.Rating)
query.addWhere(clause) query.addWhere(clause)

View File

@@ -106,6 +106,34 @@ func imageQueryQ(t *testing.T, sqb models.ImageQueryBuilder, q string, expectedI
assert.Len(t, images, totalImages) 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) { func TestImageQueryRating(t *testing.T) {
const rating = 3 const rating = 3
ratingCriterion := models.IntCriterionInput{ ratingCriterion := models.IntCriterionInput{

View File

@@ -7,7 +7,6 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/stashapp/stash/pkg/database" "github.com/stashapp/stash/pkg/database"
"github.com/stashapp/stash/pkg/utils"
) )
const sceneTable = "scenes" const sceneTable = "scenes"
@@ -242,12 +241,8 @@ func (qb *SceneQueryBuilder) Count() (int, error) {
return runCountQuery(buildCountQuery("SELECT scenes.id FROM scenes"), nil) return runCountQuery(buildCountQuery("SELECT scenes.id FROM scenes"), nil)
} }
func (qb *SceneQueryBuilder) SizeCount() (string, error) { func (qb *SceneQueryBuilder) Size() (uint64, error) {
sum, err := runSumQuery("SELECT SUM(size) as sum FROM scenes", nil) return runSumQuery("SELECT SUM(size) as sum FROM scenes", nil)
if err != nil {
return "0 B", err
}
return utils.HumanizeBytes(sum), err
} }
func (qb *SceneQueryBuilder) CountByStudioID(studioID int) (int, error) { func (qb *SceneQueryBuilder) CountByStudioID(studioID int) (int, error) {

View File

@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"io" "io"
"io/ioutil" "io/ioutil"
"math"
"net/http" "net/http"
"os" "os"
"os/user" "os/user"
@@ -188,29 +187,6 @@ func IsZipFileUncompressed(path string) (bool, error) {
return false, nil 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 // WriteFile writes file to path creating parent directories if needed
func WriteFile(path string, file []byte) error { func WriteFile(path string, file []byte) error {
pathErr := EnsureDirAll(filepath.Dir(path)) pathErr := EnsureDirAll(filepath.Dir(path))

View File

@@ -21,6 +21,7 @@ import FsLightbox from "fslightbox-react";
import { PerformerDetailsPanel } from "./PerformerDetailsPanel"; import { PerformerDetailsPanel } from "./PerformerDetailsPanel";
import { PerformerOperationsPanel } from "./PerformerOperationsPanel"; import { PerformerOperationsPanel } from "./PerformerOperationsPanel";
import { PerformerScenesPanel } from "./PerformerScenesPanel"; import { PerformerScenesPanel } from "./PerformerScenesPanel";
import { PerformerImagesPanel } from "./PerformerImagesPanel";
interface IPerformerParams { interface IPerformerParams {
id?: string; id?: string;
@@ -57,7 +58,10 @@ export const Performer: React.FC = () => {
const [deletePerformer] = usePerformerDestroy(); const [deletePerformer] = usePerformerDestroy();
const activeTabKey = const activeTabKey =
tab === "scenes" || tab === "edit" || tab === "operations" tab === "scenes" ||
tab === "images" ||
tab === "edit" ||
tab === "operations"
? tab ? tab
: "details"; : "details";
const setActiveTabKey = (newTab: string | null) => { const setActiveTabKey = (newTab: string | null) => {
@@ -152,6 +156,9 @@ export const Performer: React.FC = () => {
<Tab eventKey="scenes" title="Scenes"> <Tab eventKey="scenes" title="Scenes">
<PerformerScenesPanel performer={performer} /> <PerformerScenesPanel performer={performer} />
</Tab> </Tab>
<Tab eventKey="images" title="Images">
<PerformerImagesPanel performer={performer} />
</Tab>
<Tab eventKey="edit" title="Edit"> <Tab eventKey="edit" title="Edit">
<PerformerDetailsPanel <PerformerDetailsPanel
performer={performer} performer={performer}

View File

@@ -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)} />;
};

View File

@@ -1,8 +1,7 @@
import React from "react"; import React from "react";
import * as GQL from "src/core/generated-graphql"; 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 { SceneList } from "src/components/Scenes/SceneList";
import { performerFilterHook } from "src/core/performers";
interface IPerformerDetailsProps { interface IPerformerDetailsProps {
performer: Partial<GQL.PerformerDataFragment>; performer: Partial<GQL.PerformerDataFragment>;
@@ -11,37 +10,5 @@ interface IPerformerDetailsProps {
export const PerformerScenesPanel: React.FC<IPerformerDetailsProps> = ({ export const PerformerScenesPanel: React.FC<IPerformerDetailsProps> = ({
performer, performer,
}) => { }) => {
function filterHook(filter: ListFilterModel) { return <SceneList filterHook={performerFilterHook(performer)} />;
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} />;
}; };

View File

@@ -11,12 +11,14 @@ import {
useAllPerformersForFilter, useAllPerformersForFilter,
useMarkerStrings, useMarkerStrings,
useScrapePerformerList, useScrapePerformerList,
useValidGalleriesForScene,
useTagCreate, useTagCreate,
useStudioCreate, useStudioCreate,
usePerformerCreate, usePerformerCreate,
useFindGalleries,
} from "src/core/StashService"; } from "src/core/StashService";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { ListFilterModel } from "src/models/list-filter/filter";
import { FilterMode } from "src/models/list-filter/types";
type ValidTypes = type ValidTypes =
| GQL.SlimPerformerDataFragment | GQL.SlimPerformerDataFragment
@@ -90,12 +92,24 @@ const getSelectedValues = (selectedItems: ValueType<Option>) =>
: []; : [];
export const SceneGallerySelect: React.FC<ISceneGallerySelect> = (props) => { export const SceneGallerySelect: React.FC<ISceneGallerySelect> = (props) => {
const { data, loading } = useValidGalleriesForScene(props.sceneId); const [query, setQuery] = React.useState<string>("");
const galleries = data?.validGalleriesForScene ?? []; const { data, loading } = useFindGalleries(getFilter());
const items = (galleries.length > 0
? [{ path: "None", id: "0" }, ...galleries] const galleries = data?.findGalleries.galleries ?? [];
: [] const items = galleries.map((g) => ({
).map((g) => ({ label: g.title ?? "", value: g.id })); 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 onChange = (selectedItems: ValueType<Option>) => {
const selectedItem = getSelectedValues(selectedItems)[0]; const selectedItem = getSelectedValues(selectedItems)[0];
@@ -110,8 +124,8 @@ export const SceneGallerySelect: React.FC<ISceneGallerySelect> = (props) => {
return ( return (
<SelectComponent <SelectComponent
className="input-control"
onChange={onChange} onChange={onChange}
onInputChange={onInputChange}
isLoading={loading} isLoading={loading}
items={items} items={items}
selectedOptions={selectedOptions} selectedOptions={selectedOptions}

View File

@@ -3,26 +3,27 @@ import { useStats } from "src/core/StashService";
import { FormattedMessage, FormattedNumber } from "react-intl"; import { FormattedMessage, FormattedNumber } from "react-intl";
import { LoadingIndicator } from "src/components/Shared"; import { LoadingIndicator } from "src/components/Shared";
import Changelog from "src/components/Changelog/Changelog"; import Changelog from "src/components/Changelog/Changelog";
import { TextUtils } from "src/utils";
export const Stats: React.FC = () => { export const Stats: React.FC = () => {
const { data, error, loading } = useStats(); const { data, error, loading } = useStats();
if (error) return <span>{error.message}</span>;
if (loading || !data) return <LoadingIndicator />; if (loading || !data) return <LoadingIndicator />;
if (error) return <span>error.message</span>; const scenesSize = TextUtils.fileSize(data.stats.scenes_size);
const imagesSize = TextUtils.fileSize(data.stats.images_size);
const size = data.stats.scene_size_count.split(" ");
return ( return (
<div className="mt-5"> <div className="mt-5">
<div className="col col-sm-8 m-sm-auto row stats"> <div className="col col-sm-8 m-sm-auto row stats">
<div className="stats-element"> <div className="stats-element">
<p className="title"> <p className="title">
<FormattedNumber value={parseFloat(size[0])} /> <FormattedNumber value={Math.floor(scenesSize.size)} />
{` ${size[1]}`} {` ${TextUtils.formatFileSizeUnit(scenesSize.unit)}`}
</p> </p>
<p className="heading"> <p className="heading">
<FormattedMessage id="library-size" defaultMessage="Library size" /> <FormattedMessage id="scenes-size" defaultMessage="Scenes size" />
</p> </p>
</div> </div>
<div className="stats-element"> <div className="stats-element">
@@ -33,6 +34,25 @@ export const Stats: React.FC = () => {
<FormattedMessage id="scenes" defaultMessage="Scenes" /> <FormattedMessage id="scenes" defaultMessage="Scenes" />
</p> </p>
</div> </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"> <div className="stats-element">
<p className="title"> <p className="title">
<FormattedNumber value={data.stats.movie_count} /> <FormattedNumber value={data.stats.movie_count} />

View File

@@ -20,6 +20,7 @@ import {
} from "src/components/Shared"; } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { StudioScenesPanel } from "./StudioScenesPanel"; import { StudioScenesPanel } from "./StudioScenesPanel";
import { StudioImagesPanel } from "./StudioImagesPanel";
import { StudioChildrenPanel } from "./StudioChildrenPanel"; import { StudioChildrenPanel } from "./StudioChildrenPanel";
interface IStudioParams { 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) => { const setActiveTabKey = (newTab: string | null) => {
if (tab !== newTab) { if (tab !== newTab) {
const tabParam = newTab === "scenes" ? "" : `/${newTab}`; const tabParam = newTab === "scenes" ? "" : `/${newTab}`;
@@ -290,6 +292,9 @@ export const Studio: React.FC = () => {
<Tab eventKey="scenes" title="Scenes"> <Tab eventKey="scenes" title="Scenes">
<StudioScenesPanel studio={studio} /> <StudioScenesPanel studio={studio} />
</Tab> </Tab>
<Tab eventKey="images" title="Images">
<StudioImagesPanel studio={studio} />
</Tab>
<Tab eventKey="childstudios" title="Child Studios"> <Tab eventKey="childstudios" title="Child Studios">
<StudioChildrenPanel studio={studio} /> <StudioChildrenPanel studio={studio} />
</Tab> </Tab>

View File

@@ -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)} />;
};

View File

@@ -1,45 +1,12 @@
import React from "react"; import React from "react";
import * as GQL from "src/core/generated-graphql"; 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 { SceneList } from "src/components/Scenes/SceneList";
import { studioFilterHook } from "src/core/studios";
interface IStudioScenesPanel { interface IStudioScenesPanel {
studio: Partial<GQL.StudioDataFragment>; studio: Partial<GQL.StudioDataFragment>;
} }
export const StudioScenesPanel: React.FC<IStudioScenesPanel> = ({ studio }) => { export const StudioScenesPanel: React.FC<IStudioScenesPanel> = ({ studio }) => {
function filterHook(filter: ListFilterModel) { return <SceneList filterHook={studioFilterHook(studio)} />;
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} />;
}; };

View File

@@ -20,6 +20,7 @@ import {
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { TagScenesPanel } from "./TagScenesPanel"; import { TagScenesPanel } from "./TagScenesPanel";
import { TagMarkersPanel } from "./TagMarkersPanel"; import { TagMarkersPanel } from "./TagMarkersPanel";
import { TagImagesPanel } from "./TagImagesPanel";
interface ITabParams { interface ITabParams {
id?: string; id?: string;
@@ -49,7 +50,7 @@ export const Tag: React.FC = () => {
const [createTag] = useTagCreate(getTagInput() as GQL.TagUpdateInput); const [createTag] = useTagCreate(getTagInput() as GQL.TagUpdateInput);
const [deleteTag] = useTagDestroy(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) => { const setActiveTabKey = (newTab: string | null) => {
if (tab !== newTab) { if (tab !== newTab) {
const tabParam = newTab === "scenes" ? "" : `/${newTab}`; const tabParam = newTab === "scenes" ? "" : `/${newTab}`;
@@ -246,6 +247,9 @@ export const Tag: React.FC = () => {
<Tab eventKey="scenes" title="Scenes"> <Tab eventKey="scenes" title="Scenes">
<TagScenesPanel tag={tag} /> <TagScenesPanel tag={tag} />
</Tab> </Tab>
<Tab eventKey="images" title="Images">
<TagImagesPanel tag={tag} />
</Tab>
<Tab eventKey="markers" title="Markers"> <Tab eventKey="markers" title="Markers">
<TagMarkersPanel tag={tag} /> <TagMarkersPanel tag={tag} />
</Tab> </Tab>

View 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)} />;
};

View File

@@ -1,45 +1,12 @@
import React from "react"; import React from "react";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { ListFilterModel } from "src/models/list-filter/filter";
import { SceneList } from "src/components/Scenes/SceneList"; import { SceneList } from "src/components/Scenes/SceneList";
import { TagsCriterion } from "src/models/list-filter/criteria/tags"; import { tagFilterHook } from "src/core/tags";
interface ITagScenesPanel { interface ITagScenesPanel {
tag: GQL.TagDataFragment; tag: GQL.TagDataFragment;
} }
export const TagScenesPanel: React.FC<ITagScenesPanel> = ({ tag }) => { export const TagScenesPanel: React.FC<ITagScenesPanel> = ({ tag }) => {
function filterHook(filter: ListFilterModel) { return <SceneList filterHook={tagFilterHook(tag)} />;
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} />;
}; };

View 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;
};
};

View 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
View 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;
};
};

View File

@@ -10,6 +10,7 @@ export type CriterionType =
| "rating" | "rating"
| "o_counter" | "o_counter"
| "resolution" | "resolution"
| "average_resolution"
| "duration" | "duration"
| "favorite" | "favorite"
| "hasMarkers" | "hasMarkers"
@@ -59,6 +60,8 @@ export abstract class Criterion {
return "O-Counter"; return "O-Counter";
case "resolution": case "resolution":
return "Resolution"; return "Resolution";
case "average_resolution":
return "Average Resolution";
case "duration": case "duration":
return "Duration"; return "Duration";
case "favorite": case "favorite":

View File

@@ -73,7 +73,16 @@ export class PerformerIsMissingCriterionOption implements ICriterionOption {
export class GalleryIsMissingCriterion extends IsMissingCriterion { export class GalleryIsMissingCriterion extends IsMissingCriterion {
public type: CriterionType = "galleryIsMissing"; public type: CriterionType = "galleryIsMissing";
public options: string[] = ["scene"]; public options: string[] = [
"title",
"details",
"url",
"date",
"studio",
"performers",
"tags",
"scene",
];
} }
export class GalleryIsMissingCriterionOption implements ICriterionOption { export class GalleryIsMissingCriterionOption implements ICriterionOption {

View File

@@ -14,3 +14,13 @@ export class ResolutionCriterionOption implements ICriterionOption {
public label: string = Criterion.getLabel("resolution"); public label: string = Criterion.getLabel("resolution");
public value: CriterionType = "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";
}

View File

@@ -22,7 +22,7 @@ import {
import { NoneCriterion } from "./none"; import { NoneCriterion } from "./none";
import { PerformersCriterion } from "./performers"; import { PerformersCriterion } from "./performers";
import { RatingCriterion } from "./rating"; import { RatingCriterion } from "./rating";
import { ResolutionCriterion } from "./resolution"; import { AverageResolutionCriterion, ResolutionCriterion } from "./resolution";
import { StudiosCriterion, ParentStudiosCriterion } from "./studios"; import { StudiosCriterion, ParentStudiosCriterion } from "./studios";
import { TagsCriterion } from "./tags"; import { TagsCriterion } from "./tags";
import { GenderCriterion } from "./gender"; import { GenderCriterion } from "./gender";
@@ -43,6 +43,8 @@ export function makeCriteria(type: CriterionType = "none") {
return new NumberCriterion(type, type); return new NumberCriterion(type, type);
case "resolution": case "resolution":
return new ResolutionCriterion(); return new ResolutionCriterion();
case "average_resolution":
return new AverageResolutionCriterion();
case "duration": case "duration":
return new DurationCriterion(type, type); return new DurationCriterion(type, type);
case "favorite": case "favorite":

View File

@@ -48,6 +48,8 @@ import {
} from "./criteria/performers"; } from "./criteria/performers";
import { RatingCriterion, RatingCriterionOption } from "./criteria/rating"; import { RatingCriterion, RatingCriterionOption } from "./criteria/rating";
import { import {
AverageResolutionCriterion,
AverageResolutionCriterionOption,
ResolutionCriterion, ResolutionCriterion,
ResolutionCriterionOption, ResolutionCriterionOption,
} from "./criteria/resolution"; } from "./criteria/resolution";
@@ -154,6 +156,7 @@ export class ListFilterModel {
this.displayModeOptions = [DisplayMode.Grid, DisplayMode.Wall]; this.displayModeOptions = [DisplayMode.Grid, DisplayMode.Wall];
this.criterionOptions = [ this.criterionOptions = [
new NoneCriterionOption(), new NoneCriterionOption(),
ListFilterModel.createCriterionOption("path"),
new RatingCriterionOption(), new RatingCriterionOption(),
ListFilterModel.createCriterionOption("o_counter"), ListFilterModel.createCriterionOption("o_counter"),
new ResolutionCriterionOption(), new ResolutionCriterionOption(),
@@ -227,7 +230,12 @@ export class ListFilterModel {
this.criterionOptions = [ this.criterionOptions = [
new NoneCriterionOption(), new NoneCriterionOption(),
ListFilterModel.createCriterionOption("path"), ListFilterModel.createCriterionOption("path"),
new RatingCriterionOption(),
new AverageResolutionCriterionOption(),
new GalleryIsMissingCriterionOption(), new GalleryIsMissingCriterionOption(),
new TagsCriterionOption(),
new PerformersCriterionOption(),
new StudiosCriterionOption(),
]; ];
break; break;
case FilterMode.SceneMarkers: case FilterMode.SceneMarkers:
@@ -645,6 +653,14 @@ export class ListFilterModel {
const result: ImageFilterType = {}; const result: ImageFilterType = {};
this.criteria.forEach((criterion) => { this.criteria.forEach((criterion) => {
switch (criterion.type) { switch (criterion.type) {
case "path": {
const pathCrit = criterion as MandatoryStringCriterion;
result.path = {
value: pathCrit.value,
modifier: pathCrit.modifier,
};
break;
}
case "rating": { case "rating": {
const ratingCrit = criterion as RatingCriterion; const ratingCrit = criterion as RatingCriterion;
result.rating = { result.rating = {
@@ -695,7 +711,7 @@ export class ListFilterModel {
} }
case "performers": { case "performers": {
const perfCrit = criterion as PerformersCriterion; const perfCrit = criterion as PerformersCriterion;
result.galleries = { result.performers = {
value: perfCrit.value.map((perf) => perf.id), value: perfCrit.value.map((perf) => perf.id),
modifier: perfCrit.modifier, modifier: perfCrit.modifier,
}; };
@@ -776,9 +792,62 @@ export class ListFilterModel {
}; };
break; 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": case "galleryIsMissing":
result.is_missing = (criterion as IsMissingCriterion).value; result.is_missing = (criterion as IsMissingCriterion).value;
break; 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 // no default
} }
}); });

View File

@@ -16,6 +16,7 @@ const Units: Unit[] = [
"terabyte", "terabyte",
"petabyte", "petabyte",
]; ];
const shortUnits = ["B", "KB", "MB", "GB", "TB", "PB"];
const truncate = ( const truncate = (
value?: string, value?: string,
@@ -32,7 +33,7 @@ const fileSize = (bytes: number = 0) => {
let unit = 0; let unit = 0;
let count = bytes; let count = bytes;
while (count >= 1024) { while (count >= 1024 && unit + 1 < Units.length) {
count /= 1024; count /= 1024;
unit++; 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) => { const secondsToTimestamp = (seconds: number) => {
let ret = new Date(seconds * 1000).toISOString().substr(11, 8); let ret = new Date(seconds * 1000).toISOString().substr(11, 8);
@@ -141,6 +147,7 @@ const formatDate = (intl: IntlShape, date?: string) => {
const TextUtils = { const TextUtils = {
truncate, truncate,
fileSize, fileSize,
formatFileSizeUnit,
secondsToTimestamp, secondsToTimestamp,
fileNameFromPath, fileNameFromPath,
age: getAge, age: getAge,