From 0730983171dbd79a1a6dc32e9c70c6238b20a023 Mon Sep 17 00:00:00 2001 From: KennyG Date: Sun, 26 Apr 2026 10:39:25 -0400 Subject: [PATCH] Add cover image source to Scene model and GraphQL schema - Introduced `cover_image_source` field in the Scene type, SceneCreateInput, and SceneUpdateInput to track the origin of cover images. - Updated the GraphQL schema and corresponding resolver logic to handle the new field. - Implemented default cover image source logic in the resolver for scene creation and updates. - Added migration to include `cover_image_source` column in the scenes table. - Enhanced UI components to support the new cover image source functionality, including updates to SceneEditPanel and SceneDetails. --- graphql/schema/types/scene.graphql | 36 +++++ internal/api/resolver_mutation_scene.go | 31 ++++ pkg/models/model_scene.go | 55 ++++--- pkg/models/scene.go | 20 +-- pkg/sqlite/database.go | 2 +- .../86_scene_cover_image_source.up.sql | 10 ++ pkg/sqlite/scene.go | 44 +++-- ui/v2.5/graphql/data/scene.graphql | 1 + .../components/Scenes/SceneDetails/Scene.tsx | 14 ++ .../Scenes/SceneDetails/SceneEditPanel.tsx | 78 +++++++-- .../SceneDetails/SceneFileInfoPanel.tsx | 150 +++++++++++++++++- .../components/Scenes/SceneMergeDialog.tsx | 92 ++++++----- ui/v2.5/src/components/Shared/ImageInput.tsx | 32 +++- .../Tagger/scenes/StashSearchResult.tsx | 8 + ui/v2.5/src/locales/en-GB.json | 20 ++- ui/v2.5/src/utils/field.tsx | 84 ++++++++++ 16 files changed, 566 insertions(+), 111 deletions(-) create mode 100644 pkg/sqlite/migrations/86_scene_cover_image_source.up.sql diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index 4d99e0a21..d45ed6a22 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -62,6 +62,18 @@ type Scene { play_duration: Float "The number ot times a scene has been played" play_count: Int + """ + Describes where the current cover image came from. + NULL | unknown/no-cover/legacy. + default | "Generate default thumbnail" + clipboard | "From clipboard" + userscript | set via GraphQL outside UI + url:* | "From URL..." + stash:* | StashBox imported image + timestamp:* | "Generate thumbnail from Current" + file:* | "From file..." + """ + cover_image_source: String "Times a scene was played" play_history: [Time!]! @@ -114,6 +126,18 @@ input SceneCreateInput { tag_ids: [ID!] "This should be a URL or a base64 encoded data URL" cover_image: String + """ + Describes where the current cover image came from. + NULL | unknown/no-cover/legacy. + default | "Generate default thumbnail" + clipboard | "From clipboard" + userscript | set via GraphQL outside UI + url:* | "From URL..." + stash:* | StashBox imported image + timestamp:* | "Generate thumbnail from Current" + file:* | "From file..." + """ + cover_image_source: String stash_ids: [StashIDInput!] """ @@ -149,6 +173,18 @@ input SceneUpdateInput { tag_ids: [ID!] "This should be a URL or a base64 encoded data URL" cover_image: String + """ + Describes where the current cover image came from. + NULL | unknown/no-cover/legacy. + default | "Generate default thumbnail" + clipboard | "From clipboard" + userscript | set via GraphQL outside UI + url:* | "From URL..." + stash:* | StashBox imported image + timestamp:* | "Generate thumbnail from Current" + file:* | "From file..." + """ + cover_image_source: String stash_ids: [StashIDInput!] "The time index a scene was left at" diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index 70158fc6f..480b961e5 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -20,6 +20,24 @@ import ( "github.com/stashapp/stash/pkg/utils" ) +// defaults to 'userscript when image is provided and source is not provided +// returns nil if source null and image is not provided +// otherwise returns the source trimmed +func applyDefaultCoverSource( + coverImageSource *string, + coverImageData []byte, +) *string { + if len(coverImageData) > 0 && coverImageSource == nil { + defaultSource := "userscript" + return &defaultSource + } + if coverImageSource == nil { + return nil + } + trimmed := strings.TrimSpace(*coverImageSource) + return &trimmed +} + // used to refetch scene after hooks run func (r *mutationResolver) getScene(ctx context.Context, id int) (ret *models.Scene, err error) { if err := r.withTxn(ctx, func(ctx context.Context) error { @@ -103,6 +121,11 @@ func (r *mutationResolver) SceneCreate(ctx context.Context, input models.SceneCr } } + newScene.CoverImageSource = applyDefaultCoverSource( + input.CoverImageSource, + coverImageData, + ) + customFields := convertMapJSONNumbers(input.CustomFields) if err := r.withTxn(ctx, func(ctx context.Context) error { @@ -187,6 +210,7 @@ func scenePartialFromInput(input models.SceneUpdateInput, translator changesetTr updatedScene.Code = translator.optionalString(input.Code, "code") updatedScene.Details = translator.optionalString(input.Details, "details") updatedScene.Director = translator.optionalString(input.Director, "director") + updatedScene.CoverImageSource = translator.optionalString(input.CoverImageSource, "cover_image_source") updatedScene.Rating = translator.optionalInt(input.Rating100, "rating100") if input.OCounter != nil { @@ -311,6 +335,10 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp if err != nil { return nil, fmt.Errorf("processing cover image: %w", err) } + + if len(coverImageData) > 0 && !updatedScene.CoverImageSource.Set { + updatedScene.CoverImageSource = models.NewOptionalString("userscript") + } } var customFields *models.CustomFieldsInput @@ -627,6 +655,9 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput if err != nil { return nil, fmt.Errorf("processing cover image: %w", err) } + if len(coverImageData) > 0 && !values.CoverImageSource.Set { + values.CoverImageSource = models.NewOptionalString("userscript") + } } if input.Values.CustomFields != nil { diff --git a/pkg/models/model_scene.go b/pkg/models/model_scene.go index 64ad34b9c..2166cc6d5 100644 --- a/pkg/models/model_scene.go +++ b/pkg/models/model_scene.go @@ -10,12 +10,13 @@ import ( // Scene stores the metadata for a single video scene. type Scene struct { - ID int `json:"id"` - Title string `json:"title"` - Code string `json:"code"` - Details string `json:"details"` - Director string `json:"director"` - Date *Date `json:"date"` + ID int `json:"id"` + Title string `json:"title"` + Code string `json:"code"` + Details string `json:"details"` + Director string `json:"director"` + CoverImageSource *string `json:"cover_image_source"` + Date *Date `json:"date"` // Rating expressed in 1-100 scale Rating *int `json:"rating"` Organized bool `json:"organized"` @@ -70,11 +71,12 @@ type UpdateSceneInput struct { // ScenePartial represents part of a Scene object. It is used to update // the database entry. type ScenePartial struct { - Title OptionalString - Code OptionalString - Details OptionalString - Director OptionalString - Date OptionalDate + Title OptionalString + Code OptionalString + Details OptionalString + Director OptionalString + CoverImageSource OptionalString + Date OptionalDate // Rating expressed in 1-100 scale Rating OptionalInt Organized OptionalBool @@ -212,21 +214,22 @@ func (s ScenePartial) UpdateInput(id int) SceneUpdateInput { } ret := SceneUpdateInput{ - ID: strconv.Itoa(id), - Title: s.Title.Ptr(), - Code: s.Code.Ptr(), - Details: s.Details.Ptr(), - Director: s.Director.Ptr(), - Urls: s.URLs.Strings(), - Date: dateStr, - Rating100: s.Rating.Ptr(), - Organized: s.Organized.Ptr(), - StudioID: s.StudioID.StringPtr(), - GalleryIds: s.GalleryIDs.IDStrings(), - PerformerIds: s.PerformerIDs.IDStrings(), - Movies: s.GroupIDs.SceneMovieInputs(), - TagIds: s.TagIDs.IDStrings(), - StashIds: stashIDs.ToStashIDInputs(), + ID: strconv.Itoa(id), + Title: s.Title.Ptr(), + Code: s.Code.Ptr(), + Details: s.Details.Ptr(), + Director: s.Director.Ptr(), + CoverImageSource: s.CoverImageSource.Ptr(), + Urls: s.URLs.Strings(), + Date: dateStr, + Rating100: s.Rating.Ptr(), + Organized: s.Organized.Ptr(), + StudioID: s.StudioID.StringPtr(), + GalleryIds: s.GalleryIDs.IDStrings(), + PerformerIds: s.PerformerIDs.IDStrings(), + Movies: s.GroupIDs.SceneMovieInputs(), + TagIds: s.TagIDs.IDStrings(), + StashIds: stashIDs.ToStashIDInputs(), } return ret diff --git a/pkg/models/scene.go b/pkg/models/scene.go index 839452501..22bc18c8f 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -190,8 +190,9 @@ type SceneCreateInput struct { Groups []SceneGroupInput `json:"groups"` TagIds []string `json:"tag_ids"` // This should be a URL or a base64 encoded data URL - CoverImage *string `json:"cover_image"` - StashIds []StashIDInput `json:"stash_ids"` + CoverImage *string `json:"cover_image"` + CoverImageSource *string `json:"cover_image_source"` + StashIds []StashIDInput `json:"stash_ids"` // 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. @@ -219,13 +220,14 @@ type SceneUpdateInput struct { Groups []SceneGroupInput `json:"groups"` TagIds []string `json:"tag_ids"` // This should be a URL or a base64 encoded data URL - CoverImage *string `json:"cover_image"` - StashIds []StashIDInput `json:"stash_ids"` - ResumeTime *float64 `json:"resume_time"` - PlayDuration *float64 `json:"play_duration"` - PlayCount *int `json:"play_count"` - PrimaryFileID *string `json:"primary_file_id"` - CustomFields *CustomFieldsInput + CoverImage *string `json:"cover_image"` + CoverImageSource *string `json:"cover_image_source"` + StashIds []StashIDInput `json:"stash_ids"` + ResumeTime *float64 `json:"resume_time"` + PlayDuration *float64 `json:"play_duration"` + PlayCount *int `json:"play_count"` + PrimaryFileID *string `json:"primary_file_id"` + CustomFields *CustomFieldsInput } type SceneDestroyInput struct { diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 7c383dc4c..026a18c08 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 85 +var appSchemaVersion uint = 86 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/86_scene_cover_image_source.up.sql b/pkg/sqlite/migrations/86_scene_cover_image_source.up.sql new file mode 100644 index 000000000..15ebfed5b --- /dev/null +++ b/pkg/sqlite/migrations/86_scene_cover_image_source.up.sql @@ -0,0 +1,10 @@ +ALTER TABLE `scenes` ADD COLUMN `cover_image_source` text CHECK ( + `cover_image_source` IS NULL + OR `cover_image_source` = 'default' + OR `cover_image_source` = 'clipboard' + OR `cover_image_source` = 'userscript' + OR `cover_image_source` LIKE 'url:%' + OR `cover_image_source` LIKE 'stash:%' + OR `cover_image_source` LIKE 'timestamp:%' + OR `cover_image_source` LIKE 'file:%' +); diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index c2093431d..433ff8492 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -77,13 +77,14 @@ ORDER BY files.size DESC; ` type sceneRow struct { - ID int `db:"id" goqu:"skipinsert"` - Title zero.String `db:"title"` - Code zero.String `db:"code"` - Details zero.String `db:"details"` - Director zero.String `db:"director"` - Date NullDate `db:"date"` - DatePrecision null.Int `db:"date_precision"` + ID int `db:"id" goqu:"skipinsert"` + Title zero.String `db:"title"` + Code zero.String `db:"code"` + CoverImageSource zero.String `db:"cover_image_source"` + Details zero.String `db:"details"` + Director zero.String `db:"director"` + Date NullDate `db:"date"` + DatePrecision null.Int `db:"date_precision"` // expressed as 1-100 Rating null.Int `db:"rating"` Organized bool `db:"organized"` @@ -92,7 +93,7 @@ type sceneRow struct { UpdatedAt Timestamp `db:"updated_at"` ResumeTime float64 `db:"resume_time"` PlayDuration float64 `db:"play_duration"` - + // not used in resolutions or updates CoverBlob zero.String `db:"cover_blob"` } @@ -103,6 +104,7 @@ func (r *sceneRow) fromScene(o models.Scene) { r.Code = zero.StringFrom(o.Code) r.Details = zero.StringFrom(o.Details) r.Director = zero.StringFrom(o.Director) + r.CoverImageSource = zero.StringFromPtr(o.CoverImageSource) r.Date = NullDateFromDatePtr(o.Date) r.DatePrecision = datePrecisionFromDatePtr(o.Date) r.Rating = intFromPtr(o.Rating) @@ -124,16 +126,23 @@ type sceneQueryRow struct { } func (r *sceneQueryRow) resolve() *models.Scene { + var coverImageSource *string + if r.CoverImageSource.Valid { + v := r.CoverImageSource.String + coverImageSource = &v + } + ret := &models.Scene{ - ID: r.ID, - Title: r.Title.String, - Code: r.Code.String, - Details: r.Details.String, - Director: r.Director.String, - Date: r.Date.DatePtr(r.DatePrecision), - Rating: nullIntPtr(r.Rating), - Organized: r.Organized, - StudioID: nullIntPtr(r.StudioID), + ID: r.ID, + Title: r.Title.String, + Code: r.Code.String, + Details: r.Details.String, + Director: r.Director.String, + CoverImageSource: coverImageSource, + Date: r.Date.DatePtr(r.DatePrecision), + Rating: nullIntPtr(r.Rating), + Organized: r.Organized, + StudioID: nullIntPtr(r.StudioID), PrimaryFileID: nullIntFileIDPtr(r.PrimaryFileID), OSHash: r.PrimaryFileOshash.String, @@ -162,6 +171,7 @@ func (r *sceneRowRecord) fromPartial(o models.ScenePartial) { r.setNullString("code", o.Code) r.setNullString("details", o.Details) r.setNullString("director", o.Director) + r.setNullString("cover_image_source", o.CoverImageSource) r.setNullDate("date", "date_precision", o.Date) r.setNullInt("rating", o.Rating) r.setBool("organized", o.Organized) diff --git a/ui/v2.5/graphql/data/scene.graphql b/ui/v2.5/graphql/data/scene.graphql index b7378c1da..03ac15aa7 100644 --- a/ui/v2.5/graphql/data/scene.graphql +++ b/ui/v2.5/graphql/data/scene.graphql @@ -21,6 +21,7 @@ fragment SceneData on Scene { last_played_at play_duration play_count + cover_image_source play_history o_history diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index cc459c7c6..6bb92d212 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -160,6 +160,10 @@ interface ISceneParams { id: string; } +type SceneUpdateInputWithCoverSource = GQL.SceneUpdateInput & { + cover_image_source?: string | null; +}; + const ScenePageTabs = PatchContainerComponent("ScenePage.Tabs"); const ScenePageTabContent = PatchContainerComponent( "ScenePage.TabContent" @@ -385,12 +389,22 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { } async function onGenerateScreenshot(at?: number) { + const input: SceneUpdateInputWithCoverSource = { + id: scene.id, + cover_image_source: typeof at === "number" ? `timestamp:${at}` : "default", + }; + await generateScreenshot({ variables: { id: scene.id, at, }, }); + await updateScene({ + variables: { + input, + }, + }); Toast.success(intl.formatMessage({ id: "toast.generating_screenshot" })); } diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 224535710..8b9c0f8f8 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -149,6 +149,7 @@ export const SceneEditPanel: React.FC = ({ stash_ids: yup.mixed().defined(), details: yup.string().ensure(), cover_image: yup.string().nullable().optional(), + cover_image_source: yup.string().nullable().optional(), custom_fields: yup.object().required().defined(), }); @@ -169,6 +170,9 @@ export const SceneEditPanel: React.FC = ({ stash_ids: getStashIDs(scene.stash_ids), details: scene.details ?? "", cover_image: initialCoverImage, + cover_image_source: + (scene as { cover_image_source?: string | null }).cover_image_source ?? + null, custom_fields: cloneDeep(scene.custom_fields ?? {}), }), [scene, initialCoverImage] @@ -177,12 +181,17 @@ export const SceneEditPanel: React.FC = ({ type InputValues = yup.InferType; const [customFieldsError, setCustomFieldsError] = useState(); + const [coverImageSourceDirty, setCoverImageSourceDirty] = useState(false); + const [coverImageRefreshToken, setCoverImageRefreshToken] = useState(0); function submit(values: InputValues) { const input = { ...schema.cast(values), custom_fields: formatCustomFieldInput(isNew, values.custom_fields), }; + if (!coverImageSourceDirty) { + delete (input as { cover_image_source?: string | null }).cover_image_source; + } onSave(input); } @@ -204,12 +213,18 @@ export const SceneEditPanel: React.FC = ({ if (formImage === null && sceneImage) { const sceneImageURL = new URL(sceneImage); sceneImageURL.searchParams.set("default", "true"); + if (coverImageRefreshToken > 0) { + sceneImageURL.searchParams.set( + "refresh", + coverImageRefreshToken.toString() + ); + } return sceneImageURL.toString(); } else if (formImage) { return formImage; } return sceneImage; - }, [formik.values.cover_image, scene.paths?.screenshot]); + }, [coverImageRefreshToken, formik.values.cover_image, scene.paths?.screenshot]); const groupEntries = useMemo(() => { return formik.values.groups @@ -263,6 +278,11 @@ export const SceneEditPanel: React.FC = ({ } }); + useEffect(() => { + setCoverImageSourceDirty(false); + setCoverImageRefreshToken(0); + }, [scene.id]); + useEffect(() => { const toFilter = Scrapers?.data?.listScrapers ?? []; @@ -302,6 +322,7 @@ export const SceneEditPanel: React.FC = ({ try { await onSubmit(input, andNew); formik.resetForm(); + setCoverImageSourceDirty(false); } catch (e) { Toast.error(e); } @@ -316,18 +337,57 @@ export const SceneEditPanel: React.FC = ({ onSave(input, true); } - const encodingImage = ImageUtils.usePasteImage(onImageLoad); + const encodingImage = ImageUtils.usePasteImage((imageData) => + onImageLoad(imageData, "clipboard") + ); - function onImageLoad(imageData: string) { + function setCoverImageSource(source: string | null) { + formik.setFieldValue("cover_image_source", source); + setCoverImageSourceDirty(true); + } + + function onImageLoad(imageData: string, source?: string) { formik.setFieldValue("cover_image", imageData); + if (source) { + setCoverImageSource(source); + } + } + + function onImageURLSource( + source: "url" | "clipboard", + value?: string + ) { + if (source === "clipboard") { + setCoverImageSource("clipboard"); + return; + } + + if (source === "url") { + setCoverImageSource(`url:${(value ?? "").trim()}`); + } } function onCoverImageChange(event: React.FormEvent) { - ImageUtils.onImageChange(event, onImageLoad); + const input = event.currentTarget as HTMLInputElement; + const fileName = input.files?.[0]?.name?.trim() ?? ""; + setCoverImageSource(`file:${fileName}`); + ImageUtils.onImageChange(event, (imageData) => onImageLoad(imageData)); } function onResetCover() { formik.setFieldValue("cover_image", null); + setCoverImageSource(null); + } + + async function onGenerateDefaultCoverImage() { + if (!onGenerateThumbDefault) { + return; + } + + await onGenerateThumbDefault(); + formik.setFieldValue("cover_image", null); + setCoverImageSource("default"); + setCoverImageRefreshToken(Date.now()); } async function onScrapeClicked(s: GQL.ScraperSourceInput) { @@ -540,6 +600,7 @@ export const SceneEditPanel: React.FC = ({ if (updatedScene.image) { // image is a base64 string formik.setFieldValue("cover_image", updatedScene.image); + setCoverImageSource(endpoint ? `stash:${endpoint}` : "url:scraper"); } if (updatedScene.remote_site_id && endpoint) { @@ -890,15 +951,12 @@ export const SceneEditPanel: React.FC = ({ isEditing onImageChange={onCoverImageChange} onImageURL={onImageLoad} - onGenerateDefault={onGenerateThumbDefault} + onImageURLSource={onImageURLSource} + onReset={onResetCover} + onGenerateDefault={onGenerateDefaultCoverImage} onGenerateCurrent={onGenerateThumbFromCurrent} /> )} - {scene.id && ( - - )} diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx index cd11a2c8a..1efe4bd2a 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx @@ -14,16 +14,50 @@ import { ReassignFilesDialog } from "src/components/Shared/ReassignFilesDialog"; import * as GQL from "src/core/generated-graphql"; import { mutateSceneSetPrimaryFile } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; +import { useConfigurationContext } from "src/hooks/Config"; import NavUtils from "src/utils/navigation"; import TextUtils from "src/utils/text"; -import { TextField, URLField, URLsField } from "src/utils/field"; +import { IconField, TextField, URLField, URLsField } from "src/utils/field"; import { StashIDPill } from "src/components/Shared/StashID"; import { PatchComponent } from "../../../patch"; import { FileSize } from "src/components/Shared/FileSize"; +import { + faCameraRotate, + faClipboard, + faFileCode, + faFile, + faLink, + faPhotoFilm, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIconProps } from "@fortawesome/react-fontawesome"; + +const pad2 = (value: number) => value.toString().padStart(2, "0"); + +function formatCoverTimestamp(seconds: number, frameRate?: number | null) { + const fps = frameRate ?? 0; + const roundedSeconds = + Number.isFinite(fps) && fps > 0 + ? Math.round(seconds * fps) / fps + : Math.round(seconds * 100) / 100; + + const centiseconds = Math.round(roundedSeconds * 100); + const totalSeconds = Math.floor(centiseconds / 100); + const cs = centiseconds % 100; + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const secs = totalSeconds % 60; + + if (hours > 0) { + return `${hours}:${pad2(minutes)}:${pad2(secs)}.${pad2(cs)}`; + } + + return `${minutes}:${pad2(secs)}.${pad2(cs)}`; +} interface IFileInfoPanelProps { sceneID: string; file: GQL.VideoFileDataFragment; + coverImageSource?: string | null; primary?: boolean; ofMany?: boolean; onSetPrimaryFile?: () => void; @@ -32,17 +66,120 @@ interface IFileInfoPanelProps { loading?: boolean; } +interface CoverSourceData { + icon?: FontAwesomeIconProps["icon"]; + value: string; + url?: string | null; + stashEndpoint?: string | null; + tooltip?: string; +} + const FileInfoPanel: React.FC = ( props: IFileInfoPanelProps ) => { const intl = useIntl(); const history = useHistory(); + const { configuration } = useConfigurationContext(); + const coverImageLabel = intl.formatMessage({ id: "scene_cover.image" }); + const coverSourceLabel = intl.formatMessage( + { id: "scene_cover.source" }, + { image: coverImageLabel } + ); // TODO - generalise fingerprints const oshash = props.file.fingerprints.find((f) => f.type === "oshash"); const phash = props.file.fingerprints.find((f) => f.type === "phash"); const checksum = props.file.fingerprints.find((f) => f.type === "md5"); + const getCoverSourceTooltip = (id: string) => + intl.formatMessage({ id }, { image: coverImageLabel }); + + const coverSourceField = useMemo(() => { + const source = props.coverImageSource; + if (!source) { + return null; + } + + let coverSourceData: CoverSourceData; + + if (source === "default") { + coverSourceData = { + icon: faPhotoFilm, + value: intl.formatMessage({ id: "scene_cover.default" }), + tooltip: getCoverSourceTooltip("scene_cover.default_tooltip"), + }; + } else if (source === "clipboard") { + coverSourceData = { + icon: faClipboard, + value: intl.formatMessage({ id: "scene_cover.clipboard" }), + tooltip: getCoverSourceTooltip("scene_cover.clipboard_tooltip"), + }; + } else if (source === "userscript") { + coverSourceData = { + icon: faFileCode, + value: intl.formatMessage({ id: "scene_cover.userscript" }), + tooltip: getCoverSourceTooltip("scene_cover.userscript_tooltip"), + }; + } else if (source.startsWith("file:")) { + const fileName = source.slice("file:".length); + coverSourceData = { + icon: faFile, + value: fileName || intl.formatMessage({ id: "scene_cover.file" }), + tooltip: getCoverSourceTooltip("scene_cover.file_tooltip"), + }; + } else if (source.startsWith("url:")) { + const urlValue = source.slice("url:".length).trim(); + coverSourceData = { + icon: faLink, + value: urlValue || intl.formatMessage({ id: "actions.from_url" }), + url: urlValue || null, + tooltip: getCoverSourceTooltip("scene_cover.url_tooltip"), + }; + } else if (source.startsWith("timestamp:")) { + const rawTimestamp = source.slice("timestamp:".length).trim(); + const parsedTimestamp = Number.parseFloat(rawTimestamp); + const timestampLabel = Number.isFinite(parsedTimestamp) + ? formatCoverTimestamp(parsedTimestamp, props.file.frame_rate) + : rawTimestamp; + coverSourceData = { + icon: faCameraRotate, + value: timestampLabel, + tooltip: getCoverSourceTooltip("scene_cover.timestamp_tooltip"), + }; + } else if (source.startsWith("stash:")) { + const endpoint = source.slice("stash:".length).trim(); + const endpointName = + configuration?.general.stashBoxes.find((sb) => sb.endpoint === endpoint) + ?.name ?? endpoint; + coverSourceData = { + value: endpointName, + stashEndpoint: endpointName, + tooltip: getCoverSourceTooltip("scene_cover.stash_tooltip"), + }; + } else { + coverSourceData = { value: source }; + } + + return ( + + ); + }, [ + configuration?.general.stashBoxes, + coverImageLabel, + coverSourceLabel, + intl, + props.coverImageSource, + props.file.frame_rate, + ]); + function onSplit() { history.push( `/scenes/new?from_scene_id=${props.sceneID}&file_id=${props.file.id}` @@ -76,6 +213,7 @@ const FileInfoPanel: React.FC = ( truncate internal /> + {coverSourceField} @@ -171,6 +309,9 @@ const _SceneFileInfoPanel: React.FC = ( props: ISceneFileInfoPanelProps ) => { const Toast = useToast(); + const coverImageSource = + (props.scene as { cover_image_source?: string | null }).cover_image_source ?? + null; const [loading, setLoading] = useState(false); const [deletingFile, setDeletingFile] = useState(); @@ -232,7 +373,11 @@ const _SceneFileInfoPanel: React.FC = ( if (props.scene.files.length === 1) { return ( - + ); } @@ -271,6 +416,7 @@ const _SceneFileInfoPanel: React.FC = ( onSetPrimaryFile(file.id)} diff --git a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx index d62daac7a..6763aeb57 100644 --- a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx @@ -625,45 +625,63 @@ const SceneMergeDetails: React.FC = ({ // only set the cover image if it's different from the existing cover image const coverImage = image.useNewValue ? image.getNewValue() : undefined; + const sourceCoverImageSource = ( + sources.find((s) => s.paths.screenshot) as unknown as + | { cover_image_source?: string | null } + | undefined + )?.cover_image_source; + const coverImageSource = image.useNewValue + ? coverImage + ? (sourceCoverImageSource ?? null) + : null + : undefined; + + const values: GQL.SceneUpdateInput = { + id: dest.id, + title: title.getNewValue(), + code: code.getNewValue(), + urls: url.getNewValue(), + date: date.getNewValue(), + rating100: rating.getNewValue(), + o_counter: oCounter.getNewValue(), + play_count: playCount.getNewValue(), + play_duration: playDuration.getNewValue(), + gallery_ids: galleries.getNewValue(), + studio_id: studio.getNewValue()?.stored_id, + performer_ids: performers.getNewValue()?.map((p) => p.stored_id!), + groups: groups.getNewValue()?.map((m) => { + // find the equivalent group in the original scenes + const found = all + .map((s) => s.groups) + .flat() + .find((mm) => mm.group.id === m.stored_id); + return { + group_id: m.stored_id!, + scene_index: found!.scene_index, + }; + }), + tag_ids: tags.getNewValue()?.map((t) => t.stored_id!), + details: details.getNewValue(), + organized: organized.getNewValue(), + stash_ids: stashIDs.getNewValue(), + cover_image: coverImage, + custom_fields: { + partial: Object.fromEntries( + Array.from(customFields.entries()).flatMap(([field, v]) => + v.useNewValue ? [[field, v.getNewValue()]] : [] + ) + ), + }, + }; + + if (coverImageSource !== undefined) { + ( + values as unknown as { cover_image_source?: string | null } + ).cover_image_source = coverImageSource; + } return { - values: { - id: dest.id, - title: title.getNewValue(), - code: code.getNewValue(), - urls: url.getNewValue(), - date: date.getNewValue(), - rating100: rating.getNewValue(), - o_counter: oCounter.getNewValue(), - play_count: playCount.getNewValue(), - play_duration: playDuration.getNewValue(), - gallery_ids: galleries.getNewValue(), - studio_id: studio.getNewValue()?.stored_id, - performer_ids: performers.getNewValue()?.map((p) => p.stored_id!), - groups: groups.getNewValue()?.map((m) => { - // find the equivalent group in the original scenes - const found = all - .map((s) => s.groups) - .flat() - .find((mm) => mm.group.id === m.stored_id); - return { - group_id: m.stored_id!, - scene_index: found!.scene_index, - }; - }), - tag_ids: tags.getNewValue()?.map((t) => t.stored_id!), - details: details.getNewValue(), - organized: organized.getNewValue(), - stash_ids: stashIDs.getNewValue(), - cover_image: coverImage, - custom_fields: { - partial: Object.fromEntries( - Array.from(customFields.entries()).flatMap(([field, v]) => - v.useNewValue ? [[field, v.getNewValue()]] : [] - ) - ), - }, - }, + values, includeViewHistory: playCount.getNewValue() !== undefined, includeOHistory: oCounter.getNewValue() !== undefined, }; diff --git a/ui/v2.5/src/components/Shared/ImageInput.tsx b/ui/v2.5/src/components/Shared/ImageInput.tsx index 7edea6444..7f9e933bd 100644 --- a/ui/v2.5/src/components/Shared/ImageInput.tsx +++ b/ui/v2.5/src/components/Shared/ImageInput.tsx @@ -10,7 +10,14 @@ import { import { useIntl } from "react-intl"; import { ModalComponent } from "./Modal"; import { Icon } from "./Icon"; -import { faArrowsRotate, faCameraRotate, faClipboard, faFile, faLink } from "@fortawesome/free-solid-svg-icons"; +import { + faCameraRotate, + faClipboard, + faFile, + faLink, + faPhotoFilm, + faCirclePlay +} from "@fortawesome/free-solid-svg-icons"; import { PatchComponent } from "src/patch"; import ImageUtils from "src/utils/image"; import { useToast } from "src/hooks/Toast"; @@ -20,6 +27,7 @@ interface IImageInput { text?: string; onImageChange: (event: React.ChangeEvent) => void; onImageURL?: (url: string) => void; + onImageURLSource?: (source: "url" | "clipboard", value?: string) => void; onReset?: () => void; acceptSVG?: boolean; onGenerateDefault?: () => void; @@ -37,6 +45,7 @@ export const ImageInput: React.FC = PatchComponent( text, onImageChange, onImageURL, + onImageURLSource, onReset, acceptSVG = false, onGenerateDefault, @@ -69,6 +78,7 @@ export const ImageInput: React.FC = PatchComponent( const data = await ImageUtils.readClipboardImage(); if (data && onImageURL) { onImageURL(data); + onImageURLSource?.("clipboard"); Toast.success( intl.formatMessage({ id: "toast.clipboard_image_pasted" }) ); @@ -98,6 +108,7 @@ export const ImageInput: React.FC = PatchComponent( setIsShowDialog(false); onImageURL(url); + onImageURLSource?.("url", url); } function renderDialog() { @@ -165,10 +176,11 @@ export const ImageInput: React.FC = PatchComponent( )} + {(onGenerateDefault || onGenerateCurrent) &&
} {onGenerateDefault && (
)} + {onReset && ( + <> +
+
+ +
+ + )} @@ -205,11 +228,6 @@ export const ImageInput: React.FC = PatchComponent( {text ?? intl.formatMessage({ id: "actions.set_image" })} - {onReset && ( - - )} ); } diff --git a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx index add295c49..7512fa80c 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx @@ -383,6 +383,14 @@ const StashSearchResult: React.FC = ({ director: resolveField("director", stashScene.director, scene.director), }; + if (imgData) { + ( + sceneCreateInput as unknown as { cover_image_source?: string } + ).cover_image_source = currentSource?.sourceInput.stash_box_endpoint + ? `stash:${currentSource.sourceInput.stash_box_endpoint}` + : "url:scraper"; + } + const includeUrl = !excludedFieldList.includes("url"); if (includeUrl && scene.urls) { sceneCreateInput.urls = uniq(stashScene.urls.concat(scene.urls)); diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 4974c06ca..0c4cb76ee 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -64,8 +64,8 @@ "full_export": "Full export", "full_import": "Full import", "generate": "Generate", - "generate_thumb_default": "Generate default thumbnail", - "generate_thumb_from_current": "Generate thumbnail from current", + "generate_thumb_default": "Generate default", + "generate_thumb_from_current": "Generate from current", "hash_migration": "hash migration", "hide": "Hide", "hide_configuration": "Hide Configuration", @@ -1419,6 +1419,22 @@ "sceneTagger": "Scene Tagger", "scene_code": "Studio Code", "scene_count": "Scene Count", + "scene_cover": { + "clipboard": "Clipboard", + "clipboard_tooltip": "{image} imported from clipboard by user", + "default": "Default", + "default_tooltip": "{image} generated from default settings", + "file": "File", + "file_tooltip": "{image} from file", + "image": "Cover Image", + "source": "{image} Source", + "stash_tooltip": "{image} from StashBox", + "timestamp_tooltip": "{image} extracted at timestamp", + "url": "URL", + "url_tooltip": "{image} from URL", + "userscript": "Userscript", + "userscript_tooltip": "{image} set via userscript" + }, "scenes_duration": "Scene Duration", "scenes_size": "Scene Size", "scene_created_at": "Scene Created At", diff --git a/ui/v2.5/src/utils/field.tsx b/ui/v2.5/src/utils/field.tsx index c4a55d475..81884aa7b 100644 --- a/ui/v2.5/src/utils/field.tsx +++ b/ui/v2.5/src/utils/field.tsx @@ -2,7 +2,9 @@ import React from "react"; import { FormattedMessage } from "react-intl"; import { Link } from "react-router-dom"; import { ExternalLink } from "src/components/Shared/ExternalLink"; +import { Icon } from "src/components/Shared/Icon"; import { TruncatedText } from "src/components/Shared/TruncatedText"; +import { FontAwesomeIconProps } from "@fortawesome/react-fontawesome"; interface ITextField { id?: string; @@ -155,3 +157,85 @@ export const URLsField: React.FC = ({ ); }; + +interface IIconField { + id?: string; + name?: string; + abbr?: string | null; + icon?: FontAwesomeIconProps["icon"]; + tooltip?: string; + value?: string | null; + url?: string | null; + stashEndpoint?: string | null; + truncate?: boolean | null; + target?: string; +} + +export const IconField: React.FC = ({ + id, + name, + abbr, + icon, + tooltip, + value, + url, + stashEndpoint, + truncate, + target = "_blank", +}) => { + if (!value && !url && !stashEndpoint) { + return null; + } + + const message = ( + <>{id ? : name}: + ); + + const displayValue = value ?? url ?? stashEndpoint ?? ""; + + function renderValue() { + if (stashEndpoint) { + return ( + + {displayValue} + + ); + } + + if (url) { + const children = truncate ? ( + + ) : ( + displayValue + ); + return ( + + {children} + + ); + } + + return displayValue; + } + + return ( + <> +
{abbr ? {message} : message}
+
+ + {icon && } + {renderValue()} + +
+ + ); +};