mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 12:54:38 +03:00
Duplicate checker UI improvements (#1309)
* Add tools settings page * Add tools and move dupe checker * Make negative number get all * Show missing phashes * Add multi-edit button * Show scene details
This commit is contained in:
@@ -76,7 +76,7 @@ func (t *AutoTagTask) getQueryFilter(regex string) *models.SceneFilterType {
|
||||
}
|
||||
|
||||
func (t *AutoTagTask) getFindFilter() *models.FindFilterType {
|
||||
perPage := 0
|
||||
perPage := -1
|
||||
return &models.FindFilterType{
|
||||
PerPage: &perPage,
|
||||
}
|
||||
|
||||
@@ -35,17 +35,19 @@ func (ff FindFilterType) GetPage() int {
|
||||
|
||||
func (ff FindFilterType) GetPageSize() int {
|
||||
const defaultPerPage = 25
|
||||
const minPerPage = 1
|
||||
const minPerPage = 0
|
||||
const maxPerPage = 1000
|
||||
|
||||
if ff.PerPage == nil {
|
||||
return defaultPerPage
|
||||
}
|
||||
|
||||
if *ff.PerPage > 1000 {
|
||||
if *ff.PerPage > maxPerPage {
|
||||
return maxPerPage
|
||||
} else if *ff.PerPage < 0 {
|
||||
// PerPage == 0 -> no limit
|
||||
} else if *ff.PerPage < minPerPage {
|
||||
// negative page sizes should return all results
|
||||
// this is a sanity check in case GetPageSize is
|
||||
// called with a negative page size.
|
||||
return minPerPage
|
||||
}
|
||||
|
||||
@@ -53,5 +55,5 @@ func (ff FindFilterType) GetPageSize() int {
|
||||
}
|
||||
|
||||
func (ff FindFilterType) IsGetAll() bool {
|
||||
return ff.PerPage != nil && *ff.PerPage == 0
|
||||
return ff.PerPage != nil && *ff.PerPage < 0
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Settings } from "./components/Settings/Settings";
|
||||
import { Stats } from "./components/Stats";
|
||||
import Studios from "./components/Studios/Studios";
|
||||
import { SceneFilenameParser } from "./components/SceneFilenameParser/SceneFilenameParser";
|
||||
import { SceneDuplicateChecker } from "./components/SceneDuplicateChecker/SceneDuplicateChecker";
|
||||
import Movies from "./components/Movies/Movies";
|
||||
import Tags from "./components/Tags/Tags";
|
||||
import Images from "./components/Images/Images";
|
||||
@@ -103,6 +104,10 @@ export const App: React.FC = () => {
|
||||
<Route path="/movies" component={Movies} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/sceneFilenameParser" component={SceneFilenameParser} />
|
||||
<Route
|
||||
path="/sceneDuplicateChecker"
|
||||
component={SceneDuplicateChecker}
|
||||
/>
|
||||
<Route path="/setup" component={Setup} />
|
||||
<Route path="/migrate" component={Migrate} />
|
||||
<Route component={PageNotFound} />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Support serving UI from specific directory location.
|
||||
* Added details, death date, hair color, and weight to Performers.
|
||||
* Added details to Studios.
|
||||
* Added [perceptual dupe checker](/settings?tab=duplicates).
|
||||
* Added [perceptual dupe checker](/sceneDuplicateChecker).
|
||||
* Add various `count` filter criteria and sort options.
|
||||
* Add URL filter criteria for scenes, galleries, movies, performers and studios.
|
||||
* Add HTTP endpoint for health checking at `/healthz`.
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
OverlayTrigger,
|
||||
Row,
|
||||
Table,
|
||||
Tooltip,
|
||||
} from "react-bootstrap";
|
||||
import { Link, useHistory } from "react-router-dom";
|
||||
import { FormattedNumber } from "react-intl";
|
||||
import querystring from "query-string";
|
||||
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import {
|
||||
LoadingIndicator,
|
||||
ErrorMessage,
|
||||
HoverPopover,
|
||||
Icon,
|
||||
TagLink,
|
||||
SweatDrops,
|
||||
} from "src/components/Shared";
|
||||
import { Pagination } from "src/components/List/Pagination";
|
||||
import { TextUtils } from "src/utils";
|
||||
import { DeleteScenesDialog } from "src/components/Scenes/DeleteScenesDialog";
|
||||
import { EditScenesDialog } from "../Scenes/EditScenesDialog";
|
||||
import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton";
|
||||
|
||||
const CLASSNAME = "duplicate-checker";
|
||||
|
||||
export const SceneDuplicateChecker: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { page, size, distance } = querystring.parse(history.location.search);
|
||||
const currentPage = Number.parseInt(
|
||||
Array.isArray(page) ? page[0] : page ?? "1",
|
||||
10
|
||||
);
|
||||
const pageSize = Number.parseInt(
|
||||
Array.isArray(size) ? size[0] : size ?? "20",
|
||||
10
|
||||
);
|
||||
const hashDistance = Number.parseInt(
|
||||
Array.isArray(distance) ? distance[0] : distance ?? "0",
|
||||
10
|
||||
);
|
||||
const [isMultiDelete, setIsMultiDelete] = useState(false);
|
||||
const [deletingScenes, setDeletingScenes] = useState(false);
|
||||
const [editingScenes, setEditingScenes] = useState(false);
|
||||
const [checkedScenes, setCheckedScenes] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
);
|
||||
const { data, loading, refetch } = GQL.useFindDuplicateScenesQuery({
|
||||
fetchPolicy: "no-cache",
|
||||
variables: { distance: hashDistance },
|
||||
});
|
||||
const { data: missingPhash } = GQL.useFindScenesQuery({
|
||||
variables: {
|
||||
filter: {
|
||||
per_page: 0,
|
||||
},
|
||||
scene_filter: {
|
||||
is_missing: "phash",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [selectedScenes, setSelectedScenes] = useState<
|
||||
GQL.SlimSceneDataFragment[] | null
|
||||
>(null);
|
||||
|
||||
if (loading) return <LoadingIndicator />;
|
||||
if (!data) return <ErrorMessage error="Error searching for duplicates." />;
|
||||
|
||||
const scenes = data?.findDuplicateScenes ?? [];
|
||||
const filteredScenes = scenes.slice(
|
||||
(currentPage - 1) * pageSize,
|
||||
currentPage * pageSize
|
||||
);
|
||||
const checkCount = Object.keys(checkedScenes).filter(
|
||||
(id) => checkedScenes[id]
|
||||
).length;
|
||||
|
||||
const setQuery = (q: Record<string, string | number | undefined>) => {
|
||||
history.push({
|
||||
search: querystring.stringify({
|
||||
...querystring.parse(history.location.search),
|
||||
...q,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
function onDeleteDialogClosed(deleted: boolean) {
|
||||
setDeletingScenes(false);
|
||||
if (deleted) {
|
||||
setSelectedScenes(null);
|
||||
refetch();
|
||||
if (isMultiDelete) setCheckedScenes({});
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheck = (checked: boolean, sceneID: string) => {
|
||||
setCheckedScenes({ ...checkedScenes, [sceneID]: checked });
|
||||
};
|
||||
|
||||
const handleDeleteChecked = () => {
|
||||
setSelectedScenes(scenes.flat().filter((s) => checkedScenes[s.id]));
|
||||
setDeletingScenes(true);
|
||||
setIsMultiDelete(true);
|
||||
};
|
||||
|
||||
const handleDeleteScene = (scene: GQL.SlimSceneDataFragment) => {
|
||||
setSelectedScenes([scene]);
|
||||
setDeletingScenes(true);
|
||||
setIsMultiDelete(false);
|
||||
};
|
||||
|
||||
function onEdit() {
|
||||
setSelectedScenes(scenes.flat().filter((s) => checkedScenes[s.id]));
|
||||
setEditingScenes(true);
|
||||
}
|
||||
|
||||
const renderFilesize = (filesize: string | null | undefined) => {
|
||||
const { size: parsedSize, unit } = TextUtils.fileSize(
|
||||
Number.parseInt(filesize ?? "0", 10)
|
||||
);
|
||||
return (
|
||||
<FormattedNumber
|
||||
value={parsedSize}
|
||||
style="unit"
|
||||
unit={unit}
|
||||
unitDisplay="narrow"
|
||||
maximumFractionDigits={2}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function maybeRenderMissingPhashWarning() {
|
||||
const missingPhashes = missingPhash?.findScenes.count ?? 0;
|
||||
if (missingPhashes > 0) {
|
||||
return (
|
||||
<p className="lead">
|
||||
<Icon icon="exclamation-triangle" className="text-warning" />
|
||||
Missing phashes for {missingPhashes} scenes. Please run the phash
|
||||
generation task.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderEdit() {
|
||||
if (editingScenes && selectedScenes) {
|
||||
return (
|
||||
<EditScenesDialog
|
||||
selected={selectedScenes}
|
||||
onClose={() => setEditingScenes(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderTagPopoverButton(scene: GQL.SlimSceneDataFragment) {
|
||||
if (scene.tags.length <= 0) return;
|
||||
|
||||
const popoverContent = scene.tags.map((tag) => (
|
||||
<TagLink key={tag.id} tag={tag} />
|
||||
));
|
||||
|
||||
return (
|
||||
<HoverPopover placement="bottom" content={popoverContent}>
|
||||
<Button className="minimal">
|
||||
<Icon icon="tag" />
|
||||
<span>{scene.tags.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderPerformerPopoverButton(scene: GQL.SlimSceneDataFragment) {
|
||||
if (scene.performers.length <= 0) return;
|
||||
|
||||
return <PerformerPopoverButton performers={scene.performers} />;
|
||||
}
|
||||
|
||||
function maybeRenderMoviePopoverButton(scene: GQL.SlimSceneDataFragment) {
|
||||
if (scene.movies.length <= 0) return;
|
||||
|
||||
const popoverContent = scene.movies.map((sceneMovie) => (
|
||||
<div className="movie-tag-container row" key="movie">
|
||||
<Link
|
||||
to={`/movies/${sceneMovie.movie.id}`}
|
||||
className="movie-tag col m-auto zoom-2"
|
||||
>
|
||||
<img
|
||||
className="image-thumbnail"
|
||||
alt={sceneMovie.movie.name ?? ""}
|
||||
src={sceneMovie.movie.front_image_path ?? ""}
|
||||
/>
|
||||
</Link>
|
||||
<TagLink
|
||||
key={sceneMovie.movie.id}
|
||||
movie={sceneMovie.movie}
|
||||
className="d-block"
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
placement="bottom"
|
||||
content={popoverContent}
|
||||
className="tag-tooltip"
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon="film" />
|
||||
<span>{scene.movies.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderSceneMarkerPopoverButton(
|
||||
scene: GQL.SlimSceneDataFragment
|
||||
) {
|
||||
if (scene.scene_markers.length <= 0) return;
|
||||
|
||||
const popoverContent = scene.scene_markers.map((marker) => {
|
||||
const markerPopover = { ...marker, scene: { id: scene.id } };
|
||||
return <TagLink key={marker.id} marker={markerPopover} />;
|
||||
});
|
||||
|
||||
return (
|
||||
<HoverPopover placement="bottom" content={popoverContent}>
|
||||
<Button className="minimal">
|
||||
<Icon icon="map-marker-alt" />
|
||||
<span>{scene.scene_markers.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderOCounter(scene: GQL.SlimSceneDataFragment) {
|
||||
if (scene.o_counter) {
|
||||
return (
|
||||
<div>
|
||||
<Button className="minimal">
|
||||
<span className="fa-icon">
|
||||
<SweatDrops />
|
||||
</span>
|
||||
<span>{scene.o_counter}</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderGallery(scene: GQL.SlimSceneDataFragment) {
|
||||
if (scene.galleries.length <= 0) return;
|
||||
|
||||
const popoverContent = scene.galleries.map((gallery) => (
|
||||
<TagLink key={gallery.id} gallery={gallery} />
|
||||
));
|
||||
|
||||
return (
|
||||
<HoverPopover placement="bottom" content={popoverContent}>
|
||||
<Button className="minimal">
|
||||
<Icon icon="images" />
|
||||
<span>{scene.galleries.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderOrganized(scene: GQL.SlimSceneDataFragment) {
|
||||
if (scene.organized) {
|
||||
return (
|
||||
<div>
|
||||
<Button className="minimal">
|
||||
<Icon icon="box" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderPopoverButtonGroup(scene: GQL.SlimSceneDataFragment) {
|
||||
if (
|
||||
scene.tags.length > 0 ||
|
||||
scene.performers.length > 0 ||
|
||||
scene.movies.length > 0 ||
|
||||
scene.scene_markers.length > 0 ||
|
||||
scene?.o_counter ||
|
||||
scene.galleries.length > 0 ||
|
||||
scene.organized
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<ButtonGroup className="flex-wrap">
|
||||
{maybeRenderTagPopoverButton(scene)}
|
||||
{maybeRenderPerformerPopoverButton(scene)}
|
||||
{maybeRenderMoviePopoverButton(scene)}
|
||||
{maybeRenderSceneMarkerPopoverButton(scene)}
|
||||
{maybeRenderOCounter(scene)}
|
||||
{maybeRenderGallery(scene)}
|
||||
{maybeRenderOrganized(scene)}
|
||||
</ButtonGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card id="scene-duplicate-checker" className="col col-xl-10 mx-auto">
|
||||
<div className={CLASSNAME}>
|
||||
{deletingScenes && selectedScenes && (
|
||||
<DeleteScenesDialog
|
||||
selected={selectedScenes}
|
||||
onClose={onDeleteDialogClosed}
|
||||
/>
|
||||
)}
|
||||
{maybeRenderEdit()}
|
||||
<h4>Duplicate Scenes</h4>
|
||||
<Form.Group>
|
||||
<Row noGutters>
|
||||
<Form.Label>Search Accuracy</Form.Label>
|
||||
<Col xs={2}>
|
||||
<Form.Control
|
||||
as="select"
|
||||
onChange={(e) =>
|
||||
setQuery({
|
||||
distance:
|
||||
e.currentTarget.value === "0"
|
||||
? undefined
|
||||
: e.currentTarget.value,
|
||||
page: undefined,
|
||||
})
|
||||
}
|
||||
defaultValue={distance ?? 0}
|
||||
className="input-control ml-4"
|
||||
>
|
||||
<option value={0}>Exact</option>
|
||||
<option value={4}>High</option>
|
||||
<option value={8}>Medium</option>
|
||||
<option value={10}>Low</option>
|
||||
</Form.Control>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Text>
|
||||
Levels below “Exact” can take longer to calculate. False
|
||||
positives might also be returned on lower accuracy levels.
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
{maybeRenderMissingPhashWarning()}
|
||||
<div className="d-flex mb-2">
|
||||
<h6 className="mr-auto align-self-center">
|
||||
{scenes.length} sets of duplicates found.
|
||||
</h6>
|
||||
{checkCount > 0 && (
|
||||
<ButtonGroup>
|
||||
<OverlayTrigger overlay={<Tooltip id="edit">Edit</Tooltip>}>
|
||||
<Button variant="secondary" onClick={onEdit}>
|
||||
<Icon icon="pencil-alt" />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
<OverlayTrigger overlay={<Tooltip id="delete">Delete</Tooltip>}>
|
||||
<Button variant="danger" onClick={handleDeleteChecked}>
|
||||
<Icon icon="trash" />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
<Pagination
|
||||
itemsPerPage={pageSize}
|
||||
currentPage={currentPage}
|
||||
totalItems={scenes.length}
|
||||
onChangePage={(newPage) =>
|
||||
setQuery({ page: newPage === 1 ? undefined : newPage })
|
||||
}
|
||||
/>
|
||||
<Form.Control
|
||||
as="select"
|
||||
className="w-auto ml-2 btn-secondary"
|
||||
defaultValue={pageSize}
|
||||
onChange={(e) =>
|
||||
setQuery({
|
||||
size:
|
||||
e.currentTarget.value === "20"
|
||||
? undefined
|
||||
: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={40}>40</option>
|
||||
<option value={60}>60</option>
|
||||
<option value={80}>80</option>
|
||||
</Form.Control>
|
||||
</div>
|
||||
<Table responsive striped className={`${CLASSNAME}-table`}>
|
||||
<colgroup>
|
||||
<col className={`${CLASSNAME}-checkbox`} />
|
||||
<col className={`${CLASSNAME}-sprite`} />
|
||||
<col className={`${CLASSNAME}-title`} />
|
||||
<col className={`${CLASSNAME}-details`} />
|
||||
<col className={`${CLASSNAME}-duration`} />
|
||||
<col className={`${CLASSNAME}-filesize`} />
|
||||
<col className={`${CLASSNAME}-resolution`} />
|
||||
<col className={`${CLASSNAME}-bitrate`} />
|
||||
<col className={`${CLASSNAME}-codec`} />
|
||||
<col className={`${CLASSNAME}-operations`} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th> </th>
|
||||
<th>Details</th>
|
||||
<th> </th>
|
||||
<th>Duration</th>
|
||||
<th>Filesize</th>
|
||||
<th>Resolution</th>
|
||||
<th>Bitrate</th>
|
||||
<th>Codec</th>
|
||||
<th>Delete</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredScenes.map((group, groupIndex) =>
|
||||
group.map((scene, i) => (
|
||||
<>
|
||||
{i === 0 && groupIndex !== 0 ? (
|
||||
<tr className="separator" />
|
||||
) : undefined}
|
||||
<tr
|
||||
className={i === 0 ? "duplicate-group" : ""}
|
||||
key={scene.id}
|
||||
>
|
||||
<td>
|
||||
<Form.Check
|
||||
onChange={(e) =>
|
||||
handleCheck(e.currentTarget.checked, scene.id)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<HoverPopover
|
||||
content={
|
||||
<img
|
||||
src={scene.paths.sprite ?? ""}
|
||||
alt=""
|
||||
width={600}
|
||||
/>
|
||||
}
|
||||
placement="right"
|
||||
>
|
||||
<img
|
||||
src={scene.paths.sprite ?? ""}
|
||||
alt=""
|
||||
width={100}
|
||||
/>
|
||||
</HoverPopover>
|
||||
</td>
|
||||
<td className="text-left">
|
||||
<p>
|
||||
<Link to={`/scenes/${scene.id}`}>
|
||||
{scene.title ??
|
||||
TextUtils.fileNameFromPath(scene.path)}
|
||||
</Link>
|
||||
</p>
|
||||
<p className="scene-path">{scene.path}</p>
|
||||
</td>
|
||||
<td className="scene-details">
|
||||
{maybeRenderPopoverButtonGroup(scene)}
|
||||
</td>
|
||||
<td>
|
||||
{scene.file.duration &&
|
||||
TextUtils.secondsToTimestamp(scene.file.duration)}
|
||||
</td>
|
||||
<td>{renderFilesize(scene.file.size)}</td>
|
||||
<td>{`${scene.file.width}x${scene.file.height}`}</td>
|
||||
<td>
|
||||
<FormattedNumber
|
||||
value={(scene.file.bitrate ?? 0) / 1000000}
|
||||
maximumFractionDigits={2}
|
||||
/>
|
||||
mbps
|
||||
</td>
|
||||
<td>{scene.file.video_codec}</td>
|
||||
<td>
|
||||
<Button
|
||||
className="edit-button"
|
||||
variant="danger"
|
||||
onClick={() => handleDeleteScene(scene)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
{scenes.length === 0 && (
|
||||
<h4 className="text-center mt-4">No duplicates found.</h4>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
13
ui/v2.5/src/components/SceneDuplicateChecker/styles.scss
Normal file
13
ui/v2.5/src/components/SceneDuplicateChecker/styles.scss
Normal file
@@ -0,0 +1,13 @@
|
||||
#scene-duplicate-checker {
|
||||
.scene-path {
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.separator {
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { SettingsLogsPanel } from "./SettingsLogsPanel";
|
||||
import { SettingsTasksPanel } from "./SettingsTasksPanel/SettingsTasksPanel";
|
||||
import { SettingsPluginsPanel } from "./SettingsPluginsPanel";
|
||||
import { SettingsScrapersPanel } from "./SettingsScrapersPanel";
|
||||
import { SettingsDuplicatePanel } from "./SettingsDuplicatePanel";
|
||||
import { SettingsToolsPanel } from "./SettingsToolsPanel";
|
||||
|
||||
export const Settings: React.FC = () => {
|
||||
const location = useLocation();
|
||||
@@ -37,6 +37,9 @@ export const Settings: React.FC = () => {
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="tasks">Tasks</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="tools">Tools</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="scrapers">Scrapers</Nav.Link>
|
||||
</Nav.Item>
|
||||
@@ -46,9 +49,6 @@ export const Settings: React.FC = () => {
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="logs">Logs</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="duplicates">Dupe Checker</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="about">About</Nav.Link>
|
||||
</Nav.Item>
|
||||
@@ -66,6 +66,9 @@ export const Settings: React.FC = () => {
|
||||
<Tab.Pane eventKey="tasks">
|
||||
<SettingsTasksPanel />
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="tools" unmountOnExit>
|
||||
<SettingsToolsPanel />
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="scrapers" unmountOnExit>
|
||||
<SettingsScrapersPanel />
|
||||
</Tab.Pane>
|
||||
@@ -75,9 +78,6 @@ export const Settings: React.FC = () => {
|
||||
<Tab.Pane eventKey="logs" unmountOnExit>
|
||||
<SettingsLogsPanel />
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="duplicates" unmountOnExit>
|
||||
<SettingsDuplicatePanel />
|
||||
</Tab.Pane>
|
||||
<Tab.Pane eventKey="about" unmountOnExit>
|
||||
<SettingsAboutPanel />
|
||||
</Tab.Pane>
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Col, Form, Row, Table } from "react-bootstrap";
|
||||
import { Link, useHistory } from "react-router-dom";
|
||||
import { FormattedNumber } from "react-intl";
|
||||
import querystring from "query-string";
|
||||
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import {
|
||||
LoadingIndicator,
|
||||
ErrorMessage,
|
||||
HoverPopover,
|
||||
} from "src/components/Shared";
|
||||
import { Pagination } from "src/components/List/Pagination";
|
||||
import { TextUtils } from "src/utils";
|
||||
import { DeleteScenesDialog } from "src/components/Scenes/DeleteScenesDialog";
|
||||
|
||||
const CLASSNAME = "DuplicateChecker";
|
||||
|
||||
export const SettingsDuplicatePanel: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { page, size, distance } = querystring.parse(history.location.search);
|
||||
const currentPage = Number.parseInt(
|
||||
Array.isArray(page) ? page[0] : page ?? "1",
|
||||
10
|
||||
);
|
||||
const pageSize = Number.parseInt(
|
||||
Array.isArray(size) ? size[0] : size ?? "20",
|
||||
10
|
||||
);
|
||||
const hashDistance = Number.parseInt(
|
||||
Array.isArray(distance) ? distance[0] : distance ?? "0",
|
||||
10
|
||||
);
|
||||
const [isMultiDelete, setIsMultiDelete] = useState(false);
|
||||
const [checkedScenes, setCheckedScenes] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
);
|
||||
const { data, loading, refetch } = GQL.useFindDuplicateScenesQuery({
|
||||
fetchPolicy: "no-cache",
|
||||
variables: { distance: hashDistance },
|
||||
});
|
||||
const [deletingScene, setDeletingScene] = useState<
|
||||
GQL.SlimSceneDataFragment[] | null
|
||||
>(null);
|
||||
|
||||
if (loading) return <LoadingIndicator />;
|
||||
if (!data) return <ErrorMessage error="Error searching for duplicates." />;
|
||||
|
||||
const scenes = data?.findDuplicateScenes ?? [];
|
||||
const filteredScenes = scenes.slice(
|
||||
(currentPage - 1) * pageSize,
|
||||
currentPage * pageSize
|
||||
);
|
||||
const checkCount = Object.keys(checkedScenes).filter(
|
||||
(id) => checkedScenes[id]
|
||||
).length;
|
||||
|
||||
const setQuery = (q: Record<string, string | number | undefined>) => {
|
||||
history.push({
|
||||
search: querystring.stringify({
|
||||
...querystring.parse(history.location.search),
|
||||
...q,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
function onDeleteDialogClosed(deleted: boolean) {
|
||||
setDeletingScene(null);
|
||||
if (deleted) {
|
||||
refetch();
|
||||
if (isMultiDelete) setCheckedScenes({});
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheck = (checked: boolean, sceneID: string) => {
|
||||
setCheckedScenes({ ...checkedScenes, [sceneID]: checked });
|
||||
};
|
||||
|
||||
const handleDeleteChecked = () => {
|
||||
setDeletingScene(scenes.flat().filter((s) => checkedScenes[s.id]));
|
||||
setIsMultiDelete(true);
|
||||
};
|
||||
|
||||
const handleDeleteScene = (scene: GQL.SlimSceneDataFragment) => {
|
||||
setDeletingScene([scene]);
|
||||
setIsMultiDelete(false);
|
||||
};
|
||||
|
||||
const renderFilesize = (filesize: string | null | undefined) => {
|
||||
const { size: parsedSize, unit } = TextUtils.fileSize(
|
||||
Number.parseInt(filesize ?? "0", 10)
|
||||
);
|
||||
return (
|
||||
<FormattedNumber
|
||||
value={parsedSize}
|
||||
style="unit"
|
||||
unit={unit}
|
||||
unitDisplay="narrow"
|
||||
maximumFractionDigits={2}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={CLASSNAME}>
|
||||
{deletingScene && (
|
||||
<DeleteScenesDialog
|
||||
selected={deletingScene}
|
||||
onClose={onDeleteDialogClosed}
|
||||
/>
|
||||
)}
|
||||
<h4>Duplicate Scenes</h4>
|
||||
<Form.Group>
|
||||
<Row noGutters>
|
||||
<Form.Label>Search Accuracy</Form.Label>
|
||||
<Col xs={2}>
|
||||
<Form.Control
|
||||
as="select"
|
||||
onChange={(e) =>
|
||||
setQuery({
|
||||
distance:
|
||||
e.currentTarget.value === "0"
|
||||
? undefined
|
||||
: e.currentTarget.value,
|
||||
page: undefined,
|
||||
})
|
||||
}
|
||||
defaultValue={distance ?? 0}
|
||||
className="ml-4"
|
||||
>
|
||||
<option value={0}>Exact</option>
|
||||
<option value={4}>High</option>
|
||||
<option value={8}>Medium</option>
|
||||
<option value={10}>Low</option>
|
||||
</Form.Control>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Text>
|
||||
Levels below “Exact” can take longer to calculate. False
|
||||
positives might also be returned on lower accuracy levels.
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<div className="d-flex mb-2">
|
||||
<h6 className="mr-auto align-self-center">
|
||||
{scenes.length} sets of duplicates found.
|
||||
</h6>
|
||||
{checkCount > 0 && (
|
||||
<Button
|
||||
className="edit-button"
|
||||
variant="danger"
|
||||
onClick={handleDeleteChecked}
|
||||
>
|
||||
Delete {checkCount} scene{checkCount > 1 && "s"}
|
||||
</Button>
|
||||
)}
|
||||
<Pagination
|
||||
itemsPerPage={pageSize}
|
||||
currentPage={currentPage}
|
||||
totalItems={scenes.length}
|
||||
onChangePage={(newPage) =>
|
||||
setQuery({ page: newPage === 1 ? undefined : newPage })
|
||||
}
|
||||
/>
|
||||
<Form.Control
|
||||
as="select"
|
||||
className="w-auto ml-2 btn-secondary"
|
||||
defaultValue={pageSize}
|
||||
onChange={(e) =>
|
||||
setQuery({
|
||||
size:
|
||||
e.currentTarget.value === "20"
|
||||
? undefined
|
||||
: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={40}>40</option>
|
||||
<option value={60}>60</option>
|
||||
<option value={80}>80</option>
|
||||
</Form.Control>
|
||||
</div>
|
||||
<Table striped className={`${CLASSNAME}-table`}>
|
||||
<colgroup>
|
||||
<col className={`${CLASSNAME}-checkbox`} />
|
||||
<col className={`${CLASSNAME}-sprite`} />
|
||||
<col className={`${CLASSNAME}-title`} />
|
||||
<col className={`${CLASSNAME}-duration`} />
|
||||
<col className={`${CLASSNAME}-filesize`} />
|
||||
<col className={`${CLASSNAME}-resolution`} />
|
||||
<col className={`${CLASSNAME}-bitrate`} />
|
||||
<col className={`${CLASSNAME}-codec`} />
|
||||
<col className={`${CLASSNAME}-operations`} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th> </th>
|
||||
<th>Title</th>
|
||||
<th>Duration</th>
|
||||
<th>Filesize</th>
|
||||
<th>Resolution</th>
|
||||
<th>Bitrate</th>
|
||||
<th>Codec</th>
|
||||
<th>Delete</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredScenes.map((group) =>
|
||||
group.map((scene, i) => (
|
||||
<tr className={i === 0 ? "duplicate-group" : ""} key={scene.id}>
|
||||
<td>
|
||||
<Form.Check
|
||||
onChange={(e) =>
|
||||
handleCheck(e.currentTarget.checked, scene.id)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<HoverPopover
|
||||
content={
|
||||
<img src={scene.paths.sprite ?? ""} alt="" width={600} />
|
||||
}
|
||||
placement="right"
|
||||
>
|
||||
<img src={scene.paths.sprite ?? ""} alt="" width={100} />
|
||||
</HoverPopover>
|
||||
</td>
|
||||
<td className="text-left">
|
||||
<Link to={`/scenes/${scene.id}`}>
|
||||
{scene.title ?? TextUtils.fileNameFromPath(scene.path)}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
{scene.file.duration &&
|
||||
TextUtils.secondsToTimestamp(scene.file.duration)}
|
||||
</td>
|
||||
<td>{renderFilesize(scene.file.size)}</td>
|
||||
<td>{`${scene.file.width}x${scene.file.height}`}</td>
|
||||
<td>
|
||||
<FormattedNumber
|
||||
value={(scene.file.bitrate ?? 0) / 1000000}
|
||||
maximumFractionDigits={2}
|
||||
/>
|
||||
mbps
|
||||
</td>
|
||||
<td>{scene.file.video_codec}</td>
|
||||
<td>
|
||||
<Button
|
||||
className="edit-button"
|
||||
variant="danger"
|
||||
onClick={() => handleDeleteScene(scene)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
{scenes.length === 0 && (
|
||||
<h4 className="text-center mt-4">
|
||||
No duplicates found. Make sure the phash task has been run.
|
||||
</h4>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button, Form, ProgressBar } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
useJobStatus,
|
||||
useMetadataUpdate,
|
||||
@@ -495,12 +494,6 @@ export const SettingsTasksPanel: React.FC = () => {
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group>
|
||||
<Link to="/sceneFilenameParser">
|
||||
<Button variant="secondary">Scene Filename Parser</Button>
|
||||
</Link>
|
||||
</Form.Group>
|
||||
|
||||
<hr />
|
||||
|
||||
<h5>Generated Content</h5>
|
||||
|
||||
19
ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx
Normal file
19
ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export const SettingsToolsPanel: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<h4>Scene Tools</h4>
|
||||
|
||||
<Form.Group>
|
||||
<Link to="/sceneFilenameParser">Scene Filename Parser</Link>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group>
|
||||
<Link to="/sceneDuplicateChecker">Scene Duplicate Checker</Link>
|
||||
</Form.Group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -70,56 +70,3 @@
|
||||
list-style: none;
|
||||
}
|
||||
}
|
||||
|
||||
.DuplicateChecker {
|
||||
min-width: 768px;
|
||||
|
||||
.filter-container {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.duplicate-group {
|
||||
border-top: 50px solid #30404d;
|
||||
|
||||
&:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
&-table {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&-checkbox {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
&-sprite {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
&-duration {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
&-filesize {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
&-resolution {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
&-bitrate {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
&-codec {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
&-operations {
|
||||
width: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
@import "src/components/Movies/styles.scss";
|
||||
@import "src/components/Performers/styles.scss";
|
||||
@import "src/components/Scenes/styles.scss";
|
||||
@import "src/components/SceneDuplicateChecker/styles.scss";
|
||||
@import "src/components/SceneFilenameParser/styles.scss";
|
||||
@import "src/components/ScenePlayer/styles.scss";
|
||||
@import "src/components/Settings/styles.scss";
|
||||
|
||||
Reference in New Issue
Block a user