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 {
|
func (t *AutoTagTask) getFindFilter() *models.FindFilterType {
|
||||||
perPage := 0
|
perPage := -1
|
||||||
return &models.FindFilterType{
|
return &models.FindFilterType{
|
||||||
PerPage: &perPage,
|
PerPage: &perPage,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,17 +35,19 @@ func (ff FindFilterType) GetPage() int {
|
|||||||
|
|
||||||
func (ff FindFilterType) GetPageSize() int {
|
func (ff FindFilterType) GetPageSize() int {
|
||||||
const defaultPerPage = 25
|
const defaultPerPage = 25
|
||||||
const minPerPage = 1
|
const minPerPage = 0
|
||||||
const maxPerPage = 1000
|
const maxPerPage = 1000
|
||||||
|
|
||||||
if ff.PerPage == nil {
|
if ff.PerPage == nil {
|
||||||
return defaultPerPage
|
return defaultPerPage
|
||||||
}
|
}
|
||||||
|
|
||||||
if *ff.PerPage > 1000 {
|
if *ff.PerPage > maxPerPage {
|
||||||
return maxPerPage
|
return maxPerPage
|
||||||
} else if *ff.PerPage < 0 {
|
} else if *ff.PerPage < minPerPage {
|
||||||
// PerPage == 0 -> no limit
|
// negative page sizes should return all results
|
||||||
|
// this is a sanity check in case GetPageSize is
|
||||||
|
// called with a negative page size.
|
||||||
return minPerPage
|
return minPerPage
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,5 +55,5 @@ func (ff FindFilterType) GetPageSize() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ff FindFilterType) IsGetAll() bool {
|
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 { Stats } from "./components/Stats";
|
||||||
import Studios from "./components/Studios/Studios";
|
import Studios from "./components/Studios/Studios";
|
||||||
import { SceneFilenameParser } from "./components/SceneFilenameParser/SceneFilenameParser";
|
import { SceneFilenameParser } from "./components/SceneFilenameParser/SceneFilenameParser";
|
||||||
|
import { SceneDuplicateChecker } from "./components/SceneDuplicateChecker/SceneDuplicateChecker";
|
||||||
import Movies from "./components/Movies/Movies";
|
import Movies from "./components/Movies/Movies";
|
||||||
import Tags from "./components/Tags/Tags";
|
import Tags from "./components/Tags/Tags";
|
||||||
import Images from "./components/Images/Images";
|
import Images from "./components/Images/Images";
|
||||||
@@ -103,6 +104,10 @@ export const App: React.FC = () => {
|
|||||||
<Route path="/movies" component={Movies} />
|
<Route path="/movies" component={Movies} />
|
||||||
<Route path="/settings" component={Settings} />
|
<Route path="/settings" component={Settings} />
|
||||||
<Route path="/sceneFilenameParser" component={SceneFilenameParser} />
|
<Route path="/sceneFilenameParser" component={SceneFilenameParser} />
|
||||||
|
<Route
|
||||||
|
path="/sceneDuplicateChecker"
|
||||||
|
component={SceneDuplicateChecker}
|
||||||
|
/>
|
||||||
<Route path="/setup" component={Setup} />
|
<Route path="/setup" component={Setup} />
|
||||||
<Route path="/migrate" component={Migrate} />
|
<Route path="/migrate" component={Migrate} />
|
||||||
<Route component={PageNotFound} />
|
<Route component={PageNotFound} />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Support serving UI from specific directory location.
|
* Support serving UI from specific directory location.
|
||||||
* Added details, death date, hair color, and weight to Performers.
|
* Added details, death date, hair color, and weight to Performers.
|
||||||
* Added details to Studios.
|
* 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 various `count` filter criteria and sort options.
|
||||||
* Add URL filter criteria for scenes, galleries, movies, performers and studios.
|
* Add URL filter criteria for scenes, galleries, movies, performers and studios.
|
||||||
* Add HTTP endpoint for health checking at `/healthz`.
|
* 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 { SettingsTasksPanel } from "./SettingsTasksPanel/SettingsTasksPanel";
|
||||||
import { SettingsPluginsPanel } from "./SettingsPluginsPanel";
|
import { SettingsPluginsPanel } from "./SettingsPluginsPanel";
|
||||||
import { SettingsScrapersPanel } from "./SettingsScrapersPanel";
|
import { SettingsScrapersPanel } from "./SettingsScrapersPanel";
|
||||||
import { SettingsDuplicatePanel } from "./SettingsDuplicatePanel";
|
import { SettingsToolsPanel } from "./SettingsToolsPanel";
|
||||||
|
|
||||||
export const Settings: React.FC = () => {
|
export const Settings: React.FC = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -37,6 +37,9 @@ export const Settings: React.FC = () => {
|
|||||||
<Nav.Item>
|
<Nav.Item>
|
||||||
<Nav.Link eventKey="tasks">Tasks</Nav.Link>
|
<Nav.Link eventKey="tasks">Tasks</Nav.Link>
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
|
<Nav.Item>
|
||||||
|
<Nav.Link eventKey="tools">Tools</Nav.Link>
|
||||||
|
</Nav.Item>
|
||||||
<Nav.Item>
|
<Nav.Item>
|
||||||
<Nav.Link eventKey="scrapers">Scrapers</Nav.Link>
|
<Nav.Link eventKey="scrapers">Scrapers</Nav.Link>
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
@@ -46,9 +49,6 @@ export const Settings: React.FC = () => {
|
|||||||
<Nav.Item>
|
<Nav.Item>
|
||||||
<Nav.Link eventKey="logs">Logs</Nav.Link>
|
<Nav.Link eventKey="logs">Logs</Nav.Link>
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
<Nav.Item>
|
|
||||||
<Nav.Link eventKey="duplicates">Dupe Checker</Nav.Link>
|
|
||||||
</Nav.Item>
|
|
||||||
<Nav.Item>
|
<Nav.Item>
|
||||||
<Nav.Link eventKey="about">About</Nav.Link>
|
<Nav.Link eventKey="about">About</Nav.Link>
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
@@ -66,6 +66,9 @@ export const Settings: React.FC = () => {
|
|||||||
<Tab.Pane eventKey="tasks">
|
<Tab.Pane eventKey="tasks">
|
||||||
<SettingsTasksPanel />
|
<SettingsTasksPanel />
|
||||||
</Tab.Pane>
|
</Tab.Pane>
|
||||||
|
<Tab.Pane eventKey="tools" unmountOnExit>
|
||||||
|
<SettingsToolsPanel />
|
||||||
|
</Tab.Pane>
|
||||||
<Tab.Pane eventKey="scrapers" unmountOnExit>
|
<Tab.Pane eventKey="scrapers" unmountOnExit>
|
||||||
<SettingsScrapersPanel />
|
<SettingsScrapersPanel />
|
||||||
</Tab.Pane>
|
</Tab.Pane>
|
||||||
@@ -75,9 +78,6 @@ export const Settings: React.FC = () => {
|
|||||||
<Tab.Pane eventKey="logs" unmountOnExit>
|
<Tab.Pane eventKey="logs" unmountOnExit>
|
||||||
<SettingsLogsPanel />
|
<SettingsLogsPanel />
|
||||||
</Tab.Pane>
|
</Tab.Pane>
|
||||||
<Tab.Pane eventKey="duplicates" unmountOnExit>
|
|
||||||
<SettingsDuplicatePanel />
|
|
||||||
</Tab.Pane>
|
|
||||||
<Tab.Pane eventKey="about" unmountOnExit>
|
<Tab.Pane eventKey="about" unmountOnExit>
|
||||||
<SettingsAboutPanel />
|
<SettingsAboutPanel />
|
||||||
</Tab.Pane>
|
</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 React, { useState, useEffect } from "react";
|
||||||
import { Button, Form, ProgressBar } from "react-bootstrap";
|
import { Button, Form, ProgressBar } from "react-bootstrap";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import {
|
import {
|
||||||
useJobStatus,
|
useJobStatus,
|
||||||
useMetadataUpdate,
|
useMetadataUpdate,
|
||||||
@@ -495,12 +494,6 @@ export const SettingsTasksPanel: React.FC = () => {
|
|||||||
</Form.Text>
|
</Form.Text>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
<Form.Group>
|
|
||||||
<Link to="/sceneFilenameParser">
|
|
||||||
<Button variant="secondary">Scene Filename Parser</Button>
|
|
||||||
</Link>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<h5>Generated Content</h5>
|
<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;
|
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/Movies/styles.scss";
|
||||||
@import "src/components/Performers/styles.scss";
|
@import "src/components/Performers/styles.scss";
|
||||||
@import "src/components/Scenes/styles.scss";
|
@import "src/components/Scenes/styles.scss";
|
||||||
|
@import "src/components/SceneDuplicateChecker/styles.scss";
|
||||||
@import "src/components/SceneFilenameParser/styles.scss";
|
@import "src/components/SceneFilenameParser/styles.scss";
|
||||||
@import "src/components/ScenePlayer/styles.scss";
|
@import "src/components/ScenePlayer/styles.scss";
|
||||||
@import "src/components/Settings/styles.scss";
|
@import "src/components/Settings/styles.scss";
|
||||||
|
|||||||
Reference in New Issue
Block a user