From f38634babcd306cff6a10f19a489a91f76e67ee5 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 25 Nov 2024 09:34:34 +1100 Subject: [PATCH] Add report fingerprint functionality (#705) --- frontend/src/components/fragments/Icon.tsx | 6 +- .../src/graphql/fragments/SceneFragment.gql | 2 + .../graphql/mutations/UnmatchFingerprint.gql | 2 +- frontend/src/graphql/types.ts | 170 ++++++++++++++++- frontend/src/pages/scenes/Scene.tsx | 128 +------------ .../pages/scenes/components/fingerprints.tsx | 146 +++++++++++++++ frontend/src/utils/date.ts | 16 ++ graphql/schema/types/scene.graphql | 18 +- pkg/database/database.go | 2 +- .../postgres/40_fingerprint_vote.up.sql | 2 + pkg/models/generated_exec.go | 177 +++++++++++++++++- pkg/models/generated_models.go | 73 +++++++- pkg/models/model_scene.go | 30 +-- pkg/models/scene.go | 3 +- pkg/scene/scene.go | 42 ++++- pkg/sqlx/fingerprints.go | 34 ++++ pkg/sqlx/querybuilder_scene.go | 83 ++++++-- 17 files changed, 742 insertions(+), 192 deletions(-) create mode 100644 frontend/src/pages/scenes/components/fingerprints.tsx create mode 100644 pkg/database/migrations/postgres/40_fingerprint_vote.up.sql create mode 100644 pkg/sqlx/fingerprints.go diff --git a/frontend/src/components/fragments/Icon.tsx b/frontend/src/components/fragments/Icon.tsx index fd2ac71..6bf72d9 100644 --- a/frontend/src/components/fragments/Icon.tsx +++ b/frontend/src/components/fragments/Icon.tsx @@ -1,4 +1,5 @@ import { FC } from "react"; +import cx from "classnames"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; @@ -7,13 +8,14 @@ interface Props { className?: string; color?: string; title?: string; + variant?: "danger" | "success" | "info" | "warning"; } -const Icon: FC = ({ icon, className, color, title }) => ( +const Icon: FC = ({ icon, className, color, title, variant }) => ( ); diff --git a/frontend/src/graphql/fragments/SceneFragment.gql b/frontend/src/graphql/fragments/SceneFragment.gql index f4ad192..afd46c1 100644 --- a/frontend/src/graphql/fragments/SceneFragment.gql +++ b/frontend/src/graphql/fragments/SceneFragment.gql @@ -35,7 +35,9 @@ fragment SceneFragment on Scene { algorithm duration submissions + reports user_submitted + user_reported created updated } diff --git a/frontend/src/graphql/mutations/UnmatchFingerprint.gql b/frontend/src/graphql/mutations/UnmatchFingerprint.gql index 25a2c12..39c90f0 100644 --- a/frontend/src/graphql/mutations/UnmatchFingerprint.gql +++ b/frontend/src/graphql/mutations/UnmatchFingerprint.gql @@ -6,7 +6,7 @@ mutation UnmatchFingerprint( ) { unmatchFingerprint: submitFingerprint( input: { - unmatch: true + vote: REMOVE scene_id: $scene_id fingerprint: { hash: $hash, algorithm: $algorithm, duration: $duration } } diff --git a/frontend/src/graphql/types.ts b/frontend/src/graphql/types.ts index 6bf2a88..7f082b8 100644 --- a/frontend/src/graphql/types.ts +++ b/frontend/src/graphql/types.ts @@ -293,8 +293,14 @@ export type Fingerprint = { created: Scalars["Time"]["output"]; duration: Scalars["Int"]["output"]; hash: Scalars["String"]["output"]; + /** number of times this fingerprint has been reported */ + reports: Scalars["Int"]["output"]; + /** number of times this fingerprint has been submitted (excluding reports) */ submissions: Scalars["Int"]["output"]; updated: Scalars["Time"]["output"]; + /** true if the current user reported this fingerprint */ + user_reported: Scalars["Boolean"]["output"]; + /** true if the current user submitted this fingerprint */ user_submitted: Scalars["Boolean"]["output"]; }; @@ -332,9 +338,20 @@ export type FingerprintQueryInput = { export type FingerprintSubmission = { fingerprint: FingerprintInput; scene_id: Scalars["ID"]["input"]; + /** @deprecated Use `vote` with REMOVE instead */ unmatch?: InputMaybe; + vote?: InputMaybe; }; +export enum FingerprintSubmissionType { + /** Report as invalid */ + INVALID = "INVALID", + /** Remove vote */ + REMOVE = "REMOVE", + /** Positive vote */ + VALID = "VALID", +} + export type FuzzyDate = { __typename: "FuzzyDate"; accuracy: DateAccuracyEnum; @@ -2076,7 +2093,9 @@ export type EditFragment = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -2852,7 +2871,9 @@ export type EditFragment = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -3054,7 +3075,9 @@ export type SceneFragment = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -3418,7 +3441,9 @@ export type ApplyEditMutation = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -4249,7 +4274,9 @@ export type ApplyEditMutation = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -4645,7 +4672,9 @@ export type PerformerEditMutation = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -5476,7 +5505,9 @@ export type PerformerEditMutation = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -5682,7 +5713,9 @@ export type PerformerEditUpdateMutation = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -6513,7 +6546,9 @@ export type PerformerEditUpdateMutation = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -6763,7 +6798,9 @@ export type SceneEditMutation = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -7594,7 +7631,9 @@ export type SceneEditMutation = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -7800,7 +7839,9 @@ export type SceneEditUpdateMutation = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -8631,7 +8672,9 @@ export type SceneEditUpdateMutation = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -8836,7 +8879,9 @@ export type StudioEditMutation = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -9667,7 +9712,9 @@ export type StudioEditMutation = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -9873,7 +9920,9 @@ export type StudioEditUpdateMutation = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -10704,7 +10753,9 @@ export type StudioEditUpdateMutation = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -10909,7 +10960,9 @@ export type TagEditMutation = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -11740,7 +11793,9 @@ export type TagEditMutation = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -11946,7 +12001,9 @@ export type TagEditUpdateMutation = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -12777,7 +12834,9 @@ export type TagEditUpdateMutation = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -13126,7 +13185,9 @@ export type VoteMutation = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -13957,7 +14018,9 @@ export type VoteMutation = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -14422,7 +14485,9 @@ export type EditQuery = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -15253,7 +15318,9 @@ export type EditQuery = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -15452,7 +15519,9 @@ export type EditUpdateQuery = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -15903,7 +15972,9 @@ export type EditsQuery = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -16754,7 +16825,9 @@ export type EditsQuery = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -17105,7 +17178,9 @@ export type QueryExistingSceneQuery = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -17256,7 +17331,9 @@ export type QueryExistingSceneQuery = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -18107,7 +18184,9 @@ export type QueryExistingSceneQuery = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -18225,7 +18304,9 @@ export type SceneQuery = { algorithm: FingerprintAlgorithm; duration: number; submissions: number; + reports: number; user_submitted: boolean; + user_reported: boolean; created: string; updated: string; }>; @@ -19272,10 +19353,15 @@ export const SceneFragmentDoc = { { kind: "Field", name: { kind: "Name", value: "algorithm" } }, { kind: "Field", name: { kind: "Name", value: "duration" } }, { kind: "Field", name: { kind: "Name", value: "submissions" } }, + { kind: "Field", name: { kind: "Name", value: "reports" } }, { kind: "Field", name: { kind: "Name", value: "user_submitted" }, }, + { + kind: "Field", + name: { kind: "Name", value: "user_reported" }, + }, { kind: "Field", name: { kind: "Name", value: "created" } }, { kind: "Field", name: { kind: "Name", value: "updated" } }, ], @@ -20882,10 +20968,15 @@ export const EditFragmentDoc = { { kind: "Field", name: { kind: "Name", value: "algorithm" } }, { kind: "Field", name: { kind: "Name", value: "duration" } }, { kind: "Field", name: { kind: "Name", value: "submissions" } }, + { kind: "Field", name: { kind: "Name", value: "reports" } }, { kind: "Field", name: { kind: "Name", value: "user_submitted" }, }, + { + kind: "Field", + name: { kind: "Name", value: "user_reported" }, + }, { kind: "Field", name: { kind: "Name", value: "created" } }, { kind: "Field", name: { kind: "Name", value: "updated" } }, ], @@ -22126,10 +22217,15 @@ export const ApplyEditDocument = { { kind: "Field", name: { kind: "Name", value: "algorithm" } }, { kind: "Field", name: { kind: "Name", value: "duration" } }, { kind: "Field", name: { kind: "Name", value: "submissions" } }, + { kind: "Field", name: { kind: "Name", value: "reports" } }, { kind: "Field", name: { kind: "Name", value: "user_submitted" }, }, + { + kind: "Field", + name: { kind: "Name", value: "user_reported" }, + }, { kind: "Field", name: { kind: "Name", value: "created" } }, { kind: "Field", name: { kind: "Name", value: "updated" } }, ], @@ -24609,10 +24705,15 @@ export const PerformerEditDocument = { { kind: "Field", name: { kind: "Name", value: "algorithm" } }, { kind: "Field", name: { kind: "Name", value: "duration" } }, { kind: "Field", name: { kind: "Name", value: "submissions" } }, + { kind: "Field", name: { kind: "Name", value: "reports" } }, { kind: "Field", name: { kind: "Name", value: "user_submitted" }, }, + { + kind: "Field", + name: { kind: "Name", value: "user_reported" }, + }, { kind: "Field", name: { kind: "Name", value: "created" } }, { kind: "Field", name: { kind: "Name", value: "updated" } }, ], @@ -26220,10 +26321,15 @@ export const PerformerEditUpdateDocument = { { kind: "Field", name: { kind: "Name", value: "algorithm" } }, { kind: "Field", name: { kind: "Name", value: "duration" } }, { kind: "Field", name: { kind: "Name", value: "submissions" } }, + { kind: "Field", name: { kind: "Name", value: "reports" } }, { kind: "Field", name: { kind: "Name", value: "user_submitted" }, }, + { + kind: "Field", + name: { kind: "Name", value: "user_reported" }, + }, { kind: "Field", name: { kind: "Name", value: "created" } }, { kind: "Field", name: { kind: "Name", value: "updated" } }, ], @@ -28017,10 +28123,15 @@ export const SceneEditDocument = { { kind: "Field", name: { kind: "Name", value: "algorithm" } }, { kind: "Field", name: { kind: "Name", value: "duration" } }, { kind: "Field", name: { kind: "Name", value: "submissions" } }, + { kind: "Field", name: { kind: "Name", value: "reports" } }, { kind: "Field", name: { kind: "Name", value: "user_submitted" }, }, + { + kind: "Field", + name: { kind: "Name", value: "user_reported" }, + }, { kind: "Field", name: { kind: "Name", value: "created" } }, { kind: "Field", name: { kind: "Name", value: "updated" } }, ], @@ -29625,10 +29736,15 @@ export const SceneEditUpdateDocument = { { kind: "Field", name: { kind: "Name", value: "algorithm" } }, { kind: "Field", name: { kind: "Name", value: "duration" } }, { kind: "Field", name: { kind: "Name", value: "submissions" } }, + { kind: "Field", name: { kind: "Name", value: "reports" } }, { kind: "Field", name: { kind: "Name", value: "user_submitted" }, }, + { + kind: "Field", + name: { kind: "Name", value: "user_reported" }, + }, { kind: "Field", name: { kind: "Name", value: "created" } }, { kind: "Field", name: { kind: "Name", value: "updated" } }, ], @@ -31220,10 +31336,15 @@ export const StudioEditDocument = { { kind: "Field", name: { kind: "Name", value: "algorithm" } }, { kind: "Field", name: { kind: "Name", value: "duration" } }, { kind: "Field", name: { kind: "Name", value: "submissions" } }, + { kind: "Field", name: { kind: "Name", value: "reports" } }, { kind: "Field", name: { kind: "Name", value: "user_submitted" }, }, + { + kind: "Field", + name: { kind: "Name", value: "user_reported" }, + }, { kind: "Field", name: { kind: "Name", value: "created" } }, { kind: "Field", name: { kind: "Name", value: "updated" } }, ], @@ -32828,10 +32949,15 @@ export const StudioEditUpdateDocument = { { kind: "Field", name: { kind: "Name", value: "algorithm" } }, { kind: "Field", name: { kind: "Name", value: "duration" } }, { kind: "Field", name: { kind: "Name", value: "submissions" } }, + { kind: "Field", name: { kind: "Name", value: "reports" } }, { kind: "Field", name: { kind: "Name", value: "user_submitted" }, }, + { + kind: "Field", + name: { kind: "Name", value: "user_reported" }, + }, { kind: "Field", name: { kind: "Name", value: "created" } }, { kind: "Field", name: { kind: "Name", value: "updated" } }, ], @@ -34423,10 +34549,15 @@ export const TagEditDocument = { { kind: "Field", name: { kind: "Name", value: "algorithm" } }, { kind: "Field", name: { kind: "Name", value: "duration" } }, { kind: "Field", name: { kind: "Name", value: "submissions" } }, + { kind: "Field", name: { kind: "Name", value: "reports" } }, { kind: "Field", name: { kind: "Name", value: "user_submitted" }, }, + { + kind: "Field", + name: { kind: "Name", value: "user_reported" }, + }, { kind: "Field", name: { kind: "Name", value: "created" } }, { kind: "Field", name: { kind: "Name", value: "updated" } }, ], @@ -36031,10 +36162,15 @@ export const TagEditUpdateDocument = { { kind: "Field", name: { kind: "Name", value: "algorithm" } }, { kind: "Field", name: { kind: "Name", value: "duration" } }, { kind: "Field", name: { kind: "Name", value: "submissions" } }, + { kind: "Field", name: { kind: "Name", value: "reports" } }, { kind: "Field", name: { kind: "Name", value: "user_submitted" }, }, + { + kind: "Field", + name: { kind: "Name", value: "user_reported" }, + }, { kind: "Field", name: { kind: "Name", value: "created" } }, { kind: "Field", name: { kind: "Name", value: "updated" } }, ], @@ -37281,8 +37417,8 @@ export const UnmatchFingerprintDocument = { fields: [ { kind: "ObjectField", - name: { kind: "Name", value: "unmatch" }, - value: { kind: "BooleanValue", value: true }, + name: { kind: "Name", value: "vote" }, + value: { kind: "EnumValue", value: "REMOVE" }, }, { kind: "ObjectField", @@ -38302,10 +38438,15 @@ export const VoteDocument = { { kind: "Field", name: { kind: "Name", value: "algorithm" } }, { kind: "Field", name: { kind: "Name", value: "duration" } }, { kind: "Field", name: { kind: "Name", value: "submissions" } }, + { kind: "Field", name: { kind: "Name", value: "reports" } }, { kind: "Field", name: { kind: "Name", value: "user_submitted" }, }, + { + kind: "Field", + name: { kind: "Name", value: "user_reported" }, + }, { kind: "Field", name: { kind: "Name", value: "created" } }, { kind: "Field", name: { kind: "Name", value: "updated" } }, ], @@ -40749,10 +40890,15 @@ export const EditDocument = { { kind: "Field", name: { kind: "Name", value: "algorithm" } }, { kind: "Field", name: { kind: "Name", value: "duration" } }, { kind: "Field", name: { kind: "Name", value: "submissions" } }, + { kind: "Field", name: { kind: "Name", value: "reports" } }, { kind: "Field", name: { kind: "Name", value: "user_submitted" }, }, + { + kind: "Field", + name: { kind: "Name", value: "user_reported" }, + }, { kind: "Field", name: { kind: "Name", value: "created" } }, { kind: "Field", name: { kind: "Name", value: "updated" } }, ], @@ -42920,10 +43066,15 @@ export const EditUpdateDocument = { { kind: "Field", name: { kind: "Name", value: "algorithm" } }, { kind: "Field", name: { kind: "Name", value: "duration" } }, { kind: "Field", name: { kind: "Name", value: "submissions" } }, + { kind: "Field", name: { kind: "Name", value: "reports" } }, { kind: "Field", name: { kind: "Name", value: "user_submitted" }, }, + { + kind: "Field", + name: { kind: "Name", value: "user_reported" }, + }, { kind: "Field", name: { kind: "Name", value: "created" } }, { kind: "Field", name: { kind: "Name", value: "updated" } }, ], @@ -43390,10 +43541,15 @@ export const EditsDocument = { { kind: "Field", name: { kind: "Name", value: "algorithm" } }, { kind: "Field", name: { kind: "Name", value: "duration" } }, { kind: "Field", name: { kind: "Name", value: "submissions" } }, + { kind: "Field", name: { kind: "Name", value: "reports" } }, { kind: "Field", name: { kind: "Name", value: "user_submitted" }, }, + { + kind: "Field", + name: { kind: "Name", value: "user_reported" }, + }, { kind: "Field", name: { kind: "Name", value: "created" } }, { kind: "Field", name: { kind: "Name", value: "updated" } }, ], @@ -45865,10 +46021,15 @@ export const QueryExistingSceneDocument = { { kind: "Field", name: { kind: "Name", value: "algorithm" } }, { kind: "Field", name: { kind: "Name", value: "duration" } }, { kind: "Field", name: { kind: "Name", value: "submissions" } }, + { kind: "Field", name: { kind: "Name", value: "reports" } }, { kind: "Field", name: { kind: "Name", value: "user_submitted" }, }, + { + kind: "Field", + name: { kind: "Name", value: "user_reported" }, + }, { kind: "Field", name: { kind: "Name", value: "created" } }, { kind: "Field", name: { kind: "Name", value: "updated" } }, ], @@ -47250,10 +47411,15 @@ export const SceneDocument = { { kind: "Field", name: { kind: "Name", value: "algorithm" } }, { kind: "Field", name: { kind: "Name", value: "duration" } }, { kind: "Field", name: { kind: "Name", value: "submissions" } }, + { kind: "Field", name: { kind: "Name", value: "reports" } }, { kind: "Field", name: { kind: "Name", value: "user_submitted" }, }, + { + kind: "Field", + name: { kind: "Name", value: "user_reported" }, + }, { kind: "Field", name: { kind: "Name", value: "created" } }, { kind: "Field", name: { kind: "Name", value: "updated" } }, ], diff --git a/frontend/src/pages/scenes/Scene.tsx b/frontend/src/pages/scenes/Scene.tsx index 58b9b5a..9663ee1 100644 --- a/frontend/src/pages/scenes/Scene.tsx +++ b/frontend/src/pages/scenes/Scene.tsx @@ -1,20 +1,13 @@ import { FC, useContext } from "react"; import { Link, useLocation, useNavigate } from "react-router-dom"; -import { Button, Card, Tabs, Tab, Table } from "react-bootstrap"; -import { - faCheckCircle, - faTimesCircle, - faSpinner, -} from "@fortawesome/free-solid-svg-icons"; +import { Button, Card, Tabs, Tab } from "react-bootstrap"; import { usePendingEditsCount, TargetTypeEnum, - useUnmatchFingerprint, SceneFragment as Scene, } from "src/graphql"; import AuthContext from "src/AuthContext"; -import { useToast } from "src/hooks"; import { canEdit, tagHref, @@ -22,26 +15,15 @@ import { studioHref, createHref, formatDuration, - formatDateTime, formatPendingEdits, getUrlBySite, compareByName, } from "src/utils"; -import { - ROUTE_SCENE_EDIT, - ROUTE_SCENES, - ROUTE_SCENE_DELETE, -} from "src/constants/route"; -import { - GenderIcon, - TagLink, - PerformerName, - Icon, -} from "src/components/fragments"; +import { ROUTE_SCENE_EDIT, ROUTE_SCENE_DELETE } from "src/constants/route"; +import { GenderIcon, TagLink, PerformerName } from "src/components/fragments"; import { EditList, URLList } from "src/components/list"; import Image from "src/components/image"; - -type Fingerprint = NonNullable; +import { FingerprintTable } from "./components/fingerprints"; const DEFAULT_TAB = "description"; @@ -54,9 +36,6 @@ const SceneComponent: FC = ({ scene }) => { const location = useLocation(); const activeTab = location.hash?.slice(1) || DEFAULT_TAB; const auth = useContext(AuthContext); - const addToast = useToast(); - - const [unmatchFingerprint, { loading: unmatching }] = useUnmatchFingerprint(); const { data: editData } = usePendingEditsCount({ type: TargetTypeEnum.SCENE, @@ -83,72 +62,6 @@ const SceneComponent: FC = ({ scene }) => { }) .map((p, index) => (index % 2 === 2 ? [" • ", p] : p)); - async function handleFingerprintUnmatch(fingerprint: Fingerprint) { - if (unmatching) return; - - const { data } = await unmatchFingerprint({ - variables: { - scene_id: scene.id, - algorithm: fingerprint.algorithm, - hash: fingerprint.hash, - duration: fingerprint.duration, - }, - }); - const success = data?.unmatchFingerprint; - addToast({ - variant: success ? "success" : "danger", - content: `${ - success ? "Removed" : "Failed to remove" - } fingerprint submission`, - }); - } - - function maybeRenderSubmitted(fingerprint: Fingerprint) { - if (fingerprint.user_submitted) { - return ( - - ); - } - } - - const fingerprints = scene.fingerprints.map((fingerprint) => ( - - {fingerprint.algorithm} - - - {fingerprint.hash} - - - - - {formatDuration(fingerprint.duration)} - - - - {fingerprint.submissions} - {maybeRenderSubmitted(fingerprint)} - - {formatDateTime(fingerprint.created)} - {formatDateTime(fingerprint.updated)} - - )); const tags = [...scene.tags].sort(compareByName).map((tag) => (
  • = ({ scene }) => { )} -
    -

    Fingerprints:

    - {fingerprints.length === 0 ? ( -
    No fingerprints found for this scene.
    - ) : ( - - - - - - - - - - - - {fingerprints} -
    - Algorithm - - Hash - - Duration - - Submissions - - First Added - - Last Added -
    - )} -
    +
    diff --git a/frontend/src/pages/scenes/components/fingerprints.tsx b/frontend/src/pages/scenes/components/fingerprints.tsx new file mode 100644 index 0000000..4a55219 --- /dev/null +++ b/frontend/src/pages/scenes/components/fingerprints.tsx @@ -0,0 +1,146 @@ +import { FC } from "react"; +import { Link } from "react-router-dom"; +import { Button, Table } from "react-bootstrap"; +import { + faCheckCircle, + faTimesCircle, + faSpinner, + faTriangleExclamation, +} from "@fortawesome/free-solid-svg-icons"; + +import { Fingerprint, useUnmatchFingerprint } from "src/graphql"; +import { useToast } from "src/hooks"; +import { createHref, formatDate, formatDuration } from "src/utils"; +import { ROUTE_SCENES } from "src/constants/route"; +import { Icon } from "src/components/fragments"; + +interface Props { + scene: { + id: string; + fingerprints: Fingerprint[]; + }; +} + +type MatchType = "submission" | "report"; + +export const FingerprintTable: FC = ({ scene }) => { + const addToast = useToast(); + + const [unmatchFingerprint, { loading: unmatching }] = useUnmatchFingerprint(); + + const handleFingerprintUnmatch = async ( + fingerprint: Fingerprint, + type: MatchType, + ) => { + if (unmatching) return; + + const { data } = await unmatchFingerprint({ + variables: { + scene_id: scene.id, + algorithm: fingerprint.algorithm, + hash: fingerprint.hash, + duration: fingerprint.duration, + }, + }); + const success = data?.unmatchFingerprint; + addToast({ + variant: success ? "success" : "danger", + content: `${ + success ? "Removed" : "Failed to remove" + } fingerprint ${type}`, + }); + }; + + const renderUnmatch = (fingerprint: Fingerprint, type: MatchType) => ( + + ); + + return ( +
    +

    Fingerprints:

    + {scene.fingerprints.length === 0 ? ( +
    No fingerprints found for this scene.
    + ) : ( + + + + + + + + + + + + + + {scene.fingerprints.map((fingerprint) => ( + + + + + + + + + + ))} + +
    + Algorithm + + Hash + + Duration + + Submissions + + Reports + + First Added + + Last Added +
    {fingerprint.algorithm} + + {fingerprint.hash} + + + + {formatDuration(fingerprint.duration)} + + + {fingerprint.submissions} + {fingerprint.user_submitted && + renderUnmatch(fingerprint, "submission")} + + {fingerprint.reports > 0 && ( + <> + {fingerprint.reports}{" "} + + {fingerprint.user_reported && + renderUnmatch(fingerprint, "report")} + + )} + {formatDate(fingerprint.created)}{formatDate(fingerprint.updated)}
    + )} +
    + ); +}; diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts index 67bee29..2c31b8b 100644 --- a/frontend/src/utils/date.ts +++ b/frontend/src/utils/date.ts @@ -13,6 +13,22 @@ export const formatDateTime = (dateTime: Date | string, utc = false) => { })}`; }; +export const formatDate = (dateTime: Date | string, utc = false) => { + const timeZone = utc ? "UTC" : undefined; + const date = dateTime instanceof Date ? dateTime : new Date(dateTime); + return date.toLocaleString("en-us", { + month: "short", + year: "numeric", + day: "numeric", + timeZone, + }); +}; + +export const formatISODate = (dateTime: Date | string) => { + const date = dateTime instanceof Date ? dateTime : new Date(dateTime); + return date.toISOString().slice(0, 10); +}; + export const isValidDate = (date?: string) => !date || isValid(parseISO(date)); export const dateWithinRange = ( diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index a15ba60..a1b11e6 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -22,14 +22,29 @@ enum FavoriteFilter { ALL } +enum FingerprintSubmissionType { + "Positive vote" + VALID + "Report as invalid" + INVALID + "Remove vote" + REMOVE +} + type Fingerprint { hash: String! algorithm: FingerprintAlgorithm! duration: Int! + "number of times this fingerprint has been submitted (excluding reports)" submissions: Int! + "number of times this fingerprint has been reported" + reports: Int! created: Time! updated: Time! + "true if the current user submitted this fingerprint" user_submitted: Boolean! + "true if the current user reported this fingerprint" + user_reported: Boolean! } type DraftFingerprint { @@ -64,7 +79,8 @@ input FingerprintQueryInput { input FingerprintSubmission { scene_id: ID! fingerprint: FingerprintInput! - unmatch: Boolean + unmatch: Boolean @deprecated(reason: "Use `vote` with REMOVE instead") + vote: FingerprintSubmissionType = VALID } type Scene { diff --git a/pkg/database/database.go b/pkg/database/database.go index 41382db..c8d72a0 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -4,7 +4,7 @@ import ( "github.com/jmoiron/sqlx" ) -var appSchemaVersion uint = 39 +var appSchemaVersion uint = 40 var databaseProviders map[string]databaseProvider diff --git a/pkg/database/migrations/postgres/40_fingerprint_vote.up.sql b/pkg/database/migrations/postgres/40_fingerprint_vote.up.sql new file mode 100644 index 0000000..cf67bad --- /dev/null +++ b/pkg/database/migrations/postgres/40_fingerprint_vote.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE "scene_fingerprints" + ADD COLUMN "vote" SMALLINT NOT NULL DEFAULT 1 CHECK (vote = -1 OR vote = 1); diff --git a/pkg/models/generated_exec.go b/pkg/models/generated_exec.go index b4f58a0..c794bc4 100644 --- a/pkg/models/generated_exec.go +++ b/pkg/models/generated_exec.go @@ -142,8 +142,10 @@ type ComplexityRoot struct { Created func(childComplexity int) int Duration func(childComplexity int) int Hash func(childComplexity int) int + Reports func(childComplexity int) int Submissions func(childComplexity int) int Updated func(childComplexity int) int + UserReported func(childComplexity int) int UserSubmitted func(childComplexity int) int } @@ -1244,6 +1246,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Fingerprint.Hash(childComplexity), true + case "Fingerprint.reports": + if e.complexity.Fingerprint.Reports == nil { + break + } + + return e.complexity.Fingerprint.Reports(childComplexity), true + case "Fingerprint.submissions": if e.complexity.Fingerprint.Submissions == nil { break @@ -1258,6 +1267,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Fingerprint.Updated(childComplexity), true + case "Fingerprint.user_reported": + if e.complexity.Fingerprint.UserReported == nil { + break + } + + return e.complexity.Fingerprint.UserReported(childComplexity), true + case "Fingerprint.user_submitted": if e.complexity.Fingerprint.UserSubmitted == nil { break @@ -5003,14 +5019,29 @@ enum FavoriteFilter { ALL } +enum FingerprintSubmissionType { + "Positive vote" + VALID + "Report as invalid" + INVALID + "Remove vote" + REMOVE +} + type Fingerprint { hash: String! algorithm: FingerprintAlgorithm! duration: Int! + "number of times this fingerprint has been submitted (excluding reports)" submissions: Int! + "number of times this fingerprint has been reported" + reports: Int! created: Time! updated: Time! + "true if the current user submitted this fingerprint" user_submitted: Boolean! + "true if the current user reported this fingerprint" + user_reported: Boolean! } type DraftFingerprint { @@ -5045,7 +5076,8 @@ input FingerprintQueryInput { input FingerprintSubmission { scene_id: ID! fingerprint: FingerprintInput! - unmatch: Boolean + unmatch: Boolean @deprecated(reason: "Use ` + "`" + `vote` + "`" + ` with REMOVE instead") + vote: FingerprintSubmissionType = VALID } type Scene { @@ -10931,6 +10963,50 @@ func (ec *executionContext) fieldContext_Fingerprint_submissions(_ context.Conte return fc, nil } +func (ec *executionContext) _Fingerprint_reports(ctx context.Context, field graphql.CollectedField, obj *Fingerprint) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Fingerprint_reports(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Reports, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(int) + fc.Result = res + return ec.marshalNInt2int(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Fingerprint_reports(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Fingerprint", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Fingerprint_created(ctx context.Context, field graphql.CollectedField, obj *Fingerprint) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Fingerprint_created(ctx, field) if err != nil { @@ -11063,6 +11139,50 @@ func (ec *executionContext) fieldContext_Fingerprint_user_submitted(_ context.Co return fc, nil } +func (ec *executionContext) _Fingerprint_user_reported(ctx context.Context, field graphql.CollectedField, obj *Fingerprint) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Fingerprint_user_reported(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.UserReported, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Fingerprint_user_reported(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Fingerprint", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _FuzzyDate_date(ctx context.Context, field graphql.CollectedField, obj *FuzzyDate) (ret graphql.Marshaler) { fc, err := ec.fieldContext_FuzzyDate_date(ctx, field) if err != nil { @@ -25704,12 +25824,16 @@ func (ec *executionContext) fieldContext_Scene_fingerprints(ctx context.Context, return ec.fieldContext_Fingerprint_duration(ctx, field) case "submissions": return ec.fieldContext_Fingerprint_submissions(ctx, field) + case "reports": + return ec.fieldContext_Fingerprint_reports(ctx, field) case "created": return ec.fieldContext_Fingerprint_created(ctx, field) case "updated": return ec.fieldContext_Fingerprint_updated(ctx, field) case "user_submitted": return ec.fieldContext_Fingerprint_user_submitted(ctx, field) + case "user_reported": + return ec.fieldContext_Fingerprint_user_reported(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Fingerprint", field.Name) }, @@ -27245,12 +27369,16 @@ func (ec *executionContext) fieldContext_SceneEdit_added_fingerprints(_ context. return ec.fieldContext_Fingerprint_duration(ctx, field) case "submissions": return ec.fieldContext_Fingerprint_submissions(ctx, field) + case "reports": + return ec.fieldContext_Fingerprint_reports(ctx, field) case "created": return ec.fieldContext_Fingerprint_created(ctx, field) case "updated": return ec.fieldContext_Fingerprint_updated(ctx, field) case "user_submitted": return ec.fieldContext_Fingerprint_user_submitted(ctx, field) + case "user_reported": + return ec.fieldContext_Fingerprint_user_reported(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Fingerprint", field.Name) }, @@ -27302,12 +27430,16 @@ func (ec *executionContext) fieldContext_SceneEdit_removed_fingerprints(_ contex return ec.fieldContext_Fingerprint_duration(ctx, field) case "submissions": return ec.fieldContext_Fingerprint_submissions(ctx, field) + case "reports": + return ec.fieldContext_Fingerprint_reports(ctx, field) case "created": return ec.fieldContext_Fingerprint_created(ctx, field) case "updated": return ec.fieldContext_Fingerprint_updated(ctx, field) case "user_submitted": return ec.fieldContext_Fingerprint_user_submitted(ctx, field) + case "user_reported": + return ec.fieldContext_Fingerprint_user_reported(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Fingerprint", field.Name) }, @@ -27746,12 +27878,16 @@ func (ec *executionContext) fieldContext_SceneEdit_fingerprints(_ context.Contex return ec.fieldContext_Fingerprint_duration(ctx, field) case "submissions": return ec.fieldContext_Fingerprint_submissions(ctx, field) + case "reports": + return ec.fieldContext_Fingerprint_reports(ctx, field) case "created": return ec.fieldContext_Fingerprint_created(ctx, field) case "updated": return ec.fieldContext_Fingerprint_updated(ctx, field) case "user_submitted": return ec.fieldContext_Fingerprint_user_submitted(ctx, field) + case "user_reported": + return ec.fieldContext_Fingerprint_user_reported(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Fingerprint", field.Name) }, @@ -34492,7 +34628,11 @@ func (ec *executionContext) unmarshalInputFingerprintSubmission(ctx context.Cont asMap[k] = v } - fieldsInOrder := [...]string{"scene_id", "fingerprint", "unmatch"} + if _, present := asMap["vote"]; !present { + asMap["vote"] = "VALID" + } + + fieldsInOrder := [...]string{"scene_id", "fingerprint", "unmatch", "vote"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -34520,6 +34660,13 @@ func (ec *executionContext) unmarshalInputFingerprintSubmission(ctx context.Cont return it, err } it.Unmatch = data + case "vote": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("vote")) + data, err := ec.unmarshalOFingerprintSubmissionType2ᚖgithubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐFingerprintSubmissionType(ctx, v) + if err != nil { + return it, err + } + it.Vote = data } } @@ -39492,6 +39639,11 @@ func (ec *executionContext) _Fingerprint(ctx context.Context, sel ast.SelectionS if out.Values[i] == graphql.Null { out.Invalids++ } + case "reports": + out.Values[i] = ec._Fingerprint_reports(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "created": out.Values[i] = ec._Fingerprint_created(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -39507,6 +39659,11 @@ func (ec *executionContext) _Fingerprint(ctx context.Context, sel ast.SelectionS if out.Values[i] == graphql.Null { out.Invalids++ } + case "user_reported": + out.Values[i] = ec._Fingerprint_user_reported(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -49785,6 +49942,22 @@ func (ec *executionContext) unmarshalOFingerprintInput2ᚕᚖgithubᚗcomᚋstas return res, nil } +func (ec *executionContext) unmarshalOFingerprintSubmissionType2ᚖgithubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐFingerprintSubmissionType(ctx context.Context, v interface{}) (*FingerprintSubmissionType, error) { + if v == nil { + return nil, nil + } + var res = new(FingerprintSubmissionType) + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOFingerprintSubmissionType2ᚖgithubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐFingerprintSubmissionType(ctx context.Context, sel ast.SelectionSet, v *FingerprintSubmissionType) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return v +} + func (ec *executionContext) marshalOFuzzyDate2ᚖgithubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐFuzzyDate(ctx context.Context, sel ast.SelectionSet, v *FuzzyDate) graphql.Marshaler { if v == nil { return graphql.Null diff --git a/pkg/models/generated_models.go b/pkg/models/generated_models.go index a0d74a1..5662297 100644 --- a/pkg/models/generated_models.go +++ b/pkg/models/generated_models.go @@ -137,13 +137,19 @@ type EyeColorCriterionInput struct { } type Fingerprint struct { - Hash string `json:"hash"` - Algorithm FingerprintAlgorithm `json:"algorithm"` - Duration int `json:"duration"` - Submissions int `json:"submissions"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` - UserSubmitted bool `json:"user_submitted"` + Hash string `json:"hash"` + Algorithm FingerprintAlgorithm `json:"algorithm"` + Duration int `json:"duration"` + // number of times this fingerprint has been submitted (excluding reports) + Submissions int `json:"submissions"` + // number of times this fingerprint has been reported + Reports int `json:"reports"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + // true if the current user submitted this fingerprint + UserSubmitted bool `json:"user_submitted"` + // true if the current user reported this fingerprint + UserReported bool `json:"user_reported"` } type FingerprintEditInput struct { @@ -170,9 +176,10 @@ type FingerprintQueryInput struct { } type FingerprintSubmission struct { - SceneID uuid.UUID `json:"scene_id"` - Fingerprint *FingerprintInput `json:"fingerprint"` - Unmatch *bool `json:"unmatch,omitempty"` + SceneID uuid.UUID `json:"scene_id"` + Fingerprint *FingerprintInput `json:"fingerprint"` + Unmatch *bool `json:"unmatch,omitempty"` + Vote *FingerprintSubmissionType `json:"vote,omitempty"` } type FuzzyDate struct { @@ -1251,6 +1258,52 @@ func (e FingerprintAlgorithm) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } +type FingerprintSubmissionType string + +const ( + // Positive vote + FingerprintSubmissionTypeValid FingerprintSubmissionType = "VALID" + // Report as invalid + FingerprintSubmissionTypeInvalid FingerprintSubmissionType = "INVALID" + // Remove vote + FingerprintSubmissionTypeRemove FingerprintSubmissionType = "REMOVE" +) + +var AllFingerprintSubmissionType = []FingerprintSubmissionType{ + FingerprintSubmissionTypeValid, + FingerprintSubmissionTypeInvalid, + FingerprintSubmissionTypeRemove, +} + +func (e FingerprintSubmissionType) IsValid() bool { + switch e { + case FingerprintSubmissionTypeValid, FingerprintSubmissionTypeInvalid, FingerprintSubmissionTypeRemove: + return true + } + return false +} + +func (e FingerprintSubmissionType) String() string { + return string(e) +} + +func (e *FingerprintSubmissionType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = FingerprintSubmissionType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid FingerprintSubmissionType", str) + } + return nil +} + +func (e FingerprintSubmissionType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + type GenderEnum string const ( diff --git a/pkg/models/model_scene.go b/pkg/models/model_scene.go index 1467172..b050c3c 100644 --- a/pkg/models/model_scene.go +++ b/pkg/models/model_scene.go @@ -105,6 +105,7 @@ type SceneFingerprint struct { Algorithm string `db:"algorithm" json:"algorithm"` Duration int `db:"duration" json:"duration"` CreatedAt time.Time `db:"created_at" json:"created_at"` + Vote int `db:"vote" json:"vote"` } type SceneFingerprints []*SceneFingerprint @@ -125,32 +126,6 @@ func (f *SceneFingerprints) Add(o interface{}) { *f = append(*f, o.(*SceneFingerprint)) } -type DBSceneFingerprint struct { - SceneID uuid.UUID `db:"scene_id" json:"scene_id"` - UserID uuid.UUID `db:"user_id" json:"user_id"` - FingerprintID int `db:"fingerprint_id" json:"fingerprint_id"` - Duration int `db:"duration" json:"duration"` - CreatedAt time.Time `db:"created_at" json:"created_at"` -} - -type DBSceneFingerprints []*DBSceneFingerprint - -func (f DBSceneFingerprints) Each(fn func(interface{})) { - for _, v := range f { - fn(*v) - } -} - -func (f DBSceneFingerprints) EachPtr(fn func(interface{})) { - for _, v := range f { - fn(v) - } -} - -func (f *DBSceneFingerprints) Add(o interface{}) { - *f = append(*f, o.(*DBSceneFingerprint)) -} - func CreateSceneFingerprints(sceneID uuid.UUID, fingerprints []*FingerprintEditInput) SceneFingerprints { var ret SceneFingerprints @@ -172,7 +147,7 @@ func CreateSceneFingerprints(sceneID uuid.UUID, fingerprints []*FingerprintEditI return ret } -func CreateSubmittedSceneFingerprints(sceneID uuid.UUID, fingerprints []*FingerprintInput) SceneFingerprints { +func CreateSubmittedSceneFingerprints(sceneID uuid.UUID, fingerprints []*FingerprintInput, vote int) SceneFingerprints { var ret SceneFingerprints for _, fingerprint := range fingerprints { @@ -184,6 +159,7 @@ func CreateSubmittedSceneFingerprints(sceneID uuid.UUID, fingerprints []*Fingerp Hash: fingerprint.Hash, Algorithm: fingerprint.Algorithm.String(), Duration: fingerprint.Duration, + Vote: vote, }) } } diff --git a/pkg/models/scene.go b/pkg/models/scene.go index fd32be0..f370221 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -9,7 +9,7 @@ type SceneRepo interface { SoftDelete(scene Scene) (*Scene, error) CreateURLs(newJoins SceneURLs) error UpdateURLs(scene uuid.UUID, updatedJoins SceneURLs) error - CreateFingerprints(newJoins SceneFingerprints) error + CreateOrReplaceFingerprints(newJoins SceneFingerprints) error UpdateFingerprints(sceneID uuid.UUID, updatedJoins SceneFingerprints) error DestroyFingerprints(sceneID uuid.UUID, toDelete SceneFingerprints) error Find(id uuid.UUID) (*Scene, error) @@ -27,6 +27,7 @@ type SceneRepo interface { // GetAllFingerprints returns fingerprints for each of the scene ids provided. // currentUserID is used to populate the UserSubmitted field. GetAllFingerprints(currentUserID uuid.UUID, ids []uuid.UUID, onlySubmitted bool) ([][]*Fingerprint, []error) + SubmittedHashExists(sceneID uuid.UUID, hash string, algorithm FingerprintAlgorithm) (bool, error) GetPerformers(id uuid.UUID) (PerformersScenes, error) GetAllAppearances(ids []uuid.UUID) ([]PerformersScenes, []error) GetURLs(id uuid.UUID) ([]*URL, error) diff --git a/pkg/scene/scene.go b/pkg/scene/scene.go index 040dabe..5f56af9 100644 --- a/pkg/scene/scene.go +++ b/pkg/scene/scene.go @@ -48,7 +48,7 @@ func Create(ctx context.Context, fac models.Repo, input models.SceneCreateInput) } sceneFingerprints := models.CreateSceneFingerprints(scene.ID, input.Fingerprints) - if err := qb.CreateFingerprints(sceneFingerprints); err != nil { + if err := qb.CreateOrReplaceFingerprints(sceneFingerprints); err != nil { return nil, err } @@ -232,6 +232,17 @@ func Destroy(fac models.Repo, input models.SceneDestroyInput) (bool, error) { return true, nil } +func submissionTypeToInt(t models.FingerprintSubmissionType) int { + switch t { + case models.FingerprintSubmissionTypeValid: + return 1 + case models.FingerprintSubmissionTypeInvalid: + return -1 + default: + return 0 + } +} + func SubmitFingerprint(ctx context.Context, fac models.Repo, input models.FingerprintSubmission) (bool, error) { qb := fac.Scene() @@ -257,11 +268,34 @@ func SubmitFingerprint(ctx context.Context, fac models.Repo, input models.Finger input.Fingerprint.UserIds = []uuid.UUID{currentUserID} } - sceneFingerprint := models.CreateSubmittedSceneFingerprints(scene.ID, []*models.FingerprintInput{input.Fingerprint}) + // set the default vote + vote := models.FingerprintSubmissionTypeValid + if input.Vote != nil { + vote = *input.Vote + } - if input.Unmatch == nil || !*input.Unmatch { + // if the user is reporting a fingerprint, ensure that the fingerprint has at least one submission + if vote == models.FingerprintSubmissionTypeInvalid { + submissionExists, err := qb.SubmittedHashExists(input.SceneID, input.Fingerprint.Hash, input.Fingerprint.Algorithm) + if err != nil { + return false, err + } + + if !submissionExists { + return false, errors.New("fingerprint has no submissions") + } + } + + voteInt := submissionTypeToInt(vote) + sceneFingerprint := models.CreateSubmittedSceneFingerprints(scene.ID, []*models.FingerprintInput{input.Fingerprint}, voteInt) + + // vote == 0 means the user is unmatching the fingerprint + // Unmatch is the deprecated field, but we still need to support it + unmatch := vote == models.FingerprintSubmissionTypeRemove || (input.Unmatch != nil && *input.Unmatch) + + if !unmatch { // set the new fingerprints - if err := qb.CreateFingerprints(sceneFingerprint); err != nil { + if err := qb.CreateOrReplaceFingerprints(sceneFingerprint); err != nil { return false, err } } else { diff --git a/pkg/sqlx/fingerprints.go b/pkg/sqlx/fingerprints.go new file mode 100644 index 0000000..bb57559 --- /dev/null +++ b/pkg/sqlx/fingerprints.go @@ -0,0 +1,34 @@ +package sqlx + +import ( + "time" + + "github.com/gofrs/uuid" +) + +type dbSceneFingerprint struct { + SceneID uuid.UUID `db:"scene_id" json:"scene_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + FingerprintID int `db:"fingerprint_id" json:"fingerprint_id"` + Duration int `db:"duration" json:"duration"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + Vote int `db:"vote" json:"vote"` +} + +type dbSceneFingerprints []*dbSceneFingerprint + +func (f dbSceneFingerprints) Each(fn func(interface{})) { + for _, v := range f { + fn(*v) + } +} + +func (f dbSceneFingerprints) EachPtr(fn func(interface{})) { + for _, v := range f { + fn(v) + } +} + +func (f *dbSceneFingerprints) Add(o interface{}) { + *f = append(*f, o.(*dbSceneFingerprint)) +} diff --git a/pkg/sqlx/querybuilder_scene.go b/pkg/sqlx/querybuilder_scene.go index 1a393fa..38b7e8a 100644 --- a/pkg/sqlx/querybuilder_scene.go +++ b/pkg/sqlx/querybuilder_scene.go @@ -27,7 +27,7 @@ var ( }) sceneFingerprintTable = newTableJoin(sceneTable, "scene_fingerprints", sceneJoinKey, func() interface{} { - return &models.DBSceneFingerprint{} + return &dbSceneFingerprint{} }) sceneURLTable = newTableJoin(sceneTable, "scene_urls", sceneJoinKey, func() interface{} { @@ -79,21 +79,27 @@ func (qb *sceneQueryBuilder) UpdateURLs(scene uuid.UUID, updatedJoins models.Sce return qb.dbi.ReplaceJoins(sceneURLTable, scene, &updatedJoins) } -func (qb *sceneQueryBuilder) CreateFingerprints(sceneFingerprints models.SceneFingerprints) error { - conflictHandling := `ON CONFLICT DO NOTHING` +func (qb *sceneQueryBuilder) CreateOrReplaceFingerprints(sceneFingerprints models.SceneFingerprints) error { + conflictHandling := ` + ON CONFLICT ON CONSTRAINT scene_fingerprints_scene_id_fingerprint_id_user_id_key + DO UPDATE SET + duration = EXCLUDED.duration, + vote = EXCLUDED.vote + ` - var fingerprints models.DBSceneFingerprints + var fingerprints dbSceneFingerprints for _, fp := range sceneFingerprints { id, err := qb.getOrCreateFingerprintID(fp.Hash, fp.Algorithm) if err != nil { return err } - fingerprints = append(fingerprints, &models.DBSceneFingerprint{ + fingerprints = append(fingerprints, &dbSceneFingerprint{ FingerprintID: id, SceneID: fp.SceneID, UserID: fp.UserID, Duration: fp.Duration, + Vote: fp.Vote, }) } @@ -105,7 +111,7 @@ func (qb *sceneQueryBuilder) UpdateFingerprints(sceneID uuid.UUID, updatedJoins return err } - return qb.CreateFingerprints(updatedJoins) + return qb.CreateOrReplaceFingerprints(updatedJoins) } func (qb *sceneQueryBuilder) DestroyFingerprints(sceneID uuid.UUID, toDestroy models.SceneFingerprints) error { @@ -572,14 +578,17 @@ func (qb *sceneQueryBuilder) queryScenes(query string, args []interface{}) (mode } type sceneFingerprintGroup struct { - SceneID uuid.UUID `db:"scene_id"` - Hash string `db:"hash"` - Algorithm models.FingerprintAlgorithm `db:"algorithm"` - Duration float64 `db:"duration"` - Submissions int `db:"submissions"` - UserSubmitted bool `db:"user_submitted"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + SceneID uuid.UUID `db:"scene_id"` + Hash string `db:"hash"` + Algorithm models.FingerprintAlgorithm `db:"algorithm"` + Duration float64 `db:"duration"` + Submissions int `db:"submissions"` + Reports int `db:"reports"` + NetSubmissions int `db:"net_submissions"` + UserSubmitted bool `db:"user_submitted"` + UserReported bool `db:"user_reported"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } func fingerprintGroupToFingerprint(fpg sceneFingerprintGroup) *models.Fingerprint { @@ -588,7 +597,9 @@ func fingerprintGroupToFingerprint(fpg sceneFingerprintGroup) *models.Fingerprin Algorithm: fpg.Algorithm, Duration: int(fpg.Duration), Submissions: fpg.Submissions, + Reports: fpg.Reports, UserSubmitted: fpg.UserSubmitted, + UserReported: fpg.UserReported, Created: fpg.CreatedAt, Updated: fpg.UpdatedAt, } @@ -612,10 +623,13 @@ func (qb *sceneQueryBuilder) GetAllFingerprints(currentUserID uuid.UUID, ids []u FP.hash, FP.algorithm, mode() WITHIN GROUP (ORDER BY SFP.duration) as duration, - COUNT(SFP.fingerprint_id) as submissions, + COUNT(CASE WHEN SFP.vote = 1 THEN 1 END) as submissions, + COUNT(CASE WHEN SFP.vote = -1 THEN 1 END) as reports, + SUM(SFP.vote) as net_submissions, MIN(created_at) as created_at, MAX(created_at) as updated_at, - bool_or(SFP.user_id = :userid) as user_submitted + bool_or(SFP.user_id = :userid AND SFP.vote = 1) as user_submitted, + bool_or(SFP.user_id = :userid AND SFP.vote = -1) as user_reported FROM scene_fingerprints SFP JOIN fingerprints FP ON SFP.fingerprint_id = FP.id WHERE SFP.scene_id IN (:sceneids) @@ -627,7 +641,7 @@ func (qb *sceneQueryBuilder) GetAllFingerprints(currentUserID uuid.UUID, ids []u query += ` GROUP BY SFP.scene_id, FP.algorithm, FP.hash - ORDER BY submissions DESC` + ORDER BY net_submissions DESC` arg := map[string]interface{}{ "userid": currentUserID, @@ -667,6 +681,38 @@ func (qb *sceneQueryBuilder) GetAllFingerprints(currentUserID uuid.UUID, ids []u return result, nil } +// SubmittedHashExists returns true if the given hash exists for the given scene +func (qb *sceneQueryBuilder) SubmittedHashExists(sceneID uuid.UUID, hash string, algorithm models.FingerprintAlgorithm) (bool, error) { + query := ` + SELECT + 1 + FROM scene_fingerprints f + JOIN fingerprints fp ON f.fingerprint_id = fp.id + WHERE f.scene_id = :sceneid AND fp.hash = :hash AND fp.algorithm = :algorithm AND f.vote = 1 + ` + + arg := map[string]interface{}{ + "sceneid": sceneID, + "hash": hash, + "algorithm": algorithm, + } + + query, args, err := sqlx.Named(query, arg) + if err != nil { + return false, err + } + + result := false + if err := qb.dbi.queryFunc(query, args, func(rows *sqlx.Rows) error { + result = true + return nil + }); err != nil { + return false, err + } + + return result, nil +} + func (qb *sceneQueryBuilder) GetPerformers(id uuid.UUID) (models.PerformersScenes, error) { joins := models.PerformersScenes{} err := qb.dbi.FindJoins(scenePerformerTable, id, &joins) @@ -1005,13 +1051,14 @@ func (qb *sceneQueryBuilder) addFingerprintsFromEdit(scene *models.Scene, data * Algorithm: fingerprint.Algorithm.String(), SceneID: scene.ID, UserID: userID, + Vote: 1, Duration: fingerprint.Duration, CreatedAt: time.Now(), }) } } - return qb.CreateFingerprints(newFingerprints) + return qb.CreateOrReplaceFingerprints(newFingerprints) } func (qb *sceneQueryBuilder) getOrCreateFingerprintID(hash string, algorithm string) (int, error) {