diff --git a/pkg/manager/task_autotag.go b/pkg/manager/task_autotag.go index cbd7cdc32..6632dc2b9 100644 --- a/pkg/manager/task_autotag.go +++ b/pkg/manager/task_autotag.go @@ -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, } diff --git a/pkg/models/extension_find_filter.go b/pkg/models/extension_find_filter.go index e2d4f8d7c..f2e7e7ab1 100644 --- a/pkg/models/extension_find_filter.go +++ b/pkg/models/extension_find_filter.go @@ -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 } diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index efee69e4a..96f90da63 100755 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -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 = () => { + diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index a4a6c3bc2..a0c890aed 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -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`. diff --git a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx new file mode 100644 index 000000000..512c64979 --- /dev/null +++ b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx @@ -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>( + {} + ); + 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 ; + if (!data) return ; + + 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) => { + 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 ( + + ); + }; + + function maybeRenderMissingPhashWarning() { + const missingPhashes = missingPhash?.findScenes.count ?? 0; + if (missingPhashes > 0) { + return ( +

+ + Missing phashes for {missingPhashes} scenes. Please run the phash + generation task. +

+ ); + } + } + + function maybeRenderEdit() { + if (editingScenes && selectedScenes) { + return ( + setEditingScenes(false)} + /> + ); + } + } + + function maybeRenderTagPopoverButton(scene: GQL.SlimSceneDataFragment) { + if (scene.tags.length <= 0) return; + + const popoverContent = scene.tags.map((tag) => ( + + )); + + return ( + + + + ); + } + + function maybeRenderPerformerPopoverButton(scene: GQL.SlimSceneDataFragment) { + if (scene.performers.length <= 0) return; + + return ; + } + + function maybeRenderMoviePopoverButton(scene: GQL.SlimSceneDataFragment) { + if (scene.movies.length <= 0) return; + + const popoverContent = scene.movies.map((sceneMovie) => ( +
+ + {sceneMovie.movie.name + + +
+ )); + + return ( + + + + ); + } + + 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 ; + }); + + return ( + + + + ); + } + + function maybeRenderOCounter(scene: GQL.SlimSceneDataFragment) { + if (scene.o_counter) { + return ( +
+ +
+ ); + } + } + + function maybeRenderGallery(scene: GQL.SlimSceneDataFragment) { + if (scene.galleries.length <= 0) return; + + const popoverContent = scene.galleries.map((gallery) => ( + + )); + + return ( + + + + ); + } + + function maybeRenderOrganized(scene: GQL.SlimSceneDataFragment) { + if (scene.organized) { + return ( +
+ +
+ ); + } + } + + 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 ( + <> + + {maybeRenderTagPopoverButton(scene)} + {maybeRenderPerformerPopoverButton(scene)} + {maybeRenderMoviePopoverButton(scene)} + {maybeRenderSceneMarkerPopoverButton(scene)} + {maybeRenderOCounter(scene)} + {maybeRenderGallery(scene)} + {maybeRenderOrganized(scene)} + + + ); + } + } + + return ( + +
+ {deletingScenes && selectedScenes && ( + + )} + {maybeRenderEdit()} +

Duplicate Scenes

+ + + Search Accuracy + + + setQuery({ + distance: + e.currentTarget.value === "0" + ? undefined + : e.currentTarget.value, + page: undefined, + }) + } + defaultValue={distance ?? 0} + className="input-control ml-4" + > + + + + + + + + + Levels below “Exact” can take longer to calculate. False + positives might also be returned on lower accuracy levels. + + + {maybeRenderMissingPhashWarning()} +
+
+ {scenes.length} sets of duplicates found. +
+ {checkCount > 0 && ( + + Edit}> + + + Delete}> + + + + )} + + setQuery({ page: newPage === 1 ? undefined : newPage }) + } + /> + + setQuery({ + size: + e.currentTarget.value === "20" + ? undefined + : e.currentTarget.value, + }) + } + > + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + {filteredScenes.map((group, groupIndex) => + group.map((scene, i) => ( + <> + {i === 0 && groupIndex !== 0 ? ( + + ) : undefined} + + + + + + + + + + + + + + )) + )} + +
Details DurationFilesizeResolutionBitrateCodecDelete
+ + handleCheck(e.currentTarget.checked, scene.id) + } + /> + + + } + placement="right" + > + + + +

+ + {scene.title ?? + TextUtils.fileNameFromPath(scene.path)} + +

+

{scene.path}

+
+ {maybeRenderPopoverButtonGroup(scene)} + + {scene.file.duration && + TextUtils.secondsToTimestamp(scene.file.duration)} + {renderFilesize(scene.file.size)}{`${scene.file.width}x${scene.file.height}`} + +  mbps + {scene.file.video_codec} + +
+ {scenes.length === 0 && ( +

No duplicates found.

+ )} +
+
+ ); +}; diff --git a/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss b/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss new file mode 100644 index 000000000..24084527a --- /dev/null +++ b/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss @@ -0,0 +1,13 @@ +#scene-duplicate-checker { + .scene-path { + font-size: 0.88em; + } + + .filter-container { + margin: 0; + } + + .separator { + height: 50px; + } +} diff --git a/ui/v2.5/src/components/Settings/Settings.tsx b/ui/v2.5/src/components/Settings/Settings.tsx index 7fa3e200f..1c580ec6e 100644 --- a/ui/v2.5/src/components/Settings/Settings.tsx +++ b/ui/v2.5/src/components/Settings/Settings.tsx @@ -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 = () => { Tasks + + Tools + Scrapers @@ -46,9 +49,6 @@ export const Settings: React.FC = () => { Logs - - Dupe Checker - About @@ -66,6 +66,9 @@ export const Settings: React.FC = () => { + + + @@ -75,9 +78,6 @@ export const Settings: React.FC = () => { - - - diff --git a/ui/v2.5/src/components/Settings/SettingsDuplicatePanel.tsx b/ui/v2.5/src/components/Settings/SettingsDuplicatePanel.tsx deleted file mode 100644 index 53916033a..000000000 --- a/ui/v2.5/src/components/Settings/SettingsDuplicatePanel.tsx +++ /dev/null @@ -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>( - {} - ); - const { data, loading, refetch } = GQL.useFindDuplicateScenesQuery({ - fetchPolicy: "no-cache", - variables: { distance: hashDistance }, - }); - const [deletingScene, setDeletingScene] = useState< - GQL.SlimSceneDataFragment[] | null - >(null); - - if (loading) return ; - if (!data) return ; - - 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) => { - 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 ( - - ); - }; - - return ( -
- {deletingScene && ( - - )} -

Duplicate Scenes

- - - Search Accuracy - - - setQuery({ - distance: - e.currentTarget.value === "0" - ? undefined - : e.currentTarget.value, - page: undefined, - }) - } - defaultValue={distance ?? 0} - className="ml-4" - > - - - - - - - - - Levels below “Exact” can take longer to calculate. False - positives might also be returned on lower accuracy levels. - - -
-
- {scenes.length} sets of duplicates found. -
- {checkCount > 0 && ( - - )} - - setQuery({ page: newPage === 1 ? undefined : newPage }) - } - /> - - setQuery({ - size: - e.currentTarget.value === "20" - ? undefined - : e.currentTarget.value, - }) - } - > - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - {filteredScenes.map((group) => - group.map((scene, i) => ( - - - - - - - - - - - - )) - )} - -
TitleDurationFilesizeResolutionBitrateCodecDelete
- - handleCheck(e.currentTarget.checked, scene.id) - } - /> - - - } - placement="right" - > - - - - - {scene.title ?? TextUtils.fileNameFromPath(scene.path)} - - - {scene.file.duration && - TextUtils.secondsToTimestamp(scene.file.duration)} - {renderFilesize(scene.file.size)}{`${scene.file.width}x${scene.file.height}`} - -  mbps - {scene.file.video_codec} - -
- {scenes.length === 0 && ( -

- No duplicates found. Make sure the phash task has been run. -

- )} -
- ); -}; diff --git a/ui/v2.5/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx b/ui/v2.5/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx index f4f371e24..1eeaf655e 100644 --- a/ui/v2.5/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx @@ -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 = () => { - - - - - -
Generated Content
diff --git a/ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx b/ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx new file mode 100644 index 000000000..02fb701c3 --- /dev/null +++ b/ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx @@ -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 ( + <> +

Scene Tools

+ + + Scene Filename Parser + + + + Scene Duplicate Checker + + + ); +}; diff --git a/ui/v2.5/src/components/Settings/styles.scss b/ui/v2.5/src/components/Settings/styles.scss index 9e58835dd..89844ce14 100644 --- a/ui/v2.5/src/components/Settings/styles.scss +++ b/ui/v2.5/src/components/Settings/styles.scss @@ -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; - } -} diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 44aec70b9..9efb57a8e 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -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";