mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 12:54:38 +03:00
Selective scan (#940)
This commit is contained in:
@@ -31,6 +31,7 @@ input GeneratePreviewOptionsInput {
|
||||
}
|
||||
|
||||
input ScanMetadataInput {
|
||||
paths: [String!]
|
||||
useFileMetadata: Boolean!
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func (r *mutationResolver) MetadataScan(ctx context.Context, input models.ScanMetadataInput) (string, error) {
|
||||
manager.GetInstance().Scan(input.UseFileMetadata)
|
||||
manager.GetInstance().Scan(input)
|
||||
return "todo", nil
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,29 @@ func (t *TaskStatus) updated() {
|
||||
t.LastUpdate = time.Now()
|
||||
}
|
||||
|
||||
func (s *singleton) neededScan() (total *int, newFiles *int) {
|
||||
func getScanPaths(inputPaths []string) []*models.StashConfig {
|
||||
if len(inputPaths) == 0 {
|
||||
return config.GetStashPaths()
|
||||
}
|
||||
|
||||
var ret []*models.StashConfig
|
||||
for _, p := range inputPaths {
|
||||
s := getStashFromDirPath(p)
|
||||
if s == nil {
|
||||
logger.Warnf("%s is not in the configured stash paths", p)
|
||||
continue
|
||||
}
|
||||
|
||||
// make a copy, changing the path
|
||||
ss := *s
|
||||
ss.Path = p
|
||||
ret = append(ret, &ss)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *singleton) neededScan(paths []*models.StashConfig) (total *int, newFiles *int) {
|
||||
const timeout = 90 * time.Second
|
||||
|
||||
// create a control channel through which to signal the counting loop when the timeout is reached
|
||||
@@ -91,7 +113,7 @@ func (s *singleton) neededScan() (total *int, newFiles *int) {
|
||||
|
||||
timeoutErr := errors.New("timed out")
|
||||
|
||||
for _, sp := range config.GetStashPaths() {
|
||||
for _, sp := range paths {
|
||||
err := walkFilesToScan(sp, func(path string, info os.FileInfo, err error) error {
|
||||
t++
|
||||
task := ScanTask{FilePath: path}
|
||||
@@ -128,7 +150,7 @@ func (s *singleton) neededScan() (total *int, newFiles *int) {
|
||||
return &t, &n
|
||||
}
|
||||
|
||||
func (s *singleton) Scan(useFileMetadata bool) {
|
||||
func (s *singleton) Scan(input models.ScanMetadataInput) {
|
||||
if s.Status.Status != Idle {
|
||||
return
|
||||
}
|
||||
@@ -138,7 +160,9 @@ func (s *singleton) Scan(useFileMetadata bool) {
|
||||
go func() {
|
||||
defer s.returnToIdleState()
|
||||
|
||||
total, newFiles := s.neededScan()
|
||||
paths := getScanPaths(input.Paths)
|
||||
|
||||
total, newFiles := s.neededScan(paths)
|
||||
|
||||
if s.Status.stopping {
|
||||
logger.Info("Stopping due to user request")
|
||||
@@ -162,7 +186,7 @@ func (s *singleton) Scan(useFileMetadata bool) {
|
||||
|
||||
var galleries []string
|
||||
|
||||
for _, sp := range config.GetStashPaths() {
|
||||
for _, sp := range paths {
|
||||
err := walkFilesToScan(sp, func(path string, info os.FileInfo, err error) error {
|
||||
if total != nil {
|
||||
s.Status.setProgress(i, *total)
|
||||
@@ -178,7 +202,7 @@ func (s *singleton) Scan(useFileMetadata bool) {
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
task := ScanTask{FilePath: path, UseFileMetadata: useFileMetadata, fileNamingAlgorithm: fileNamingAlgo, calculateMD5: calculateMD5}
|
||||
task := ScanTask{FilePath: path, UseFileMetadata: input.UseFileMetadata, fileNamingAlgorithm: fileNamingAlgo, calculateMD5: calculateMD5}
|
||||
go task.Start(&wg)
|
||||
wg.Wait()
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ func (t *CleanTask) shouldClean(path string) bool {
|
||||
// use image.FileExists for zip file checking
|
||||
fileExists := image.FileExists(path)
|
||||
|
||||
if !fileExists || t.getStashFromPath(path) == nil {
|
||||
if !fileExists || getStashFromPath(path) == nil {
|
||||
logger.Infof("File not found. Cleaning: \"%s\"", path)
|
||||
return true
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func (t *CleanTask) shouldCleanScene(s *models.Scene) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
stash := t.getStashFromPath(s.Path)
|
||||
stash := getStashFromPath(s.Path)
|
||||
if stash.ExcludeVideo {
|
||||
logger.Infof("File in stash library that excludes video. Cleaning: \"%s\"", s.Path)
|
||||
return true
|
||||
@@ -84,7 +84,7 @@ func (t *CleanTask) shouldCleanGallery(g *models.Gallery) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
stash := t.getStashFromPath(path)
|
||||
stash := getStashFromPath(path)
|
||||
if stash.ExcludeImage {
|
||||
logger.Infof("File in stash library that excludes images. Cleaning: \"%s\"", path)
|
||||
return true
|
||||
@@ -113,7 +113,7 @@ func (t *CleanTask) shouldCleanImage(s *models.Image) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
stash := t.getStashFromPath(s.Path)
|
||||
stash := getStashFromPath(s.Path)
|
||||
if stash.ExcludeImage {
|
||||
logger.Infof("File in stash library that excludes images. Cleaning: \"%s\"", s.Path)
|
||||
return true
|
||||
@@ -211,9 +211,8 @@ func (t *CleanTask) fileExists(filename string) (bool, error) {
|
||||
return !info.IsDir(), nil
|
||||
}
|
||||
|
||||
func (t *CleanTask) getStashFromPath(pathToCheck string) *models.StashConfig {
|
||||
func getStashFromPath(pathToCheck string) *models.StashConfig {
|
||||
for _, s := range config.GetStashPaths() {
|
||||
|
||||
rel, error := filepath.Rel(s.Path, filepath.Dir(pathToCheck))
|
||||
|
||||
if error == nil {
|
||||
@@ -225,3 +224,17 @@ func (t *CleanTask) getStashFromPath(pathToCheck string) *models.StashConfig {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getStashFromDirPath(pathToCheck string) *models.StashConfig {
|
||||
for _, s := range config.GetStashPaths() {
|
||||
rel, error := filepath.Rel(s.Path, pathToCheck)
|
||||
|
||||
if error == nil {
|
||||
if !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -114,11 +114,7 @@ func ListDir(path string) []string {
|
||||
if !file.IsDir() {
|
||||
continue
|
||||
}
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dirPaths = append(dirPaths, filepath.Join(abs, file.Name()))
|
||||
dirPaths = append(dirPaths, filepath.Join(path, file.Name()))
|
||||
}
|
||||
return dirPaths
|
||||
}
|
||||
@@ -220,11 +216,7 @@ func GetDir(path string) string {
|
||||
path = GetHomeDirectory()
|
||||
}
|
||||
|
||||
absolutePath, err := filepath.Abs(path)
|
||||
if err == nil {
|
||||
path = absolutePath
|
||||
}
|
||||
return absolutePath
|
||||
return path
|
||||
}
|
||||
|
||||
func GetParent(path string) *string {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
### ✨ New Features
|
||||
* Add selective scan.
|
||||
* Add selective export of all objects.
|
||||
* Add stash-box tagger to scenes page.
|
||||
* Add filters tab in scene page.
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Col, Form, Row } from "react-bootstrap";
|
||||
import { useConfiguration } from "src/core/StashService";
|
||||
import { Icon, Modal } from "src/components/Shared";
|
||||
import { FolderSelect } from "src/components/Shared/FolderSelect/FolderSelect";
|
||||
|
||||
interface IScanDialogProps {
|
||||
onClose: (paths?: string[]) => void;
|
||||
}
|
||||
|
||||
export const ScanDialog: React.FC<IScanDialogProps> = (
|
||||
props: IScanDialogProps
|
||||
) => {
|
||||
const { data } = useConfiguration();
|
||||
|
||||
const libraryPaths = data?.configuration.general.stashes.map((s) => s.path);
|
||||
|
||||
const [paths, setPaths] = useState<string[]>([]);
|
||||
const [currentDirectory, setCurrentDirectory] = useState<string>("");
|
||||
|
||||
function removePath(p: string) {
|
||||
setPaths(paths.filter((path) => path !== p));
|
||||
}
|
||||
|
||||
function addPath(p: string) {
|
||||
if (p && !paths.includes(p)) {
|
||||
setPaths(paths.concat(p));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
show
|
||||
disabled={paths.length === 0}
|
||||
icon="pencil-alt"
|
||||
header="Select folders to scan"
|
||||
accept={{
|
||||
onClick: () => {
|
||||
props.onClose(paths);
|
||||
},
|
||||
text: "Scan",
|
||||
}}
|
||||
cancel={{
|
||||
onClick: () => props.onClose(),
|
||||
text: "Cancel",
|
||||
variant: "secondary",
|
||||
}}
|
||||
>
|
||||
<div className="dialog-container">
|
||||
{paths.map((p) => (
|
||||
<Row className="align-items-center mb-1">
|
||||
<Form.Label column xs={10}>
|
||||
{p}
|
||||
</Form.Label>
|
||||
<Col xs={2} className="d-flex justify-content-end">
|
||||
<Button
|
||||
className="ml-auto"
|
||||
size="sm"
|
||||
variant="danger"
|
||||
title="Delete"
|
||||
onClick={() => removePath(p)}
|
||||
>
|
||||
<Icon icon="minus" />
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
|
||||
<FolderSelect
|
||||
currentDirectory={currentDirectory}
|
||||
setCurrentDirectory={(v) => setCurrentDirectory(v)}
|
||||
defaultDirectories={libraryPaths}
|
||||
appendButton={
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => addPath(currentDirectory)}
|
||||
>
|
||||
<Icon icon="plus" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -19,6 +19,7 @@ import * as GQL from "src/core/generated-graphql";
|
||||
import { Modal } from "src/components/Shared";
|
||||
import { GenerateButton } from "./GenerateButton";
|
||||
import { ImportDialog } from "./ImportDialog";
|
||||
import { ScanDialog } from "./ScanDialog";
|
||||
|
||||
type Plugin = Pick<GQL.Plugin, "id">;
|
||||
type PluginTask = Pick<GQL.PluginTask, "name" | "description">;
|
||||
@@ -28,6 +29,7 @@ export const SettingsTasksPanel: React.FC = () => {
|
||||
const [isImportAlertOpen, setIsImportAlertOpen] = useState<boolean>(false);
|
||||
const [isCleanAlertOpen, setIsCleanAlertOpen] = useState<boolean>(false);
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState<boolean>(false);
|
||||
const [isScanDialogOpen, setIsScanDialogOpen] = useState<boolean>(false);
|
||||
const [useFileMetadata, setUseFileMetadata] = useState<boolean>(false);
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
@@ -145,9 +147,28 @@ export const SettingsTasksPanel: React.FC = () => {
|
||||
return <ImportDialog onClose={() => setIsImportDialogOpen(false)} />;
|
||||
}
|
||||
|
||||
async function onScan() {
|
||||
function renderScanDialog() {
|
||||
if (!isScanDialogOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
return <ScanDialog onClose={onScanDialogClosed} />;
|
||||
}
|
||||
|
||||
function onScanDialogClosed(paths?: string[]) {
|
||||
if (paths) {
|
||||
onScan(paths);
|
||||
}
|
||||
|
||||
setIsScanDialogOpen(false);
|
||||
}
|
||||
|
||||
async function onScan(paths?: string[]) {
|
||||
try {
|
||||
await mutateMetadataScan({ useFileMetadata });
|
||||
await mutateMetadataScan({
|
||||
useFileMetadata,
|
||||
paths,
|
||||
});
|
||||
Toast.success({ content: "Started scan" });
|
||||
jobStatus.refetch();
|
||||
} catch (e) {
|
||||
@@ -267,6 +288,7 @@ export const SettingsTasksPanel: React.FC = () => {
|
||||
{renderImportAlert()}
|
||||
{renderCleanAlert()}
|
||||
{renderImportDialog()}
|
||||
{renderScanDialog()}
|
||||
|
||||
<h4>Running Jobs</h4>
|
||||
|
||||
@@ -284,9 +306,21 @@ export const SettingsTasksPanel: React.FC = () => {
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Button variant="secondary" type="submit" onClick={() => onScan()}>
|
||||
<Button
|
||||
className="mr-2"
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
onClick={() => onScan()}
|
||||
>
|
||||
Scan
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
onClick={() => setIsScanDialogOpen(true)}
|
||||
>
|
||||
Selective Scan
|
||||
</Button>
|
||||
<Form.Text className="text-muted">
|
||||
Scan for new content and add it to the database.
|
||||
</Form.Text>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
import { Button, Form, Row, Col } from "react-bootstrap";
|
||||
import { Icon } from "src/components/Shared";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { FolderSelect } from "../Shared/FolderSelect/FolderSelect";
|
||||
import { FolderSelectDialog } from "../Shared/FolderSelect/FolderSelectDialog";
|
||||
|
||||
interface IStashProps {
|
||||
index: number;
|
||||
@@ -94,7 +94,7 @@ export const StashConfiguration: React.FC<IStashConfigurationProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
return <FolderSelect onClose={handleAdd} />;
|
||||
return <FolderSelectDialog onClose={handleAdd} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,42 +1,52 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Button, InputGroup, Form, Modal } from "react-bootstrap";
|
||||
import { Button, InputGroup, Form } from "react-bootstrap";
|
||||
import { LoadingIndicator } from "src/components/Shared";
|
||||
import { useDirectory } from "src/core/StashService";
|
||||
|
||||
interface IProps {
|
||||
onClose: (directory?: string) => void;
|
||||
currentDirectory: string;
|
||||
setCurrentDirectory: (value: string) => void;
|
||||
defaultDirectories?: string[];
|
||||
appendButton?: JSX.Element;
|
||||
}
|
||||
|
||||
export const FolderSelect: React.FC<IProps> = (props: IProps) => {
|
||||
const [currentDirectory, setCurrentDirectory] = useState<string>("");
|
||||
export const FolderSelect: React.FC<IProps> = ({
|
||||
currentDirectory,
|
||||
setCurrentDirectory,
|
||||
defaultDirectories,
|
||||
appendButton,
|
||||
}) => {
|
||||
const { data, error, loading } = useDirectory(currentDirectory);
|
||||
|
||||
const selectableDirectories: string[] = currentDirectory
|
||||
? data?.directory.directories ?? defaultDirectories ?? []
|
||||
: defaultDirectories ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (currentDirectory === "" && data?.directory.path)
|
||||
if (currentDirectory === "" && !defaultDirectories && data?.directory.path)
|
||||
setCurrentDirectory(data.directory.path);
|
||||
}, [currentDirectory, data]);
|
||||
}, [currentDirectory, setCurrentDirectory, data, defaultDirectories]);
|
||||
|
||||
const selectableDirectories: string[] = data?.directory.directories ?? [];
|
||||
|
||||
const topDirectory = data?.directory?.parent ? (
|
||||
<li className="folder-list-parent folder-list-item">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() =>
|
||||
data.directory.parent && setCurrentDirectory(data.directory.parent)
|
||||
function goUp() {
|
||||
if (defaultDirectories?.includes(currentDirectory)) {
|
||||
setCurrentDirectory("");
|
||||
} else if (data?.directory.parent) {
|
||||
setCurrentDirectory(data.directory.parent);
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
const topDirectory =
|
||||
currentDirectory && data?.directory?.parent ? (
|
||||
<li className="folder-list-parent folder-list-item">
|
||||
<Button variant="link" onClick={() => goUp()}>
|
||||
<FormattedMessage defaultMessage="Up a directory" id="up-dir" />
|
||||
</Button>
|
||||
</li>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Modal show onHide={() => props.onClose()} title="">
|
||||
<Modal.Header>Select Directory</Modal.Header>
|
||||
<Modal.Body>
|
||||
<div className="dialog-content">
|
||||
<>
|
||||
{error ? <h1>{error.message}</h1> : ""}
|
||||
<InputGroup>
|
||||
<Form.Control
|
||||
@@ -47,39 +57,27 @@ export const FolderSelect: React.FC<IProps> = (props: IProps) => {
|
||||
value={currentDirectory}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<InputGroup.Append>
|
||||
{appendButton ? (
|
||||
<InputGroup.Append>{appendButton}</InputGroup.Append>
|
||||
) : undefined}
|
||||
{!data || !data.directory || loading ? (
|
||||
<LoadingIndicator inline />
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<InputGroup.Append>
|
||||
<LoadingIndicator inline small message="" />
|
||||
</InputGroup.Append>
|
||||
) : undefined}
|
||||
</InputGroup>
|
||||
<ul className="folder-list">
|
||||
{topDirectory}
|
||||
{selectableDirectories.map((path) => {
|
||||
return (
|
||||
<li key={path} className="folder-list-item">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => setCurrentDirectory(path)}
|
||||
>
|
||||
<Button variant="link" onClick={() => setCurrentDirectory(path)}>
|
||||
{path}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="success"
|
||||
onClick={() => props.onClose(currentDirectory)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Modal } from "react-bootstrap";
|
||||
import { FolderSelect } from "./FolderSelect";
|
||||
|
||||
interface IProps {
|
||||
onClose: (directory?: string) => void;
|
||||
}
|
||||
|
||||
export const FolderSelectDialog: React.FC<IProps> = (props: IProps) => {
|
||||
const [currentDirectory, setCurrentDirectory] = useState<string>("");
|
||||
|
||||
return (
|
||||
<Modal show onHide={() => props.onClose()} title="">
|
||||
<Modal.Header>Select Directory</Modal.Header>
|
||||
<Modal.Body>
|
||||
<div className="dialog-content">
|
||||
<FolderSelect
|
||||
currentDirectory={currentDirectory}
|
||||
setCurrentDirectory={(v) => setCurrentDirectory(v)}
|
||||
/>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="success"
|
||||
onClick={() => props.onClose(currentDirectory)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -16,7 +16,8 @@
|
||||
}
|
||||
|
||||
&.inline {
|
||||
display: inline;
|
||||
display: inline-block;
|
||||
height: auto;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user