mirror of https://github.com/stashapp/stash.git
Upload Image from url (#1193)
This commit is contained in:
parent
b3966b3c76
commit
55aee21cff
|
@ -26,7 +26,7 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input models.MovieCr
|
|||
|
||||
// Process the base 64 encoded image string
|
||||
if input.FrontImage != nil {
|
||||
_, frontimageData, err = utils.ProcessBase64Image(*input.FrontImage)
|
||||
frontimageData, err = utils.ProcessImageInput(*input.FrontImage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input models.MovieCr
|
|||
|
||||
// Process the base 64 encoded image string
|
||||
if input.BackImage != nil {
|
||||
_, backimageData, err = utils.ProcessBase64Image(*input.BackImage)
|
||||
backimageData, err = utils.ProcessImageInput(*input.BackImage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -126,7 +126,7 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input models.MovieUp
|
|||
var frontimageData []byte
|
||||
frontImageIncluded := translator.hasField("front_image")
|
||||
if input.FrontImage != nil {
|
||||
_, frontimageData, err = utils.ProcessBase64Image(*input.FrontImage)
|
||||
frontimageData, err = utils.ProcessImageInput(*input.FrontImage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -134,7 +134,7 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input models.MovieUp
|
|||
backImageIncluded := translator.hasField("back_image")
|
||||
var backimageData []byte
|
||||
if input.BackImage != nil {
|
||||
_, backimageData, err = utils.ProcessBase64Image(*input.BackImage)
|
||||
backimageData, err = utils.ProcessImageInput(*input.BackImage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -189,7 +189,7 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input models.MovieUp
|
|||
// HACK - if front image is null and back image is not null, then set the front image
|
||||
// to the default image since we can't have a null front image and a non-null back image
|
||||
if frontimageData == nil && backimageData != nil {
|
||||
_, frontimageData, _ = utils.ProcessBase64Image(models.DefaultMovieImage)
|
||||
frontimageData, _ = utils.ProcessImageInput(models.DefaultMovieImage)
|
||||
}
|
||||
|
||||
if err := qb.UpdateImages(movie.ID, frontimageData, backimageData); err != nil {
|
||||
|
|
|
@ -18,7 +18,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
|
|||
var err error
|
||||
|
||||
if input.Image != nil {
|
||||
_, imageData, err = utils.ProcessBase64Image(*input.Image)
|
||||
imageData, err = utils.ProcessImageInput(*input.Image)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
@ -139,7 +139,7 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
|
|||
var err error
|
||||
imageIncluded := translator.hasField("image")
|
||||
if input.Image != nil {
|
||||
_, imageData, err = utils.ProcessBase64Image(*input.Image)
|
||||
imageData, err = utils.ProcessImageInput(*input.Image)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
@ -80,7 +80,7 @@ func (r *mutationResolver) sceneUpdate(input models.SceneUpdateInput, translator
|
|||
|
||||
if input.CoverImage != nil && *input.CoverImage != "" {
|
||||
var err error
|
||||
_, coverImageData, err = utils.ProcessBase64Image(*input.CoverImage)
|
||||
coverImageData, err = utils.ProcessImageInput(*input.CoverImage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
|
|||
|
||||
// Process the base 64 encoded image string
|
||||
if input.Image != nil {
|
||||
_, imageData, err = utils.ProcessBase64Image(*input.Image)
|
||||
imageData, err = utils.ProcessImageInput(*input.Image)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -96,7 +96,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
|||
imageIncluded := translator.hasField("image")
|
||||
if input.Image != nil {
|
||||
var err error
|
||||
_, imageData, err = utils.ProcessBase64Image(*input.Image)
|
||||
imageData, err = utils.ProcessImageInput(*input.Image)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input models.TagCreate
|
|||
var err error
|
||||
|
||||
if input.Image != nil {
|
||||
_, imageData, err = utils.ProcessBase64Image(*input.Image)
|
||||
imageData, err = utils.ProcessImageInput(*input.Image)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -82,7 +82,7 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input models.TagUpdate
|
|||
|
||||
imageIncluded := translator.hasField("image")
|
||||
if input.Image != nil {
|
||||
_, imageData, err = utils.ProcessBase64Image(*input.Image)
|
||||
imageData, err = utils.ProcessImageInput(*input.Image)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
@ -4,11 +4,66 @@ import (
|
|||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Timeout to get the image. Includes transfer time. May want to make this
|
||||
// configurable at some point.
|
||||
const imageGetTimeout = time.Second * 60
|
||||
|
||||
const base64RE = `^data:.+\/(.+);base64,(.*)$`
|
||||
|
||||
// ProcessImageInput transforms an image string either from a base64 encoded
|
||||
// string, or from a URL, and returns the image as a byte slice
|
||||
func ProcessImageInput(imageInput string) ([]byte, error) {
|
||||
regex := regexp.MustCompile(base64RE)
|
||||
if regex.MatchString(imageInput) {
|
||||
_, d, err := ProcessBase64Image(imageInput)
|
||||
return d, err
|
||||
}
|
||||
|
||||
// assume input is a URL. Read it.
|
||||
return ReadImageFromURL(imageInput)
|
||||
}
|
||||
|
||||
// ReadImageFromURL returns image data from a URL
|
||||
func ReadImageFromURL(url string) ([]byte, error) {
|
||||
client := &http.Client{
|
||||
Timeout: imageGetTimeout,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// assume is a URL for now
|
||||
|
||||
// set the host of the URL as the referer
|
||||
if req.URL.Scheme != "" {
|
||||
req.Header.Set("Referer", req.URL.Scheme+"://"+req.Host+"/")
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// ProcessBase64Image transforms a base64 encoded string from a form post and returns the MD5 hash of the data and the
|
||||
// image itself as a byte slice.
|
||||
func ProcessBase64Image(imageString string) (string, []byte, error) {
|
||||
|
@ -16,7 +71,7 @@ func ProcessBase64Image(imageString string) (string, []byte, error) {
|
|||
return "", nil, fmt.Errorf("empty image string")
|
||||
}
|
||||
|
||||
regex := regexp.MustCompile(`^data:.+\/(.+);base64,(.*)$`)
|
||||
regex := regexp.MustCompile(base64RE)
|
||||
matches := regex.FindStringSubmatch(imageString)
|
||||
var encodedString string
|
||||
if len(matches) > 2 {
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
* Added Performer tags.
|
||||
|
||||
### 🎨 Improvements
|
||||
* Allow scene/performer/studio image upload via URL.
|
||||
* Add button to hide unmatched scenes in Tagger view.
|
||||
* Hide create option in dropdowns when searching in filters.
|
||||
* Add scrape gallery from fragment to UI
|
||||
|
|
|
@ -571,8 +571,10 @@ export const Movie: React.FC = () => {
|
|||
onToggleEdit={onToggleEdit}
|
||||
onSave={onSave}
|
||||
onImageChange={onFrontImageChange}
|
||||
onImageChangeURL={onFrontImageLoad}
|
||||
onClearImage={onClearFrontImage}
|
||||
onBackImageChange={onBackImageChange}
|
||||
onBackImageChangeURL={onBackImageLoad}
|
||||
onClearBackImage={onClearBackImage}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
|
|
|
@ -418,6 +418,10 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||
ImageUtils.onImageChange(event, onImageLoad);
|
||||
}
|
||||
|
||||
function onImageChangeURL(url: string) {
|
||||
formik.setFieldValue("image", url);
|
||||
}
|
||||
|
||||
function onDisplayScrapeDialog(scraper: GQL.Scraper) {
|
||||
setIsDisplayingScraperDialog(scraper);
|
||||
}
|
||||
|
@ -533,7 +537,12 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||
);
|
||||
|
||||
return (
|
||||
<OverlayTrigger trigger="click" placement="top" overlay={popover}>
|
||||
<OverlayTrigger
|
||||
trigger="click"
|
||||
placement="top"
|
||||
overlay={popover}
|
||||
rootClose
|
||||
>
|
||||
<Button variant="secondary" className="mr-2">
|
||||
Scrape with...
|
||||
</Button>
|
||||
|
@ -636,7 +645,11 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||
""
|
||||
)}
|
||||
{renderScraperMenu()}
|
||||
<ImageInput isEditing onImageChange={onImageChangeHandler} />
|
||||
<ImageInput
|
||||
isEditing
|
||||
onImageChange={onImageChangeHandler}
|
||||
onImageURL={onImageChangeURL}
|
||||
/>
|
||||
<Button
|
||||
className="mx-2"
|
||||
variant="danger"
|
||||
|
|
|
@ -723,7 +723,11 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
alt="Scene cover"
|
||||
/>
|
||||
)}
|
||||
<ImageInput isEditing onImageChange={onCoverImageChange} />
|
||||
<ImageInput
|
||||
isEditing
|
||||
onImageChange={onCoverImageChange}
|
||||
onImageURL={onImageLoad}
|
||||
/>
|
||||
</Form.Group>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -12,6 +12,8 @@ interface IProps {
|
|||
onAutoTag?: () => void;
|
||||
onImageChange: (event: React.FormEvent<HTMLInputElement>) => void;
|
||||
onBackImageChange?: (event: React.FormEvent<HTMLInputElement>) => void;
|
||||
onImageChangeURL?: (url: string) => void;
|
||||
onBackImageChangeURL?: (url: string) => void;
|
||||
onClearImage?: () => void;
|
||||
onClearBackImage?: () => void;
|
||||
acceptSVG?: boolean;
|
||||
|
@ -65,6 +67,7 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
|
|||
isEditing={props.isEditing}
|
||||
text="Back image..."
|
||||
onImageChange={props.onBackImageChange}
|
||||
onImageURL={props.onBackImageChangeURL}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -116,6 +119,7 @@ export const DetailsEditNavbar: React.FC<IProps> = (props: IProps) => {
|
|||
isEditing={props.isEditing}
|
||||
text={props.onBackImageChange ? "Front image..." : undefined}
|
||||
onImageChange={props.onImageChange}
|
||||
onImageURL={props.onImageChangeURL}
|
||||
acceptSVG={props.acceptSVG ?? false}
|
||||
/>
|
||||
{props.isEditing && props.onClearImage ? (
|
||||
|
|
|
@ -1,10 +1,20 @@
|
|||
import React from "react";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
OverlayTrigger,
|
||||
Popover,
|
||||
Row,
|
||||
} from "react-bootstrap";
|
||||
import { Modal } from ".";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface IImageInput {
|
||||
isEditing: boolean;
|
||||
text?: string;
|
||||
onImageChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onImageURL?: (url: string) => void;
|
||||
acceptSVG?: boolean;
|
||||
}
|
||||
|
||||
|
@ -12,18 +22,107 @@ export const ImageInput: React.FC<IImageInput> = ({
|
|||
isEditing,
|
||||
text,
|
||||
onImageChange,
|
||||
onImageURL,
|
||||
acceptSVG = false,
|
||||
}) => {
|
||||
const [isShowDialog, setIsShowDialog] = useState(false);
|
||||
const [url, setURL] = useState("");
|
||||
|
||||
if (!isEditing) return <div />;
|
||||
|
||||
if (!onImageURL) {
|
||||
// just return the file input
|
||||
return (
|
||||
<Form.Label className="image-input">
|
||||
<Button variant="secondary">{text ?? "Browse for image..."}</Button>
|
||||
<Form.Control
|
||||
type="file"
|
||||
onChange={onImageChange}
|
||||
accept={`.jpg,.jpeg,.png${acceptSVG ? ",.svg" : ""}`}
|
||||
/>
|
||||
</Form.Label>
|
||||
);
|
||||
}
|
||||
|
||||
function onConfirmURL() {
|
||||
if (!onImageURL) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsShowDialog(false);
|
||||
onImageURL(url);
|
||||
}
|
||||
|
||||
function renderDialog() {
|
||||
return (
|
||||
<Modal
|
||||
show={!!isShowDialog}
|
||||
onHide={() => setIsShowDialog(false)}
|
||||
header="Image URL"
|
||||
accept={{ onClick: onConfirmURL, text: "Confirm" }}
|
||||
>
|
||||
<div className="dialog-content">
|
||||
<Form.Group controlId="url" as={Row}>
|
||||
<Form.Label column xs={3}>
|
||||
URL
|
||||
</Form.Label>
|
||||
<Col xs={9}>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setURL(event.currentTarget.value)
|
||||
}
|
||||
value={url}
|
||||
placeholder="URL"
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const popover = (
|
||||
<Popover id="set-image-popover">
|
||||
<Popover.Content>
|
||||
<>
|
||||
<div>
|
||||
<Form.Label className="image-input">
|
||||
<Button variant="secondary">
|
||||
<Icon icon="file" className="fa-fw" />
|
||||
<span>From file...</span>
|
||||
</Button>
|
||||
<Form.Control
|
||||
type="file"
|
||||
onChange={onImageChange}
|
||||
accept={`.jpg,.jpeg,.png${acceptSVG ? ",.svg" : ""}`}
|
||||
/>
|
||||
</Form.Label>
|
||||
</div>
|
||||
<div>
|
||||
<Button className="minimal" onClick={() => setIsShowDialog(true)}>
|
||||
<Icon icon="link" className="fa-fw" />
|
||||
<span>From URL...</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
return (
|
||||
<Form.Label className="image-input">
|
||||
<Button variant="secondary">{text ?? "Browse for image..."}</Button>
|
||||
<Form.Control
|
||||
type="file"
|
||||
onChange={onImageChange}
|
||||
accept={`.jpg,.jpeg,.png${acceptSVG ? ",.svg" : ""}`}
|
||||
/>
|
||||
</Form.Label>
|
||||
<>
|
||||
{renderDialog()}
|
||||
<OverlayTrigger
|
||||
trigger="click"
|
||||
placement="top"
|
||||
overlay={popover}
|
||||
rootClose
|
||||
>
|
||||
<Button variant="secondary" className="mr-2">
|
||||
{text ?? "Set image..."}
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -319,6 +319,7 @@ export const Studio: React.FC = () => {
|
|||
onToggleEdit={onToggleEdit}
|
||||
onSave={onSave}
|
||||
onImageChange={onImageChangeHandler}
|
||||
onImageChangeURL={onImageLoad}
|
||||
onClearImage={() => {
|
||||
onClearImage();
|
||||
}}
|
||||
|
|
Loading…
Reference in New Issue