From f51ac81749ef7fa7cacac038aefb72fbe1c5abd1 Mon Sep 17 00:00:00 2001 From: DrDaveUK Date: Tue, 12 Sep 2023 07:46:36 +0100 Subject: [PATCH] Scene Duplicate Checker UI & Feature Improvement (#4006) * UI Update to show which file is being deleted Added Selection dropwdown Added checkbox to ensure that the codecs are the same within the group * Refactor size options * Convert select box to dropdown * Internationalisation --------- Co-authored-by: Steve Enderby Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- .../SceneDuplicateChecker.tsx | 222 +++++++++++++++++- .../SceneDuplicateChecker/styles.scss | 3 +- ui/v2.5/src/locales/en-GB.json | 6 + 3 files changed, 217 insertions(+), 14 deletions(-) diff --git a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx index c45d1b29362..3d6a1c6fdb3 100644 --- a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx +++ b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx @@ -1,9 +1,10 @@ -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { Button, ButtonGroup, Card, Col, + Dropdown, Form, OverlayTrigger, Row, @@ -46,7 +47,6 @@ const defaultDurationDiff = "1"; export const SceneDuplicateChecker: React.FC = () => { const intl = useIntl(); const history = useHistory(); - const query = new URLSearchParams(history.location.search); const currentPage = Number.parseInt(query.get("page") ?? "1", 10); const pageSize = Number.parseInt(query.get("size") ?? "20", 10); @@ -59,9 +59,12 @@ export const SceneDuplicateChecker: React.FC = () => { const [isMultiDelete, setIsMultiDelete] = useState(false); const [deletingScenes, setDeletingScenes] = useState(false); const [editingScenes, setEditingScenes] = useState(false); + const [chkSafeSelect, setChkSafeSelect] = useState(true); + const [checkedScenes, setCheckedScenes] = useState>( {} ); + const { data, loading, refetch } = GQL.useFindDuplicateScenesQuery({ fetchPolicy: "no-cache", variables: { @@ -69,6 +72,9 @@ export const SceneDuplicateChecker: React.FC = () => { duration_diff: durationDiff, }, }); + + const scenes = data?.findDuplicateScenes ?? []; + const { data: missingPhash } = GQL.useFindScenesQuery({ variables: { filter: { @@ -91,10 +97,27 @@ export const SceneDuplicateChecker: React.FC = () => { const [mergeScenes, setMergeScenes] = useState<{ id: string; title: string }[]>(); + const pageOptions = useMemo(() => { + const pageSizes = [ + 10, 20, 30, 40, 50, 100, 150, 200, 250, 500, 750, 1000, 1250, 1500, + ]; + + const filteredSizes = pageSizes.filter((s, i) => { + return scenes.length > s || i == 0 || scenes.length > pageSizes[i - 1]; + }); + + return filteredSizes.map((size) => { + return ( + + ); + }); + }, [scenes.length]); + if (loading) return ; if (!data) return ; - const scenes = data?.findDuplicateScenes ?? []; const filteredScenes = scenes.slice( (currentPage - 1) * pageSize, currentPage * pageSize @@ -116,6 +139,16 @@ export const SceneDuplicateChecker: React.FC = () => { history.push({ search: newQuery.toString() }); }; + const resetCheckboxSelection = () => { + const updatedScenes: Record = {}; + + Object.keys(checkedScenes).forEach((sceneKey) => { + updatedScenes[sceneKey] = false; + }); + + setCheckedScenes(updatedScenes); + }; + function onDeleteDialogClosed(deleted: boolean) { setDeletingScenes(false); if (deleted) { @@ -123,8 +156,102 @@ export const SceneDuplicateChecker: React.FC = () => { refetch(); if (isMultiDelete) setCheckedScenes({}); } + resetCheckboxSelection(); } + const findLargestScene = (group: GQL.SlimSceneDataFragment[]) => { + // Get total size of a scene + const totalSize = (scene: GQL.SlimSceneDataFragment) => { + return scene.files.reduce((sum: number, f) => sum + (f.size || 0), 0); + }; + // Find scene object with maximum total size + return group.reduce((largest, scene) => { + const largestSize = totalSize(largest); + const currentSize = totalSize(scene); + return currentSize > largestSize ? scene : largest; + }); + }; + + // Helper to get file date + + const findFirstFileByAge = ( + oldest: boolean, + compareScenes: GQL.SlimSceneDataFragment[] + ) => { + let selectedFile: GQL.VideoFileDataFragment; + let oldestTimestamp: Date | undefined = undefined; + + // Loop through all files + for (const file of compareScenes.flatMap((s) => s.files)) { + // Get timestamp + const timestamp: Date = new Date(file.mod_time); + + // Check if current file is oldest + if (oldest) { + if (oldestTimestamp === undefined || timestamp < oldestTimestamp) { + oldestTimestamp = timestamp; + selectedFile = file; + } + } else { + if (oldestTimestamp === undefined || timestamp > oldestTimestamp) { + oldestTimestamp = timestamp; + selectedFile = file; + } + } + } + + // Find scene with oldest file + return compareScenes.find((s) => + s.files.some((f) => f.id === selectedFile.id) + ); + }; + + function checkSameCodec(codecGroup: GQL.SlimSceneDataFragment[]) { + const codecs = codecGroup.map((s) => s.files[0]?.video_codec); + return new Set(codecs).size === 1; + } + + const onSelectLargestClick = () => { + setSelectedScenes([]); + const checkedArray: Record = {}; + + filteredScenes.forEach((group) => { + if (chkSafeSelect && !checkSameCodec(group)) { + return; + } + // Find largest scene in group a + const largest = findLargestScene(group); + group.forEach((scene) => { + if (scene !== largest) { + checkedArray[scene.id] = true; + } + }); + }); + + setCheckedScenes(checkedArray); + }; + + const onSelectByAge = (oldest: boolean) => { + setSelectedScenes([]); + + const checkedArray: Record = {}; + + filteredScenes.forEach((group) => { + if (chkSafeSelect && !checkSameCodec(group)) { + return; + } + + const oldestScene = findFirstFileByAge(oldest, group); + group.forEach((scene) => { + if (scene !== oldestScene) { + checkedArray[scene.id] = true; + } + }); + }); + + setCheckedScenes(checkedArray); + }; + const handleCheck = (checked: boolean, sceneID: string) => { setCheckedScenes({ ...checkedScenes, [sceneID]: checked }); }; @@ -144,6 +271,7 @@ export const SceneDuplicateChecker: React.FC = () => { function onEdit() { setSelectedScenes(scenes.flat().filter((s) => checkedScenes[s.id])); setEditingScenes(true); + resetCheckboxSelection(); } const renderFilesize = (filesize: number | null | undefined) => { @@ -395,9 +523,10 @@ export const SceneDuplicateChecker: React.FC = () => { currentPage={currentPage} totalItems={scenes.length} metadataByline={[]} - onChangePage={(newPage) => - setQuery({ page: newPage === 1 ? undefined : newPage }) - } + onChangePage={(newPage) => { + setQuery({ page: newPage === 1 ? undefined : newPage }); + resetCheckboxSelection(); + }} /> { ? undefined : e.currentTarget.value, }); + resetCheckboxSelection(); }} > - - - - - + {pageOptions} ); @@ -572,6 +698,54 @@ export const SceneDuplicateChecker: React.FC = () => { + + + + + + + + + resetCheckboxSelection()}> + {intl.formatMessage({ id: "dupe_check.select_none" })} + + + onSelectLargestClick()}> + {intl.formatMessage({ + id: "dupe_check.select_all_but_largest_file", + })} + + + onSelectByAge(true)}> + {intl.formatMessage({ + id: "dupe_check.select_oldest", + })} + + + onSelectByAge(false)}> + {intl.formatMessage({ + id: "dupe_check.select_youngest", + })} + + + + + + + { + setChkSafeSelect(e.target.checked); + resetCheckboxSelection(); + }} + /> + + {maybeRenderMissingPhashWarning()} @@ -621,6 +795,7 @@ export const SceneDuplicateChecker: React.FC = () => { > handleCheck(e.currentTarget.checked, scene.id) } @@ -641,15 +816,36 @@ export const SceneDuplicateChecker: React.FC = () => { src={scene.paths.sprite ?? ""} alt="" width={100} + style={{ + border: checkedScenes[scene.id] + ? "2px solid red" + : "", + }} />

- + + {" "} {scene.title ? scene.title - : TextUtils.fileNameFromPath(file?.path ?? "")} + : TextUtils.fileNameFromPath( + file?.path ?? "" + )}{" "}

{file?.path ?? ""}

diff --git a/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss b/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss index 9177a9367c9..750e4466fdd 100644 --- a/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss +++ b/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss @@ -8,7 +8,8 @@ } .separator { - height: 50px; + border-top: 1px solid white; + height: 10px; } .form-group .row { diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 3747b969d92..76c6be33c14 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -918,6 +918,7 @@ "equal": "Equal" }, "found_sets": "{setCount, plural, one{# set of duplicates found.} other {# sets of duplicates found.}}", + "only_select_matching_codecs": "Only select if all codecs match in the duplicate group", "options": { "exact": "Exact", "high": "High", @@ -925,6 +926,11 @@ "medium": "Medium" }, "search_accuracy_label": "Search Accuracy", + "select_options" : "Select Options…", + "select_all_but_largest_file": "Select every file in each duplicated group, except the largest file", + "select_none": "Select None", + "select_oldest": "Select the oldest file in the duplicate group", + "select_youngest": "Select the youngest file in the duplicate group", "title": "Duplicate Scenes" }, "duplicated_phash": "Duplicated (phash)",