From e587bd3b2d34da172daf909efa8c73faf9aef35b Mon Sep 17 00:00:00 2001 From: Harshith Mohan <26010946+harshithmohan@users.noreply.github.com> Date: Fri, 23 Aug 2024 00:52:04 +0530 Subject: [PATCH] Start working on TMDB linking page (#1010) * Start working on TMDB linking page * Refactor types --- src/components/Collection/SeriesMetadata.tsx | 12 +- src/components/Collection/Tmdb/EpisodeRow.tsx | 85 +++++++ .../Collection/Tmdb/MatchRating.tsx | 38 +++ src/components/Collection/Tmdb/TopPanel.tsx | 74 ++++++ src/components/Input/SelectEpisodeList.tsx | 2 +- src/components/Utilities/ItemCount.tsx | 8 +- .../MultiplesUtilEpisode.tsx | 2 +- .../ReleaseManagement/MultiplesUtilList.tsx | 2 +- .../Utilities/Renamer/ConfigModal.tsx | 2 +- .../Utilities/Renamer/RenamerScript.tsx | 2 +- .../Utilities/Renamer/RenamerSettings.tsx | 2 +- src/core/react-query/episode/queries.ts | 4 +- src/core/react-query/renamer/helpers.ts | 8 +- src/core/react-query/renamer/mutations.ts | 3 +- src/core/react-query/renamer/queries.ts | 8 +- src/core/react-query/renamer/types.ts | 80 +------ src/core/react-query/series/queries.ts | 4 +- src/core/react-query/series/types.ts | 3 +- src/core/react-query/tmdb/queries.ts | 62 +++++ src/core/react-query/tmdb/types.ts | 5 + src/core/router/index.tsx | 2 + src/core/slices/utilities/renamer.ts | 2 +- src/core/types/api/episode.ts | 18 +- src/core/types/api/renamer.ts | 68 ++++++ src/core/types/api/tmdb.ts | 38 +++ src/core/utilities/getEpisodePrefix.ts | 20 +- .../collection/series/SeriesEpisodes.tsx | 9 +- .../collection/series/SeriesTmdbLinking.tsx | 225 ++++++++++++++++++ .../MultiplesUtil.tsx | 2 +- src/pages/utilities/Renamer.tsx | 2 +- .../utilities/SeriesWithoutFilesUtility.tsx | 2 +- .../ManuallyLinkedTab.tsx | 2 +- 32 files changed, 682 insertions(+), 114 deletions(-) create mode 100644 src/components/Collection/Tmdb/EpisodeRow.tsx create mode 100644 src/components/Collection/Tmdb/MatchRating.tsx create mode 100644 src/components/Collection/Tmdb/TopPanel.tsx create mode 100644 src/core/react-query/tmdb/queries.ts create mode 100644 src/core/react-query/tmdb/types.ts create mode 100644 src/core/types/api/renamer.ts create mode 100644 src/core/types/api/tmdb.ts create mode 100644 src/pages/collection/series/SeriesTmdbLinking.tsx diff --git a/src/components/Collection/SeriesMetadata.tsx b/src/components/Collection/SeriesMetadata.tsx index 36d82a545..d2bd3964d 100644 --- a/src/components/Collection/SeriesMetadata.tsx +++ b/src/components/Collection/SeriesMetadata.tsx @@ -1,4 +1,5 @@ import React, { useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; import { mdiCloseCircleOutline, mdiOpenInNew, mdiPencilCircleOutline, mdiPlusCircleOutline } from '@mdi/js'; import { Icon } from '@mdi/react'; @@ -14,6 +15,7 @@ type Props = { }; const MetadataLink = ({ id, seriesId, site, type }: Props) => { + const navigate = useNavigate(); const { mutate: deleteTmdbLink } = useDeleteTmdbLinkMutation(type ?? 'Movie'); const { mutate: deleteTvdbLink } = useDeleteSeriesTvdbLinkMutation(); @@ -34,7 +36,13 @@ const MetadataLink = ({ id, seriesId, site, type }: Props) => { } }, [id, site, type]); - const canRemoveLink = useMemo(() => site === 'TvDB' || site === 'TMDB', [site]); + const canEditLink = useMemo(() => site === 'TMDB', [site]); + const canRemoveLink = useMemo(() => ['TMDB', 'TvDB'].includes(site), [site]); + + const editLink = useEventCallback(() => { + if (!id || !type) return; + navigate(`../tmdb-linking/${type[0].toLowerCase()}${id}`); + }); const removeLink = useEventCallback(() => { if (!id) return; @@ -74,7 +82,7 @@ const MetadataLink = ({ id, seriesId, site, type }: Props) => { {id ? ( <> - + + + + ); +}; + +export default TopPanel; diff --git a/src/components/Input/SelectEpisodeList.tsx b/src/components/Input/SelectEpisodeList.tsx index 97f2219ef..c146499bd 100644 --- a/src/components/Input/SelectEpisodeList.tsx +++ b/src/components/Input/SelectEpisodeList.tsx @@ -6,7 +6,7 @@ import cx from 'classnames'; import { find, toInteger } from 'lodash'; import { EpisodeTypeEnum } from '@/core/types/api/episode'; -import getEpisodePrefix from '@/core/utilities/getEpisodePrefix'; +import { getEpisodePrefix } from '@/core/utilities/getEpisodePrefix'; import useEventCallback from '@/hooks/useEventCallback'; import Input from './Input'; diff --git a/src/components/Utilities/ItemCount.tsx b/src/components/Utilities/ItemCount.tsx index c29d6215b..2dd2ab3ca 100644 --- a/src/components/Utilities/ItemCount.tsx +++ b/src/components/Utilities/ItemCount.tsx @@ -1,14 +1,14 @@ import React from 'react'; -const ItemCount = ({ count, selected, series = false }: { count: number, selected?: number, series?: boolean }) => ( +const ItemCount = ({ count, selected, suffix }: { count: number, selected?: number, suffix?: string }) => (
{count}   - {series && 'Series'} - {!series && (count === 1 ? 'File' : 'Files')} + {suffix && suffix} + {!suffix && (count === 1 ? 'File' : 'Files')} {(selected ?? 0) > 0 && ( <> @@ -18,7 +18,7 @@ const ItemCount = ({ count, selected, series = false }: { count: number, selecte {selected ?? 0}   - {series && 'Series'} + {suffix && suffix} Selected diff --git a/src/components/Utilities/ReleaseManagement/MultiplesUtilEpisode.tsx b/src/components/Utilities/ReleaseManagement/MultiplesUtilEpisode.tsx index 82307c4c1..6039efd19 100644 --- a/src/components/Utilities/ReleaseManagement/MultiplesUtilEpisode.tsx +++ b/src/components/Utilities/ReleaseManagement/MultiplesUtilEpisode.tsx @@ -5,7 +5,7 @@ import { countBy, forEach } from 'lodash'; import FileInfo from '@/components/FileInfo'; import Select from '@/components/Input/Select'; -import getEpisodePrefix from '@/core/utilities/getEpisodePrefix'; +import { getEpisodePrefix } from '@/core/utilities/getEpisodePrefix'; import type { MultipleFileOptionsType } from '@/components/Utilities/constants'; import type { EpisodeType } from '@/core/types/api/episode'; diff --git a/src/components/Utilities/ReleaseManagement/MultiplesUtilList.tsx b/src/components/Utilities/ReleaseManagement/MultiplesUtilList.tsx index 6dbae89c9..0655b93d1 100644 --- a/src/components/Utilities/ReleaseManagement/MultiplesUtilList.tsx +++ b/src/components/Utilities/ReleaseManagement/MultiplesUtilList.tsx @@ -7,7 +7,7 @@ import { useSeriesEpisodesWithMultipleReleases, useSeriesWithMultipleReleases, } from '@/core/react-query/release-management/queries'; -import getEpisodePrefix from '@/core/utilities/getEpisodePrefix'; +import { getEpisodePrefix } from '@/core/utilities/getEpisodePrefix'; import useFlattenListResult from '@/hooks/useFlattenListResult'; import type { UtilityHeaderType } from '@/components/Utilities/constants'; diff --git a/src/components/Utilities/Renamer/ConfigModal.tsx b/src/components/Utilities/Renamer/ConfigModal.tsx index d724c47c1..a133cf58b 100644 --- a/src/components/Utilities/Renamer/ConfigModal.tsx +++ b/src/components/Utilities/Renamer/ConfigModal.tsx @@ -10,7 +10,7 @@ import { useRenamerNewConfigMutation, useRenamerPatchConfigMutation } from '@/co import { useRenamerConfigsQuery, useRenamersQuery } from '@/core/react-query/renamer/queries'; import useEventCallback from '@/hooks/useEventCallback'; -import type { RenamerConfigType } from '@/core/react-query/renamer/types'; +import type { RenamerConfigType } from '@/core/types/api/renamer'; type Props = { config: RenamerConfigType; diff --git a/src/components/Utilities/Renamer/RenamerScript.tsx b/src/components/Utilities/Renamer/RenamerScript.tsx index 6d7476438..e58d28a75 100644 --- a/src/components/Utilities/Renamer/RenamerScript.tsx +++ b/src/components/Utilities/Renamer/RenamerScript.tsx @@ -5,7 +5,7 @@ import { findKey } from 'lodash'; import useEventCallback from '@/hooks/useEventCallback'; -import type { RenamerConfigSettingsType, RenamerSettingsType } from '@/core/react-query/renamer/types'; +import type { RenamerConfigSettingsType, RenamerSettingsType } from '@/core/types/api/renamer'; import type { Updater } from 'use-immer'; const RenamerEditor = lazy( diff --git a/src/components/Utilities/Renamer/RenamerSettings.tsx b/src/components/Utilities/Renamer/RenamerSettings.tsx index b22f7e9c8..a763e744e 100644 --- a/src/components/Utilities/Renamer/RenamerSettings.tsx +++ b/src/components/Utilities/Renamer/RenamerSettings.tsx @@ -5,7 +5,7 @@ import Checkbox from '@/components/Input/Checkbox'; import InputSmall from '@/components/Input/InputSmall'; import useEventCallback from '@/hooks/useEventCallback'; -import type { RenamerConfigSettingsType, RenamerSettingsType } from '@/core/react-query/renamer/types'; +import type { RenamerConfigSettingsType, RenamerSettingsType } from '@/core/types/api/renamer'; import type { Updater } from 'use-immer'; type Props = { diff --git a/src/core/react-query/episode/queries.ts b/src/core/react-query/episode/queries.ts index f122610bc..d3812d133 100644 --- a/src/core/react-query/episode/queries.ts +++ b/src/core/react-query/episode/queries.ts @@ -5,7 +5,7 @@ import { transformListResultSimplified } from '@/core/react-query/helpers'; import type { FileRequestType } from '@/core/react-query/types'; import type { ListResultType } from '@/core/types/api'; -import type { EpisodeAniDBType } from '@/core/types/api/episode'; +import type { AniDBEpisodeType } from '@/core/types/api/episode'; import type { FileType } from '@/core/types/api/file'; export const useEpisodeFilesQuery = ( @@ -21,7 +21,7 @@ export const useEpisodeFilesQuery = ( }); export const useEpisodeAniDBQuery = (episodeId: number, enabled = true) => - useQuery({ + useQuery({ queryKey: ['episode', 'anidb', episodeId], queryFn: () => axios.get(`Episode/${episodeId}/AniDB`), enabled, diff --git a/src/core/react-query/renamer/helpers.ts b/src/core/react-query/renamer/helpers.ts index 08c0b7b8a..9e2f617c7 100644 --- a/src/core/react-query/renamer/helpers.ts +++ b/src/core/react-query/renamer/helpers.ts @@ -3,14 +3,16 @@ import store from '@/core/store'; import type { RenamerConfigResponseType, - RenamerConfigSettingsType, - RenamerConfigType, RenamerRelocateBaseRequestType, RenamerResponseType, +} from '@/core/react-query/renamer/types'; +import type { + RenamerConfigSettingsType, + RenamerConfigType, RenamerResultType, RenamerSettingsType, RenamerType, -} from '@/core/react-query/renamer/types'; +} from '@/core/types/api/renamer'; export const updateResults = (response: RenamerResultType[]) => { const mappedResults = response.reduce( diff --git a/src/core/react-query/renamer/mutations.ts b/src/core/react-query/renamer/mutations.ts index 368770284..c7ddb851d 100644 --- a/src/core/react-query/renamer/mutations.ts +++ b/src/core/react-query/renamer/mutations.ts @@ -7,12 +7,11 @@ import { updateApiErrors, updateResults } from '@/core/react-query/renamer/helpe import type { RenamerConfigResponseType, - RenamerConfigType, RenamerPatchRequestType, RenamerPreviewRequestType, RenamerRelocateRequestType, - RenamerResultType, } from '@/core/react-query/renamer/types'; +import type { RenamerConfigType, RenamerResultType } from '@/core/types/api/renamer'; export const useRenamerPreviewMutation = () => useMutation({ diff --git a/src/core/react-query/renamer/queries.ts b/src/core/react-query/renamer/queries.ts index 7a39ba949..8d56a97af 100644 --- a/src/core/react-query/renamer/queries.ts +++ b/src/core/react-query/renamer/queries.ts @@ -3,12 +3,8 @@ import { useQuery } from '@tanstack/react-query'; import { axios } from '@/core/axios'; import { transformRenamer, transformRenamerConfigs } from '@/core/react-query/renamer/helpers'; -import type { - RenamerConfigResponseType, - RenamerConfigType, - RenamerResponseType, - RenamerType, -} from '@/core/react-query/renamer/types'; +import type { RenamerConfigResponseType, RenamerResponseType } from '@/core/react-query/renamer/types'; +import type { RenamerConfigType, RenamerType } from '@/core/types/api/renamer'; export const useRenamersQuery = (enabled = true) => useQuery({ diff --git a/src/core/react-query/renamer/types.ts b/src/core/react-query/renamer/types.ts index ac296c341..2c5a8467e 100644 --- a/src/core/react-query/renamer/types.ts +++ b/src/core/react-query/renamer/types.ts @@ -1,85 +1,19 @@ +import type { + RenamerConfigBaseType, + RenamerConfigSettingsType, + RenamerSettingsType, + RenamerType, +} from '@/core/types/api/renamer'; import type { Operation } from 'fast-json-patch'; -type RenamerBaseType = { - RenamerID: string; - Version?: string; - Name: string; - Description: string; - Enabled: boolean; - DefaultSettings?: RenamerConfigSettingsType[]; -}; - -export type RenamerResponseType = RenamerBaseType & { +export type RenamerResponseType = RenamerType & { Settings?: RenamerSettingsType[]; }; -export type RenamerType = RenamerBaseType & { - Settings?: Record; -}; - -export type RenamerSettingsType = { - Name: string; - Type: string; - Description?: string; - Language?: CodeLanguageType; - SettingType: SettingTypeType; - MinimumValue?: number; - MaximumValue?: number; -}; - -type CodeLanguageType = - | 'PlainText' - | 'CSharp' - | 'Java' - | 'JavaScript' - | 'TypeScript' - | 'Lua' - | 'Python' - | 'Ini' - | 'Json' - | 'Yaml' - | 'Xml'; - -export type SettingTypeType = - | 'Auto' - | 'Code' - | 'Text' - | 'LargeText' - | 'Integer' - | 'Decimal' - | 'Boolean'; - -export type RenamerConfigSettingsType = { - Name: string; - Value: string | number | boolean; -}; - -type RenamerConfigBaseType = { - RenamerID: string; - Name: string; -}; - export type RenamerConfigResponseType = RenamerConfigBaseType & { Settings?: RenamerConfigSettingsType[]; }; -export type RenamerConfigType = RenamerConfigBaseType & { - Settings?: Record; -}; - -export type RenamerResultType = { - FileID: number; - FileLocationID?: number; - ConfigName?: string; - ImportFolderID?: number; - IsSuccess: boolean; - IsRelocated?: boolean; - IsPreview?: boolean; - ErrorMessage?: string; - RelativePath?: string; - AbsolutePath?: string; -}; - export type RenamerRelocateBaseRequestType = { move?: boolean; rename?: boolean; diff --git a/src/core/react-query/series/queries.ts b/src/core/react-query/series/queries.ts index ec7621a6a..ebb6f4584 100644 --- a/src/core/react-query/series/queries.ts +++ b/src/core/react-query/series/queries.ts @@ -16,7 +16,7 @@ import type { DashboardRequestType, FileRequestType } from '@/core/react-query/t import type { ListResultType } from '@/core/types/api'; import type { CollectionGroupType } from '@/core/types/api/collection'; import type { ImagesType } from '@/core/types/api/common'; -import type { EpisodeAniDBType, EpisodeType } from '@/core/types/api/episode'; +import type { AniDBEpisodeType, EpisodeType } from '@/core/types/api/episode'; import type { FileType } from '@/core/types/api/file'; import type { SeriesAniDBRelatedType, @@ -48,7 +48,7 @@ export const useSeriesAniDBQuery = (anidbId: number, enabled = true) => }); export const useSeriesAniDBEpisodesQuery = (anidbId: number, params: SeriesAniDBEpisodesRequestType, enabled = true) => - useQuery, unknown, EpisodeAniDBType[]>({ + useQuery, unknown, AniDBEpisodeType[]>({ queryKey: ['series', 'anidb', anidbId, 'episodes', params], queryFn: () => axios.get(`Series/AniDB/${anidbId}/Episode`, { params }), select: transformListResultSimplified, diff --git a/src/core/react-query/series/types.ts b/src/core/react-query/series/types.ts index 7db3f7daf..debc53763 100644 --- a/src/core/react-query/series/types.ts +++ b/src/core/react-query/series/types.ts @@ -1,11 +1,12 @@ import type { PaginationType } from '@/core/types/api'; import type { DataSourceType, ImageType } from '@/core/types/api/common'; +import type { EpisodeTypeEnum } from '@/core/types/api/episode'; type SeriesEpisodesBaseRequestType = { includeMissing?: string; includeHidden?: string; includeWatched?: string; - type?: string; + type?: EpisodeTypeEnum[]; search?: string; fuzzy?: boolean; }; diff --git a/src/core/react-query/tmdb/queries.ts b/src/core/react-query/tmdb/queries.ts new file mode 100644 index 000000000..98bf150a6 --- /dev/null +++ b/src/core/react-query/tmdb/queries.ts @@ -0,0 +1,62 @@ +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; + +import { axios } from '@/core/axios'; + +import type { TmdbEpisodeXRefRequestType } from '@/core/react-query/tmdb/types'; +import type { ListResultType, PaginationType } from '@/core/types/api'; +import type { TmdbBaseItemType, TmdbEpisodeType, TmdbEpisodeXRefType, TmdbMovieXRefType } from '@/core/types/api/tmdb'; + +export const useTmdbEpisodeXRefsInfiniteQuery = ( + seriesId: number, + params: TmdbEpisodeXRefRequestType, + enabled = true, +) => + useInfiniteQuery>({ + queryKey: ['series', seriesId, 'tmdb', 'cross-references', 'episode', params], + queryFn: ({ pageParam }) => + axios.get( + `Series/${seriesId}/TMDB/Show/CrossReferences/Episode`, + { + params: { + ...params, + page: pageParam as number, + }, + }, + ), + initialPageParam: 1, + getNextPageParam: (lastPage, _, lastPageParam: number) => { + if (!params.pageSize || lastPage.Total / params.pageSize <= lastPageParam) return undefined; + return lastPageParam + 1; + }, + enabled, + }); + +export const useTmdbMovieXrefsQuery = (seriesId: number, enabled = true) => + useQuery({ + queryKey: ['series', seriesId, 'tmdb', 'cross-references', 'movie'], + queryFn: () => axios.get(`Series/${seriesId}/TMDB/Movie/CrossReferences`), + enabled, + }); + +export const useTmdbShowEpisodesQuery = (showId: number, params: PaginationType, enabled = true) => + useInfiniteQuery>({ + queryKey: ['series', 'tmdb', 'episodes', showId, params], + queryFn: ({ pageParam }) => + axios.get( + `Tmdb/Show/${showId}/Episode`, + { params: { ...params, page: pageParam as number } }, + ), + initialPageParam: 1, + getNextPageParam: (lastPage, _, lastPageParam: number) => { + if (!params.pageSize || lastPage.Total / params.pageSize <= lastPageParam) return undefined; + return lastPageParam + 1; + }, + enabled, + }); + +export const useTmdbShowOrMovieQuery = (tmdbId: number, type: 'tv' | 'movie', enabled = true) => + useQuery({ + queryKey: ['series', 'tmdb', 'show', type, tmdbId], + queryFn: () => axios.get(`Tmdb/${type === 'tv' ? 'Show' : 'Movie'}/${tmdbId}`), + enabled, + }); diff --git a/src/core/react-query/tmdb/types.ts b/src/core/react-query/tmdb/types.ts new file mode 100644 index 000000000..6e86c01b1 --- /dev/null +++ b/src/core/react-query/tmdb/types.ts @@ -0,0 +1,5 @@ +import type { PaginationType } from '@/core/types/api'; + +export type TmdbEpisodeXRefRequestType = { + tmdbShowID?: number; +} & PaginationType; diff --git a/src/core/router/index.tsx b/src/core/router/index.tsx index f0dbc6ee9..0d7895d9b 100644 --- a/src/core/router/index.tsx +++ b/src/core/router/index.tsx @@ -16,6 +16,7 @@ import SeriesFileSummary from '@/pages/collection/series/SeriesFileSummary'; import SeriesImages from '@/pages/collection/series/SeriesImages'; import SeriesOverview from '@/pages/collection/series/SeriesOverview'; import SeriesTags from '@/pages/collection/series/SeriesTags'; +import SeriesTmdbLinking from '@/pages/collection/series/SeriesTmdbLinking'; import DashboardPage from '@/pages/dashboard/DashboardPage'; import Acknowledgement from '@/pages/firstrun/Acknowledgement'; import AniDBAccount from '@/pages/firstrun/AniDBAccount'; @@ -98,6 +99,7 @@ const router = sentryCreateBrowserRouter( } /> } /> } /> + } /> }> } /> } /> diff --git a/src/core/slices/utilities/renamer.ts b/src/core/slices/utilities/renamer.ts index 363c47ab0..80fe4c6f1 100644 --- a/src/core/slices/utilities/renamer.ts +++ b/src/core/slices/utilities/renamer.ts @@ -1,7 +1,7 @@ import { createSlice } from '@reduxjs/toolkit'; -import type { RenamerResultType } from '@/core/react-query/renamer/types'; import type { FileType } from '@/core/types/api/file'; +import type { RenamerResultType } from '@/core/types/api/renamer'; import type { PayloadAction } from '@reduxjs/toolkit'; type State = { diff --git a/src/core/types/api/episode.ts b/src/core/types/api/episode.ts index 618aa4a4a..3ba2c1343 100644 --- a/src/core/types/api/episode.ts +++ b/src/core/types/api/episode.ts @@ -1,5 +1,6 @@ import type { DataSourceType, EpisodeImagesType, RatingType } from './common'; import type { FileType } from '@/core/types/api/file'; +import type { TmdbEpisodeType, TmdbMovieType } from '@/core/types/api/tmdb'; export type EpisodeType = { IDs: EpisodeIDsType; @@ -10,7 +11,11 @@ export type EpisodeType = { ResumePosition: string | null; Watched: string | null; Size: number; - AniDB?: EpisodeAniDBType; + AniDB?: AniDBEpisodeType; + TMDB?: { + Episodes: TmdbEpisodeType[]; + Movies: TmdbMovieType[]; + }; IsHidden: boolean; Files?: FileType[]; }; @@ -43,7 +48,7 @@ export const enum EpisodeTypeEnum { Extra = 'Extra', } -export type EpisodeAniDBType = { +export type AniDBEpisodeType = { ID: number; Type: EpisodeTypeEnum; EpisodeNumber: number; @@ -60,3 +65,12 @@ export type EpisodeFilesQueryType = { isManuallyLinked?: boolean; includeMediaInfo?: boolean; }; + +export enum MatchRatingType { + UserVerified = 'UserVerified', + DateAndTitleMatches = 'DateAndTitleMatches', + DateMatches = 'DateMatches', + TitleMatches = 'TitleMatches', + FirstAvailable = 'FirstAvailable', + None = 'None', +} diff --git a/src/core/types/api/renamer.ts b/src/core/types/api/renamer.ts new file mode 100644 index 000000000..194f26d9c --- /dev/null +++ b/src/core/types/api/renamer.ts @@ -0,0 +1,68 @@ +export type RenamerType = { + RenamerID: string; + Version?: string; + Name: string; + Description: string; + Enabled: boolean; + DefaultSettings?: RenamerConfigSettingsType[]; + Settings?: Record; +}; + +export type RenamerSettingsType = { + Name: string; + Type: string; + Description?: string; + Language?: CodeLanguageType; + SettingType: SettingTypeType; + MinimumValue?: number; + MaximumValue?: number; +}; + +type CodeLanguageType = + | 'PlainText' + | 'CSharp' + | 'Java' + | 'JavaScript' + | 'TypeScript' + | 'Lua' + | 'Python' + | 'Ini' + | 'Json' + | 'Yaml' + | 'Xml'; + +type SettingTypeType = + | 'Auto' + | 'Code' + | 'Text' + | 'LargeText' + | 'Integer' + | 'Decimal' + | 'Boolean'; + +export type RenamerConfigSettingsType = { + Name: string; + Value: string | number | boolean; +}; + +export type RenamerConfigBaseType = { + RenamerID: string; + Name: string; +}; + +export type RenamerConfigType = RenamerConfigBaseType & { + Settings?: Record; +}; + +export type RenamerResultType = { + FileID: number; + FileLocationID?: number; + ConfigName?: string; + ImportFolderID?: number; + IsSuccess: boolean; + IsRelocated?: boolean; + IsPreview?: boolean; + ErrorMessage?: string; + RelativePath?: string; + AbsolutePath?: string; +}; diff --git a/src/core/types/api/tmdb.ts b/src/core/types/api/tmdb.ts new file mode 100644 index 000000000..8495f505a --- /dev/null +++ b/src/core/types/api/tmdb.ts @@ -0,0 +1,38 @@ +import type { MatchRatingType } from '@/core/types/api/episode'; + +export type TmdbEpisodeType = { + ID: number; + SeasonID: string; + ShowID: number; + Title: string; + Overview: string; + EpisodeNumber: number; + SeasonNumber: number; +}; + +export type TmdbBaseItemType = { + ID: number; + Title: string; + Overview: string; +}; + +export type TmdbMovieType = TmdbBaseItemType; + +export type TmdbShowType = TmdbBaseItemType; + +export type TmdbXrefType = { + AnidbAnimeID: number; + AnidbEpisodeID?: number; +}; + +export type TmdbEpisodeXRefType = { + AnidbEpisodeID: number; + TmdbShowID: number; + TmdbEpisodeID?: number; + Index: number; + Rating: MatchRatingType; +} & TmdbXrefType; + +export type TmdbMovieXRefType = { + TmdbMovieID: number; +} & TmdbXrefType; diff --git a/src/core/utilities/getEpisodePrefix.ts b/src/core/utilities/getEpisodePrefix.ts index c46925d39..61c179a07 100644 --- a/src/core/utilities/getEpisodePrefix.ts +++ b/src/core/utilities/getEpisodePrefix.ts @@ -1,6 +1,6 @@ import { EpisodeTypeEnum } from '@/core/types/api/episode'; -const getEpisodePrefix = (type?: EpisodeTypeEnum) => { +export const getEpisodePrefix = (type?: EpisodeTypeEnum) => { switch (type) { case EpisodeTypeEnum.Special: return 'S'; @@ -18,4 +18,20 @@ const getEpisodePrefix = (type?: EpisodeTypeEnum) => { } }; -export default getEpisodePrefix; +export const getEpisodePrefixAlt = (type?: EpisodeTypeEnum) => { + switch (type) { + case EpisodeTypeEnum.Special: + return 'SP'; + case EpisodeTypeEnum.ThemeSong: + return 'C'; + case EpisodeTypeEnum.Trailer: + return 'T'; + case EpisodeTypeEnum.Other: + return 'O'; + case EpisodeTypeEnum.Parody: + return 'P'; + case EpisodeTypeEnum.Normal: + default: + return 'EP'; + } +}; diff --git a/src/pages/collection/series/SeriesEpisodes.tsx b/src/pages/collection/series/SeriesEpisodes.tsx index 505e37759..9cc49f75b 100644 --- a/src/pages/collection/series/SeriesEpisodes.tsx +++ b/src/pages/collection/series/SeriesEpisodes.tsx @@ -14,6 +14,7 @@ import Button from '@/components/Input/Button'; import toast from '@/components/Toast'; import { useWatchSeriesEpisodesMutation } from '@/core/react-query/series/mutations'; import { useSeriesEpisodesInfiniteQuery, useSeriesQuery } from '@/core/react-query/series/queries'; +import { EpisodeTypeEnum } from '@/core/types/api/episode'; import { dayjs } from '@/core/util'; import useEventCallback from '@/hooks/useEventCallback'; import useFlattenListResult from '@/hooks/useFlattenListResult'; @@ -24,7 +25,7 @@ const pageSize = 26; const SeriesEpisodes = () => { const { seriesId } = useParams(); - const [episodeFilterType, setEpisodeFilterType] = useState('Normal'); + const [episodeFilterType, setEpisodeFilterType] = useState(EpisodeTypeEnum.Normal); const [episodeFilterAvailability, setEpisodeFilterAvailability] = useState('false'); const [episodeFilterWatched, setEpisodeFilterWatched] = useState('true'); const [episodeFilterHidden, setEpisodeFilterHidden] = useState('false'); @@ -40,7 +41,7 @@ const SeriesEpisodes = () => { const { id: eventType, value } = event.target; switch (eventType) { case 'episodeType': - setEpisodeFilterType(value); + setEpisodeFilterType(value as EpisodeTypeEnum); break; case 'status': setEpisodeFilterAvailability(value); @@ -73,7 +74,7 @@ const SeriesEpisodes = () => { { includeMissing: episodeFilterAvailability, includeHidden: episodeFilterHidden, - type: episodeFilterType, + type: [episodeFilterType], includeWatched: episodeFilterWatched, includeDataFrom: ['AniDB'], search: debouncedSearch, @@ -136,7 +137,7 @@ const SeriesEpisodes = () => { seriesId: toNumber(seriesId), includeMissing: episodeFilterAvailability, includeHidden: episodeFilterHidden, - type: episodeFilterType, + type: [episodeFilterType], includeWatched: episodeFilterWatched, value: watched, search: debouncedSearch, diff --git a/src/pages/collection/series/SeriesTmdbLinking.tsx b/src/pages/collection/series/SeriesTmdbLinking.tsx new file mode 100644 index 000000000..406c9cb40 --- /dev/null +++ b/src/pages/collection/series/SeriesTmdbLinking.tsx @@ -0,0 +1,225 @@ +import React, { useMemo } from 'react'; +import { useParams } from 'react-router'; +import { useNavigate, useOutletContext } from 'react-router-dom'; +import { mdiLoading, mdiOpenInNew } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { debounce, toNumber } from 'lodash'; + +import EpisodeRow from '@/components/Collection/Tmdb/EpisodeRow'; +import TopPanel from '@/components/Collection/Tmdb/TopPanel'; +import { useSeriesEpisodesInfiniteQuery, useSeriesQuery } from '@/core/react-query/series/queries'; +import { + useTmdbEpisodeXRefsInfiniteQuery, + useTmdbMovieXrefsQuery, + useTmdbShowOrMovieQuery, +} from '@/core/react-query/tmdb/queries'; +import { EpisodeTypeEnum } from '@/core/types/api/episode'; +import useFlattenListResult from '@/hooks/useFlattenListResult'; + +import type { SeriesContextType } from '@/components/Collection/constants'; +import type { TmdbEpisodeXRefType } from '@/core/types/api/tmdb'; + +const SeriesTmdbLinking = () => { + const { seriesId, tmdbId: fullTmdbId } = useParams(); + const navigate = useNavigate(); + + if (!seriesId) { + navigate('..'); + } + + const [tmdbId, type] = useMemo<[number, 'tv' | 'movie' | null]>( + () => { + if (!fullTmdbId) return [0, null]; + const tmdbType = fullTmdbId.startsWith('s') ? 'tv' : 'movie'; + const id = toNumber(fullTmdbId.slice(1)); + return [id, tmdbType]; + }, + [fullTmdbId], + ); + + const seriesQuery = useSeriesQuery(toNumber(seriesId!), {}, !!seriesId); + + const { + data: episodesData, + fetchNextPage, + isFetchingNextPage, + isPending: episodesIsPending, + isSuccess: episodesIsSuccess, + } = useSeriesEpisodesInfiniteQuery( + toNumber(seriesId!), + { + includeDataFrom: ['AniDB', 'TMDB'], + type: [EpisodeTypeEnum.Normal, EpisodeTypeEnum.Special, EpisodeTypeEnum.Other], + pageSize: 50, + }, + !!seriesId, + ); + const [episodes, episodeCount] = useFlattenListResult(episodesData); + + const episodeXrefsQuery = useTmdbEpisodeXRefsInfiniteQuery( + toNumber(seriesId!), + { tmdbShowID: tmdbId, pageSize: 0 }, + !!seriesId && type === 'tv', + ); + const [episodeXrefs] = useFlattenListResult(episodeXrefsQuery.data); + + const movieXrefsQuery = useTmdbMovieXrefsQuery( + toNumber(seriesId!), + !!seriesId && type === 'movie', + ); + + const xrefs = useMemo( + () => (type === 'tv' ? episodeXrefs : movieXrefsQuery.data ?? []), + [episodeXrefs, movieXrefsQuery.data, type], + ); + + const tmdbShowOrMovieQuery = useTmdbShowOrMovieQuery(tmdbId, type!, !!type && tmdbId !== 0); + + const isPending = useMemo( + () => + seriesQuery.isPending + || episodesIsPending + || tmdbShowOrMovieQuery.isPending + || (type === 'tv' && episodeXrefsQuery.isPending) + || (type === 'movie' && movieXrefsQuery.isPending), + [ + seriesQuery.isPending, + episodesIsPending, + tmdbShowOrMovieQuery.isPending, + type, + episodeXrefsQuery.isPending, + movieXrefsQuery.isPending, + ], + ); + + const isSuccess = useMemo( + () => + seriesQuery.isSuccess + && episodesIsSuccess + && tmdbShowOrMovieQuery.isSuccess + && (type === 'movie' || episodeXrefsQuery.isSuccess) + && (type === 'tv' || movieXrefsQuery.isSuccess), + [ + seriesQuery.isSuccess, + episodesIsSuccess, + tmdbShowOrMovieQuery.isSuccess, + type, + episodeXrefsQuery.isSuccess, + movieXrefsQuery.isSuccess, + ], + ); + + const { scrollRef } = useOutletContext(); + + const rowVirtualizer = useVirtualizer({ + count: episodeCount, + getScrollElement: () => scrollRef.current, + estimateSize: () => 56, // 56px is the minimum height of a loaded row, + overscan: 5, + gap: 8, + }); + const virtualItems = rowVirtualizer.getVirtualItems(); + + const fetchNextPageDebounced = useMemo( + () => + debounce(() => { + fetchNextPage().catch(() => {}); + }, 100), + [fetchNextPage], + ); + + return ( +
+ +
+ {isPending && ( +
+ +
+ )} + + {isSuccess && ( + <> +
+ + {type === 'tv' && + +
+ {virtualItems.map((virtualItem) => { + const episode = episodes[virtualItem.index]; + + if (!episode && !isFetchingNextPage) fetchNextPageDebounced(); + + return ( +
+ {episode + ? ( + + ) + : ( + ( + <> +
+ +
+ {type === 'tv' &&
} +
+ +
+ + ) + )} +
+ ); + })} +
+ + )} +
+
+ ); +}; + +export default SeriesTmdbLinking; diff --git a/src/pages/utilities/ReleaseManagementUtilityTabs/MultiplesUtil.tsx b/src/pages/utilities/ReleaseManagementUtilityTabs/MultiplesUtil.tsx index 6ed670c2b..3a8f77c95 100644 --- a/src/pages/utilities/ReleaseManagementUtilityTabs/MultiplesUtil.tsx +++ b/src/pages/utilities/ReleaseManagementUtilityTabs/MultiplesUtil.tsx @@ -67,7 +67,7 @@ const MultiplesUtil = () => { return (
- } options={}> + } options={}>
({ id: 'filename', diff --git a/src/pages/utilities/SeriesWithoutFilesUtility.tsx b/src/pages/utilities/SeriesWithoutFilesUtility.tsx index 2a8fd5fc3..2e9bf6740 100644 --- a/src/pages/utilities/SeriesWithoutFilesUtility.tsx +++ b/src/pages/utilities/SeriesWithoutFilesUtility.tsx @@ -162,7 +162,7 @@ function SeriesWithoutFilesUtility() {
} + options={} >
value).length} - series + suffix="Series" /> } >