diff --git a/src/components/Utilities/Unrecognized/LinkFilesWithProvider/UnrecognizedVideo.tsx b/src/components/Utilities/Unrecognized/LinkFilesWithProvider/LinkCard.tsx similarity index 78% rename from src/components/Utilities/Unrecognized/LinkFilesWithProvider/UnrecognizedVideo.tsx rename to src/components/Utilities/Unrecognized/LinkFilesWithProvider/LinkCard.tsx index 37727040d..07894b575 100644 --- a/src/components/Utilities/Unrecognized/LinkFilesWithProvider/UnrecognizedVideo.tsx +++ b/src/components/Utilities/Unrecognized/LinkFilesWithProvider/LinkCard.tsx @@ -1,8 +1,6 @@ import type { KeyboardEvent, MouseEvent } from 'react'; import cx from 'classnames'; -import { LinkState } from '@/core/types/utilities/unrecognized-utility'; - import CrossReference from './CrossReference'; import ProviderName from './ProviderName'; import VideoMetadata from './VideoMetadata'; @@ -16,29 +14,31 @@ type Props = { }; const linkStateClassMap = { - [LinkState.PreInit]: 'opacity-65 cursor-wait', - [LinkState.Init]: '', - [LinkState.Searching]: 'animate-pulse cursor-wait', - [LinkState.Ready]: 'cursor-pointer', - [LinkState.Submitting]: 'cursor-progress', - [LinkState.Submitted]: '', + 'pre-init': 'opacity-65 cursor-wait', + init: '', + searching: 'animate-pulse cursor-wait', + ready: 'cursor-pointer', + submitting: 'cursor-progress', + submitted: '', + fetching: 'animate-pulse cursor-wait', } as const; const selectionDisabledStates = [ - LinkState.PreInit, - LinkState.Searching, - LinkState.Submitting, + 'pre-init', + 'searching', + 'submitting', + 'fetching', ]; -const UnrecognizedVideo = (props: Props) => { +const LinkCard = (props: Props) => { const { link, selected, toggleSelect } = props; let border = 'border-panel-border'; - if (link.state === LinkState.Submitted) { + if (link.state === 'submitted') { border = 'border-panel-text-important'; - } else if ([LinkState.Searching, LinkState.Submitting].includes(link.state)) { + } else if (['searching', 'submitting'].includes(link.state)) { border = 'border-panel-text-primary'; - } else if (link.state === LinkState.Ready) { + } else if (link.state === 'ready') { border = 'border-panel-text-warning'; } else if (selected) { border = 'border-panel-text-primary'; @@ -69,7 +69,7 @@ const UnrecognizedVideo = (props: Props) => { border, selected && 'bg-panel-background-selected-row!', !selected && linkStateClassMap[link.state], - [LinkState.Ready, LinkState.Submitting, LinkState.Submitted].includes(link.state) && 'bg-panel-background-alt', + ['ready', 'submitting', 'submitted'].includes(link.state) && 'bg-panel-background-alt', )} onMouseDown={handleMouseDown} onClick={handleSelect} @@ -105,4 +105,4 @@ const UnrecognizedVideo = (props: Props) => { ); }; -export default UnrecognizedVideo; +export default LinkCard; diff --git a/src/components/Utilities/Unrecognized/LinkFilesWithProvider/Menu.tsx b/src/components/Utilities/Unrecognized/LinkFilesWithProvider/Menu.tsx index e6bfc30f1..940db439c 100644 --- a/src/components/Utilities/Unrecognized/LinkFilesWithProvider/Menu.tsx +++ b/src/components/Utilities/Unrecognized/LinkFilesWithProvider/Menu.tsx @@ -1,7 +1,6 @@ import { mdiMagnify, mdiPencil, mdiSelectAll, mdiSelection, mdiSelectionRemove, mdiTrayPlus } from '@mdi/js'; import MenuButton from '@/components/Utilities/Unrecognized/MenuButton'; -import { LinkState } from '@/core/types/utilities/unrecognized-utility'; import type { ManualLinkType } from '@/core/types/utilities/unrecognized-utility'; @@ -32,7 +31,7 @@ const Menu = (props: Props) => { name="Search for Release Info" keybinding="S" disabled={!selectedLinks.length - || !selectedLinks.some(link => [LinkState.Ready, LinkState.Init].includes(link.state))} + || !selectedLinks.some(link => ['ready', 'init', 'fetching'].includes(link.state))} /> { /> {(selectedLinks.length > 0 - && !selectedLinks.some(link => [LinkState.Searching, LinkState.Submitting].includes(link.state))) && ( + && !selectedLinks.some(link => ['searching', 'submitting'].includes(link.state))) && ( { /> )} - {selectedLinks.some(link => link.state === LinkState.Ready) && ( + {selectedLinks.some(link => link.state === 'ready') && ( { const name = link.release.ProviderName; const editedByUser = name.startsWith('User+') || name.endsWith('+User'); - const providerName = name !== 'User' && ![LinkState.Searching].includes(link.state) + const providerName = name !== 'User' && link.state !== 'searching' ? name .replace(/^User\+/, '') .replace(/\+User$/, '') diff --git a/src/components/Utilities/Unrecognized/LinkFilesWithProvider/TitleOptions.tsx b/src/components/Utilities/Unrecognized/LinkFilesWithProvider/TitleOptions.tsx index 4db0c70eb..a8b305ea7 100644 --- a/src/components/Utilities/Unrecognized/LinkFilesWithProvider/TitleOptions.tsx +++ b/src/components/Utilities/Unrecognized/LinkFilesWithProvider/TitleOptions.tsx @@ -1,13 +1,11 @@ import { countBy } from 'lodash'; -import { LinkState } from '@/core/types/utilities/unrecognized-utility'; - -import type { ManualLinkType } from '@/core/types/utilities/unrecognized-utility'; +import type { LinkStateType, ManualLinkType } from '@/core/types/utilities/unrecognized-utility'; const TitleOptions = ({ links, selectedCount }: { links: ManualLinkType[], selectedCount: number }) => { - const countByStatus = countBy(links, link => link.state) as Record; - const submittedCount = countByStatus[LinkState.Submitted] ?? 0; - const pendingCount = (countByStatus[LinkState.Submitting] ?? 0) + (countByStatus[LinkState.Ready] ?? 0); + const countByStatus = countBy(links, link => link.state) as Record; + const submittedCount = countByStatus.submitted ?? 0; + const pendingCount = (countByStatus.submitting ?? 0) + (countByStatus.ready ?? 0); return (
diff --git a/src/core/react-query/release-info/mutations.ts b/src/core/react-query/release-info/mutations.ts index 56a90cd71..a69c547d0 100644 --- a/src/core/react-query/release-info/mutations.ts +++ b/src/core/react-query/release-info/mutations.ts @@ -45,3 +45,11 @@ export const usePreviewReleaseInfoByProviderIdMutation = () => id: 'release-info', }, }); + +export const useReleaseInfoByFileIdMutation = () => + useMutation({ + mutationFn: fileId => axios.get(`ReleaseInfo/File/${fileId}`), + scope: { + id: 'release-info', + }, + }); diff --git a/src/core/router/index.tsx b/src/core/router/index.tsx index fd5922e0e..d089c4bce 100644 --- a/src/core/router/index.tsx +++ b/src/core/router/index.tsx @@ -46,13 +46,13 @@ import UnsupportedPage from '@/pages/unsupported/UnsupportedPage'; import DuplicateFilesLinkedTab from '@/pages/utilities/DuplicateFilesTabs/DuplicateFilesLinkedTab'; import DuplicateFilesUnrecognizedTab from '@/pages/utilities/DuplicateFilesTabs/DuplicateFilesUnrecognizedTab'; import FileSearch from '@/pages/utilities/FileSearch'; +import LinkFilesWithProviders from '@/pages/utilities/LinkFilesWithProviders'; import MissingEpisodes from '@/pages/utilities/MissingEpisodes'; import ReleaseManagement from '@/pages/utilities/ReleaseManagement'; import Renamer from '@/pages/utilities/Renamer'; import SeriesWithoutFilesUtility from '@/pages/utilities/SeriesWithoutFilesUtility'; import IgnoredFilesTab from '@/pages/utilities/UnrecognizedUtilityTabs/IgnoredFilesTab'; import LinkFilesTab from '@/pages/utilities/UnrecognizedUtilityTabs/LinkFilesTab'; -import LinkFilesWithProvidersTab from '@/pages/utilities/UnrecognizedUtilityTabs/LinkFilesWithProvidersTab'; import ManuallyLinkedTab from '@/pages/utilities/UnrecognizedUtilityTabs/ManuallyLinkedTab'; import UnrecognizedTab from '@/pages/utilities/UnrecognizedUtilityTabs/UnrecognizedTab'; @@ -93,7 +93,7 @@ const router = sentryCreateBrowserRouter( } /> } /> } /> - } /> + } /> } /> } /> @@ -105,6 +105,7 @@ const router = sentryCreateBrowserRouter( } /> } /> } /> + } /> } /> diff --git a/src/core/types/utilities/unrecognized-utility.ts b/src/core/types/utilities/unrecognized-utility.ts index c3435ad2a..7884bacb7 100644 --- a/src/core/types/utilities/unrecognized-utility.ts +++ b/src/core/types/utilities/unrecognized-utility.ts @@ -1,13 +1,6 @@ import type { FileType, ReleaseInfoType } from '@/core/types/api/file'; -export enum LinkState { - PreInit = 'pre-init', - Init = 'init', - Searching = 'searching', - Ready = 'ready', - Submitting = 'submitting', - Submitted = 'submitted', -} +export type LinkStateType = 'pre-init' | 'init' | 'searching' | 'ready' | 'submitting' | 'submitted' | 'fetching'; export type ManualLinkProviderType = { id: string; @@ -20,5 +13,5 @@ export type ManualLinkType = { providers: ManualLinkProviderType[]; release: ReleaseInfoType; metadata?: string; - state: LinkState; + state: LinkStateType; }; diff --git a/src/core/utilities/releaseInfoHelpers.ts b/src/core/utilities/releaseInfoHelpers.ts new file mode 100644 index 000000000..14a13ca48 --- /dev/null +++ b/src/core/utilities/releaseInfoHelpers.ts @@ -0,0 +1,90 @@ +import { produce } from 'immer'; + +import { ReleaseSource } from '@/core/types/api/file'; + +import type { FileType, ReleaseInfoType } from '@/core/types/api/file'; +import type { ManualLinkProviderType, ManualLinkType } from '@/core/types/utilities/unrecognized-utility'; + +let lastLinkId = 0; +const generateLinkId = () => { + if (lastLinkId === Number.MAX_SAFE_INTEGER) { + lastLinkId = 0; + } + lastLinkId += 1; + return lastLinkId; +}; + +export const mergeReleaseInfo = (incoming: ReleaseInfoType, original: ReleaseInfoType): ReleaseInfoType => + produce(incoming, (draft) => { + if (draft.Source === ReleaseSource.Unknown && original.Source !== ReleaseSource.Unknown) { + draft.Source = original.Source; + } + + if (draft.Version < 1) draft.Version = 1; + + draft.FileSize ??= original.FileSize; + draft.OriginalFilename ??= original.OriginalFilename; + draft.IsChaptered ??= original.IsChaptered; + draft.IsCensored ??= original.IsCensored; + draft.IsCreditless ??= original.IsCreditless; + draft.Group ??= original.Group; + + if (draft.ProviderName !== 'User' && !/\+User\b/.test(draft.ProviderName)) { + draft.ProviderName += '+User'; + } + }); + +const createLinksFromFiles = (files: FileType[], providers: ManualLinkProviderType[]) => { + const sortedFiles = files.toSorted((fileA, fileB) => { + let locationA = (fileA.Locations.find(loc => loc.IsAccessible) ?? fileA.Locations[0])?.RelativePath ?? ''; + let locationB = (fileB.Locations.find(loc => loc.IsAccessible) ?? fileB.Locations[0])?.RelativePath ?? ''; + if (locationA.startsWith('dot')) locationA = `.${locationA.substring(3)}`; + if (locationB.startsWith('dot')) locationB = `.${locationB.substring(3)}`; + return locationA.localeCompare(locationB, 'en-US', { + numeric: true, + ignorePunctuation: true, + sensitivity: 'base', + }); + }); + + const newLinks: Record = {}; + sortedFiles.forEach((file) => { + const now = new Date().toISOString(); + const release: ReleaseInfoType = { + OriginalFilename: file.Locations?.[0]?.RelativePath.split(/[/\\]/g).pop(), + ProviderName: 'User', + Version: 1, + Source: ReleaseSource.Unknown, + CrossReferences: [], + FileSize: file.Size, + Hashes: file.Hashes, + IsCorrupted: false, + Released: file.MediaInfo?.Encoded?.slice(0, 10) ?? file.Created?.slice(0, 10), + Created: now, + Updated: now, + }; + + const linkId = generateLinkId(); + if (file.Imported) { + newLinks[linkId] = { + id: linkId, + file, + providers: [], + state: 'fetching', + release, + }; + } else { + newLinks[linkId] = { + id: linkId, + file, + providers, + state: 'pre-init', + release, + }; + } + }); + + return newLinks; +}; + +export default createLinksFromFiles; diff --git a/src/hooks/utilities/useLinkWorkflow.ts b/src/hooks/utilities/useLinkWorkflow.ts new file mode 100644 index 000000000..1b8150686 --- /dev/null +++ b/src/hooks/utilities/useLinkWorkflow.ts @@ -0,0 +1,179 @@ +import { useEffect, useEffectEvent, useRef } from 'react'; +import { forEach } from 'lodash'; + +import { + useAutoPreviewReleaseInfoForFileByIdMutation, + usePreviewReleaseInfoByProviderIdMutation, + useReleaseInfoByFileIdMutation, + useSubmitReleaseInfoForFileByIdMutation, +} from '@/core/react-query/release-info/mutations'; +import { mergeReleaseInfo } from '@/core/utilities/releaseInfoHelpers'; + +import type { ManualLinkType } from '@/core/types/utilities/unrecognized-utility'; +import type { Updater } from 'use-immer'; + +const useLinkWorkflow = ( + links: ManualLinkType[], + setLinks: Updater>, + providerMap: Record, + initialized: boolean, +) => { + const { mutateAsync: previewReleaseInfo } = usePreviewReleaseInfoByProviderIdMutation(); + const { mutateAsync: searchReleaseInfo } = useAutoPreviewReleaseInfoForFileByIdMutation(); + const { mutateAsync: submitReleaseInfo } = useSubmitReleaseInfoForFileByIdMutation(); + const { mutateAsync: fetchReleaseInfo } = useReleaseInfoByFileIdMutation(); + + // useRef avoids re-renders — these Sets track in-flight API calls (bookkeeping only, not UI state) + const inFlight = useRef({ + initializing: new Set(), + searching: new Set(), + submitting: new Set(), + fetching: new Set(), + }); + + const processPreInit = useEffectEvent((link: ManualLinkType) => { + if (inFlight.current.initializing.has(link.id)) return; + inFlight.current.initializing.add(link.id); + + const hasProvidersEnabled = link.providers.some(provider => provider.enabled); + const offlineImporterProviderId = link.providers.find( + provider => providerMap[provider.id]?.Name === 'Offline Importer', + )?.id; + + if (!offlineImporterProviderId) { + setLinks((draft) => { + draft[link.id].state = hasProvidersEnabled ? 'searching' : 'init'; + }); + return; + } + + const path = link.file.Locations.find(location => location.AbsolutePath)?.AbsolutePath + ?? link.file.Locations?.[0]?.RelativePath ?? ''; + + previewReleaseInfo({ id: `match://${path}`, providerId: offlineImporterProviderId }) + .then((data) => { + if (!data) return; + setLinks((draft) => { + draft[link.id].release = data; + draft[link.id].state = hasProvidersEnabled ? 'searching' : 'init'; + }); + }) + .catch(() => { + setLinks((draft) => { + draft[link.id].state = hasProvidersEnabled ? 'searching' : 'init'; + }); + }) + .finally(() => inFlight.current.initializing.delete(link.id)); + }); + + const processSearch = useEffectEvent((link: ManualLinkType) => { + if (inFlight.current.searching.has(link.id)) return; + inFlight.current.searching.add(link.id); + + const enabledReleaseProviders = link.providers + .filter(provider => provider.enabled) + .map(provider => provider.id); + if (!enabledReleaseProviders.length) return; + + searchReleaseInfo({ fileId: link.file.ID, providerIDs: enabledReleaseProviders }) + .then((data) => { + if (!data) { + setLinks((draft) => { + draft[link.id].state = 'init'; + }); + return; + } + + setLinks((draft) => { + draft[link.id].release = mergeReleaseInfo(data, link.release); + draft[link.id].state = 'ready'; + }); + }) + .catch(() => { + setLinks((draft) => { + draft[link.id].state = 'init'; + }); + }) + .finally(() => inFlight.current.searching.delete(link.id)); + }); + + const processSubmit = useEffectEvent((link: ManualLinkType) => { + if (inFlight.current.submitting.has(link.id)) return; + inFlight.current.submitting.add(link.id); + + submitReleaseInfo({ fileId: link.file.ID, release: link.release }) + .then(() => { + setLinks((draft) => { + draft[link.id].state = 'submitted'; + }); + }) + .catch(() => { + setLinks((draft) => { + draft[link.id].state = 'ready'; + }); + }) + .finally(() => inFlight.current.submitting.delete(link.id)); + }); + + const processLinked = useEffectEvent((link: ManualLinkType) => { + if (inFlight.current.fetching.has(link.id)) return; + inFlight.current.fetching.add(link.id); + + fetchReleaseInfo(link.file.ID) + .then((data) => { + if (!data) { + setLinks((draft) => { + draft[link.id].state = 'init'; + }); + return; + } + + setLinks((draft) => { + draft[link.id].release = data; + draft[link.id].state = 'ready'; + }); + }) + .catch(() => { + setLinks((draft) => { + draft[link.id].state = 'init'; + }); + }) + .finally(() => inFlight.current.fetching.delete(link.id)); + }); + + useEffect(() => { + if (!initialized || !links.length) return; + + links.forEach((link) => { + if (link.state === 'pre-init') { + processPreInit(link); + } else if (link.state === 'searching') { + processSearch(link); + } else if (link.state === 'submitting') { + processSubmit(link); + } else if (link.state === 'fetching') { + processLinked(link); + } + }); + }, [initialized, links]); + + useEffect(() => () => { + inFlight.current.initializing.clear(); + inFlight.current.searching.clear(); + inFlight.current.submitting.clear(); + inFlight.current.fetching.clear(); + }, []); + + const cancelActiveWork = useEffectEvent(() => { + setLinks((draft) => { + forEach(Object.values(draft), (draftLink) => { + if (draftLink.state === 'submitting') draftLink.state = 'ready'; + else if (['searching', 'fetching'].includes(draftLink.state)) draftLink.state = 'init'; + }); + }); + }); + + return { cancelActiveWork }; +}; + +export default useLinkWorkflow; diff --git a/src/pages/dashboard/panels/UnrecognizedFiles.tsx b/src/pages/dashboard/panels/UnrecognizedFiles.tsx index 7ac672fb6..8df1c919d 100644 --- a/src/pages/dashboard/panels/UnrecognizedFiles.tsx +++ b/src/pages/dashboard/panels/UnrecognizedFiles.tsx @@ -54,8 +54,7 @@ const FileItem = ({ file }: { file: FileType }) => {