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:
WithoutPants
2021-04-20 18:58:28 +10:00
committed by GitHub
parent 39512e1452
commit 8705f78591
12 changed files with 566 additions and 344 deletions

View File

@@ -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,
}

View File

@@ -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
}

View File

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

View File

@@ -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`.

View File

@@ -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 &ldquo;Exact&rdquo; 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}
/>
&nbsp;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>
);
};

View File

@@ -0,0 +1,13 @@
#scene-duplicate-checker {
.scene-path {
font-size: 0.88em;
}
.filter-container {
margin: 0;
}
.separator {
height: 50px;
}
}

View File

@@ -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>

View File

@@ -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 &ldquo;Exact&rdquo; 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}
/>
&nbsp;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>
);
};

View File

@@ -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>

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

View File

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

View File

@@ -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";