diff --git a/src/components/Collection/Anilist/AnilistLinkSelectPanel.tsx b/src/components/Collection/Anilist/AnilistLinkSelectPanel.tsx new file mode 100644 index 000000000..61a6142ae --- /dev/null +++ b/src/components/Collection/Anilist/AnilistLinkSelectPanel.tsx @@ -0,0 +1,197 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useParams, useSearchParams } from 'react-router'; +import { mdiLoading, mdiMagnify, mdiOpenInNew, mdiRefresh } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import cx from 'classnames'; +import { toNumber } from 'lodash'; +import { useDebounceValue } from 'usehooks-ts'; + +import Button from '@/components/Input/Button'; +import Input from '@/components/Input/Input'; +import { getAnilistUnavailableMessage, getAnilistUnavailableState } from '@/core/react-query/anilist/helpers'; +import { useAnilistRefreshMutation } from '@/core/react-query/anilist/mutations'; +import { useAnilistAutoSearchQuery, useAnilistSearchQuery } from '@/core/react-query/anilist/queries'; +import { useSettingsQuery } from '@/core/react-query/settings/queries'; +import toast from '@/core/toast'; +import { getAnilistAnimeLink } from '@/core/util'; + +import type { AnilistSearchResultType } from '@/core/types/api/anilist'; + +type SearchResultRowProps = { + result: AnilistSearchResultType; + selectLink: (anilistId: number) => void; +}; + +const SearchResultRow = ({ result, selectLink }: SearchResultRowProps) => { + const handleClick = () => { + selectLink(result.ID); + }; + + return ( +
+ + {result.ID} + + +
+ | + {result.Title} +
+
+ ); +}; + +const AnilistLinkSelectPanel = () => { + const { seriesId } = useParams(); + + const [, setSearchParams] = useSearchParams(); + + const [selectedId, setSelectedId] = useState(0); + const [searchText, setSearchText] = useState(''); + const [debouncedSearch] = useDebounceValue(searchText, 200); + + const { includeRestricted } = useSettingsQuery().data.WebUI_Settings.collection.anilist; + + const autoSearchQuery = useAnilistAutoSearchQuery(toNumber(seriesId), debouncedSearch === '' && !!seriesId); + const autoSearchResults = useMemo( + () => autoSearchQuery.data?.map(result => result.Anime) ?? [], + [autoSearchQuery.data], + ); + + const searchQuery = useAnilistSearchQuery(debouncedSearch, { + includeRestricted, + pageSize: 25, + }); + const { isPending: refreshPending, mutate: refreshData } = useAnilistRefreshMutation(); + + const activeQuery = debouncedSearch === '' ? autoSearchQuery : searchQuery; + const unavailableState = useMemo(() => getAnilistUnavailableState(activeQuery.error), [activeQuery.error]); + + const noResults = useMemo(() => { + if (unavailableState) return false; + if (debouncedSearch === '') return autoSearchResults.length === 0; + return searchQuery.data?.length === 0; + }, [autoSearchResults, debouncedSearch, searchQuery.data, unavailableState]); + + const handleRetry = () => { + activeQuery.refetch().catch(console.error); + }; + + const isPending = useMemo( + () => autoSearchQuery.isLoading || searchQuery.isLoading || refreshPending, + [autoSearchQuery.isLoading, refreshPending, searchQuery.isLoading], + ); + + const selectLink = (anilistId: number) => { + setSelectedId(anilistId); + }; + + useEffect(() => { + if (selectedId === 0) return; + + refreshData( + { + anilistId: selectedId, + Immediate: true, + QuickRefresh: true, + }, + { + onSuccess: () => setSearchParams({ id: selectedId.toString() }), + onError: (error) => { + const state = getAnilistUnavailableState(error); + toast.error(state ? getAnilistUnavailableMessage(state) : 'Failed to refresh data!'); + setSelectedId(0); + }, + }, + ); + }, [refreshData, selectedId, setSearchParams]); + + return ( +
+
+
+ AniList |  +
+ Not linked +
+
+
+ + setSearchText(event.target.value)} + placeholder="Enter Title or AniList ID..." + inputClassName="!p-4" + startIcon={mdiMagnify} + autoFocus + /> + +
+ {isPending && ( +
+ +
+ )} + + {!isPending && ( +
+ {debouncedSearch === '' && autoSearchResults.map(result => ( + + ))} + + {debouncedSearch && searchQuery.data?.map(result => ( + + ))} + + {noResults && ( +
+ No results found! +
+ )} + + {unavailableState && ( +
+ {getAnilistUnavailableMessage(unavailableState)} + +
+ )} +
+ )} +
+
+ ); +}; + +export default AnilistLinkSelectPanel; diff --git a/src/components/Collection/Anilist/EpisodeRow.tsx b/src/components/Collection/Anilist/EpisodeRow.tsx new file mode 100644 index 000000000..e06835922 --- /dev/null +++ b/src/components/Collection/Anilist/EpisodeRow.tsx @@ -0,0 +1,170 @@ +import { useMemo } from 'react'; +import { useSearchParams } from 'react-router'; +import { mdiLoading } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import cx from 'classnames'; +import { find, map, toNumber } from 'lodash'; + +import EpisodeSelect from '@/components/Collection/Anilist/EpisodeSelect'; +import AniDBEpisode from '@/components/Collection/Tmdb/AniDBEpisode'; +import MatchRating from '@/components/Collection/Tmdb/MatchRating'; + +import type { AnilistEpisodeType, AnilistEpisodeXrefType } from '@/core/types/api/anilist'; +import type { EpisodeType } from '@/core/types/api/episode'; +import type { Updater } from 'use-immer'; + +type Props = { + anilistEpisodes?: AnilistEpisodeType[]; + anilistEpisodesPending: boolean; + episode: EpisodeType; + existingXrefs?: number[]; + isOdd: boolean; + offset: number; + setLinkOverrides: Updater>; + xrefs?: Record; +}; + +const EpisodeRow = (props: Props) => { + const { + anilistEpisodes, + anilistEpisodesPending, + episode, + existingXrefs, + isOdd, + offset, + setLinkOverrides, + xrefs, + } = props; + + const [searchParams] = useSearchParams(); + const anilistId = toNumber(searchParams.get('id')); + + const xref = useMemo( + () => { + if (!xrefs?.[episode.IDs.AniDB]) return undefined; + return xrefs[episode.IDs.AniDB][offset]; + }, + [episode.IDs.AniDB, offset, xrefs], + ); + + const anilistEpisode = useMemo(() => { + if (!xref || xref.AnilistEpisodeID === 0) return undefined; + return find(anilistEpisodes, { ID: xref.AnilistEpisodeID }); + }, [anilistEpisodes, xref]); + + const isDisabled = useMemo(() => { + if (!xref || xref.AnilistEpisodeID === 0) return false; + return xref.AnilistAnimeID !== anilistId; + }, [anilistId, xref]); + + const isPending = useMemo( + () => { + // Xrefs are not loaded yet + if (!xrefs) return true; + // Xrefs are loaded but episode doesn't have an xref + if (!xref) return false; + // Episodes for another anime are never loaded, so don't wait for them + if (isDisabled) return false; + + return !anilistEpisode && anilistEpisodesPending; + }, + [anilistEpisode, anilistEpisodesPending, isDisabled, xref, xrefs], + ); + + const editExtraEpisodeLink = () => { + const episodeId = episode.IDs.AniDB; + setLinkOverrides((draftState) => { + if (!draftState[episodeId]) { + draftState[episodeId] = map(xrefs?.[episodeId], item => item.AnilistEpisodeID); + } + + // If offset is 0, we are adding a link + if (offset === 0) { + draftState[episodeId].push(0); + return; + } + + draftState[episodeId].splice(offset, 1); + + // When existing xrefs are present and are more than 1, + // we need to keep the first link to "overwrite" others + if (existingXrefs && existingXrefs.length > 1) { + return; + } + + // When only one is preset, we can remove the override if existingXref was present + if (draftState[episodeId].length === 1 && existingXrefs) { + delete draftState[episodeId]; + } + }); + }; + + const overrideLink = (newAnilistId?: number) => { + const episodeId = episode.IDs.AniDB; + setLinkOverrides((draftState) => { + if (!draftState[episodeId]) { + draftState[episodeId] = map(xrefs?.[episodeId], item => item.AnilistEpisodeID); + } + + if (newAnilistId === undefined) { + draftState[episodeId].splice(offset, 1); + return; + } + + if (newAnilistId === 0 && !existingXrefs && offset === 0) { + delete draftState[episodeId]; + return; + } + + draftState[episodeId][offset] = newAnilistId; + }); + }; + + const matchRating = useMemo(() => { + if (isPending) return undefined; + return xref?.Rating; + }, [isPending, xref]); + + return ( + <> + 0} + onIconClick={(offset > 0 || (anilistEpisode ?? xref?.AnilistEpisodeID)) ? editExtraEpisodeLink : undefined} + /> + + + + {!isPending && ( + + )} + + {isPending + && ( +
+ +
+ )} + + ); +}; + +export default EpisodeRow; diff --git a/src/components/Collection/Anilist/EpisodeSelect.tsx b/src/components/Collection/Anilist/EpisodeSelect.tsx new file mode 100644 index 000000000..dd6d83c44 --- /dev/null +++ b/src/components/Collection/Anilist/EpisodeSelect.tsx @@ -0,0 +1,213 @@ +import { useMemo, useState } from 'react'; +import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react'; +import { mdiChevronDown, mdiLoading, mdiMagnify } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import cx from 'classnames'; +import { find } from 'lodash'; +import { useDebounceValue } from 'usehooks-ts'; + +import Input from '@/components/Input/Input'; +import { dayjs, padNumber } from '@/core/util'; + +import type { AnilistEpisodeType } from '@/core/types/api/anilist'; + +type Props = { + anilistEpisode?: AnilistEpisodeType; + anilistEpisodes?: AnilistEpisodeType[]; + fallbackEpisodeNumber?: number; + isDisabled: boolean; + isOdd: boolean; + override?: number; + overrideLink: (newAnilistId?: number) => void; +}; + +const getAiredAt = (episode?: AnilistEpisodeType) => { + if (!episode) return ''; + if (!episode.AiredAt) return 'Airdate Unknown'; + return dayjs(episode.AiredAt).format('YYYY-MM-DD'); +}; + +const EpisodeSelect = (props: Props) => { + const { + anilistEpisode: initialAnilistEpisode, + anilistEpisodes, + fallbackEpisodeNumber, + isDisabled, + isOdd, + override, + overrideLink, + } = props; + + const [searchText, setSearchText] = useState(''); + const [debouncedSearch] = useDebounceValue(searchText, 200); + + const episodes = useMemo(() => { + const search = debouncedSearch.trim().toLowerCase(); + if (!anilistEpisodes) return []; + if (!search) return anilistEpisodes; + return anilistEpisodes.filter(episode => + episode.EpisodeNumber.toString().includes(search) + || `episode ${episode.EpisodeNumber}`.includes(search) + ); + }, [anilistEpisodes, debouncedSearch]); + + const anilistEpisode = useMemo(() => { + if (override && override !== initialAnilistEpisode?.ID) { + return find(anilistEpisodes, { ID: override }) ?? initialAnilistEpisode; + } + return initialAnilistEpisode; + }, [anilistEpisodes, initialAnilistEpisode, override]); + + const handleSelect = (newSelectedEpisode?: AnilistEpisodeType) => { + overrideLink(newSelectedEpisode?.ID ?? 0); + }; + + const [scrollElement, setScrollElement] = useState(null); + const rowVirtualizer = useVirtualizer({ + count: episodes.length + 1, + getScrollElement: () => scrollElement, + estimateSize: () => 26, + overscan: 5, + gap: 8, + }); + const virtualItems = rowVirtualizer.getVirtualItems(); + + const episodeNumber = anilistEpisode?.EpisodeNumber ?? fallbackEpisodeNumber; + const episodeTitle = episodeNumber ? `Episode ${episodeNumber}` : undefined; + + return ( + + + {({ open }) => ( + <> +
+ {episodeNumber ? padNumber(episodeNumber) : 'XX'} +
+ +
+
+ {episodeTitle ? getAiredAt(anilistEpisode) : ''} +
+
+ {episodeTitle ?? 'Entry Not Linked'} +
+
+ + + + )} +
+ + + setSearchText(event.target.value)} + onKeyDown={event => event.stopPropagation()} + placeholder="Enter Episode Number..." + inputClassName="!p-4" + startIcon={mdiMagnify} + /> + +
+
+ {!anilistEpisodes && ( +
+ +
+ )} + + {anilistEpisodes && ( +
+ {virtualItems.map((virtualItem) => { + const { index, key, start } = virtualItem; + + const episode = index === 0 ? undefined : episodes[index - 1]; + + return ( + +
+ {episode ? `E${padNumber(episode.EpisodeNumber)}` : 'XX'} +
+ | + +
+ {episode ? `Episode ${episode.EpisodeNumber}` : 'Do Not Link Entry'} +
+ +
+ {episode?.AiredAt ? getAiredAt(episode) : ''} +
+
+ ); + })} +
+ )} +
+
+
+
+
+ ); +}; + +export default EpisodeSelect; diff --git a/src/components/Collection/Series/EditSeriesTabs/UpdateActionsTab.tsx b/src/components/Collection/Series/EditSeriesTabs/UpdateActionsTab.tsx index 83038a6ca..23e439e93 100644 --- a/src/components/Collection/Series/EditSeriesTabs/UpdateActionsTab.tsx +++ b/src/components/Collection/Series/EditSeriesTabs/UpdateActionsTab.tsx @@ -1,8 +1,11 @@ import Action from '@/components/Collection/Series/EditSeriesTabs/Action'; import { + useAutoSearchAnilistMatchMutation, useAutoSearchTmdbMatchMutation, useRefreshSeriesAniDBInfoMutation, + useRefreshSeriesAnilistInfoMutation, useRefreshSeriesTMDBInfoMutation, + useUpdateSeriesAnilistImagesMutation, useUpdateSeriesTMDBImagesMutation, } from '@/core/react-query/series/mutations'; @@ -15,6 +18,9 @@ const UpdateActionsTab = ({ seriesId }: Props) => { const { mutate: autoMatchTmdb } = useAutoSearchTmdbMatchMutation(seriesId); const { mutate: refreshTmdb } = useRefreshSeriesTMDBInfoMutation(seriesId); const { mutate: updateTmdbImagesMutation } = useUpdateSeriesTMDBImagesMutation(seriesId); + const { mutate: autoMatchAnilist } = useAutoSearchAnilistMatchMutation(seriesId); + const { mutate: refreshAnilist } = useRefreshSeriesAnilistInfoMutation(seriesId); + const { mutate: updateAnilistImagesMutation } = useUpdateSeriesAnilistImagesMutation(seriesId); const triggerAnidbRefresh = (force: boolean, cacheOnly: boolean) => { refreshAnidb({ force, cacheOnly }); @@ -24,6 +30,10 @@ const UpdateActionsTab = ({ seriesId }: Props) => { updateTmdbImagesMutation({ force: true }); }; + const updateAnilistImagesForce = () => { + updateAnilistImagesMutation({ force: true }); + }; + return (
{ description="Forces a complete redownload of images from TMDB." onClick={updateTmdbImagesForce} /> + + +
); }; diff --git a/src/components/Collection/Series/ImageUploadModal.tsx b/src/components/Collection/Series/ImageUploadModal.tsx index fd50618a2..67b754055 100644 --- a/src/components/Collection/Series/ImageUploadModal.tsx +++ b/src/components/Collection/Series/ImageUploadModal.tsx @@ -19,6 +19,7 @@ import type { ImageTabType } from '@/core/types/api/image'; const tabLabelMap: Record = { Posters: { label: 'Poster', serverType: 'Primary' }, Backdrops: { label: 'Backdrop', serverType: 'Backdrop' }, + Banners: { label: 'Banner', serverType: 'Banner' }, Logos: { label: 'Logo', serverType: 'Logo' }, }; diff --git a/src/components/Collection/SeriesMetadata.tsx b/src/components/Collection/SeriesMetadata.tsx index 3827c46bd..f29556ce3 100644 --- a/src/components/Collection/SeriesMetadata.tsx +++ b/src/components/Collection/SeriesMetadata.tsx @@ -3,6 +3,8 @@ import { mdiCloseCircleOutline, mdiOpenInNew, mdiPencilCircleOutline, mdiPlusCir import { Icon } from '@mdi/react'; import Button from '@/components/Input/Button'; +import { getAnilistAnimeLink } from '@/core/anilistUtils'; +import { useDeleteAnilistLinkMutation } from '@/core/react-query/anilist/mutations'; import { invalidateQueries } from '@/core/react-query/queryClient'; import { useDeleteTmdbLinkMutation } from '@/core/react-query/tmdb/mutations'; import { getAnidbAnimeLink } from '@/core/util'; @@ -11,19 +13,22 @@ import useNavigateVoid from '@/hooks/useNavigateVoid'; type Props = { id?: number; seriesId: number; - site: 'AniDB' | 'TMDB'; + site: 'AniDB' | 'AniList' | 'TMDB'; type?: 'Movie' | 'Show'; }; const SeriesMetadata = ({ id, seriesId, site, type }: Props) => { const navigate = useNavigateVoid(); const { mutate: deleteTmdbLink } = useDeleteTmdbLinkMutation(seriesId, type ?? 'Movie'); + const { mutate: deleteAnilistLink } = useDeleteAnilistLinkMutation(seriesId); const siteLink = useMemo(() => { if (!id) return '#'; switch (site) { case 'AniDB': return getAnidbAnimeLink(id); + case 'AniList': + return getAnilistAnimeLink(id); case 'TMDB': return `https://www.themoviedb.org/${type === 'Show' ? 'tv' : 'movie'}/${id}`; default: @@ -31,16 +36,25 @@ const SeriesMetadata = ({ id, seriesId, site, type }: Props) => { } }, [id, site, type]); - const canAddLink = useMemo(() => site === 'TMDB', [site]); - const canEditLink = useMemo(() => site === 'TMDB', [site]); - const canRemoveLink = useMemo(() => site === 'TMDB', [site]); + const canAddLink = useMemo(() => site === 'TMDB' || site === 'AniList', [site]); + const canEditLink = useMemo(() => site === 'TMDB' || site === 'AniList', [site]); + const canRemoveLink = useMemo(() => site === 'TMDB' || site === 'AniList', [site]); const addLink = () => { + if (site === 'AniList') { + navigate('../anilist-linking'); + return; + } navigate('../tmdb-linking'); }; const editLink = () => { - if (!id || !type) return; + if (!id) return; + if (site === 'AniList') { + navigate(`../anilist-linking?id=${id}`); + return; + } + if (!type) return; navigate(`../tmdb-linking?type=${type}&id=${id}`); }; @@ -52,6 +66,11 @@ const SeriesMetadata = ({ id, seriesId, site, type }: Props) => { onSuccess: () => invalidateQueries(['series', seriesId]), }); break; + case 'AniList': + deleteAnilistLink({ ID: id }, { + onSuccess: () => invalidateQueries(['series', seriesId]), + }); + break; default: break; } @@ -77,7 +96,8 @@ const SeriesMetadata = ({ id, seriesId, site, type }: Props) => { : ( <> {site === 'TMDB' && 'Add TMDB Link'} - {site !== 'TMDB' && 'Series Not Linked'} + {site === 'AniList' && 'Add AniList Link'} + {site === 'AniDB' && 'Series Not Linked'} )} diff --git a/src/components/Collection/Tmdb/MatchRating.tsx b/src/components/Collection/Tmdb/MatchRating.tsx index 218e6c8e1..f3aa7b2c1 100644 --- a/src/components/Collection/Tmdb/MatchRating.tsx +++ b/src/components/Collection/Tmdb/MatchRating.tsx @@ -16,6 +16,10 @@ const getAbbreviation = (rating?: MatchRatingValues) => { return ['~T', 'Approx. Title']; case 'DateKindaMatches': return ['~D', 'Approx. Date']; + case 'DateAndNumberMatches': + return ['DN', 'Date & Number']; + case 'DateOffsetMatches': + return ['~DO', 'Date Offset']; case 'UserVerified': return ['UO', 'User Override']; case 'FirstAvailable': @@ -37,9 +41,10 @@ const MatchRating = ({ isDisabled, isOdd, rating }: Props) => ( 'flex w-16 items-center justify-center rounded-md border border-panel-border text-button-primary-text', { 'bg-panel-text-important': rating === 'DateAndTitleMatches' - || rating === 'TitleMatches', + || rating === 'TitleMatches' || rating === 'DateAndNumberMatches', 'bg-panel-text-warning': rating === 'DateMatches' || rating === 'TitleKindaMatches' - || rating === 'DateAndTitleKindaMatches' || rating === 'DateKindaMatches', + || rating === 'DateAndTitleKindaMatches' || rating === 'DateKindaMatches' + || rating === 'DateOffsetMatches', 'bg-panel-text-primary': rating === 'UserVerified', 'bg-panel-text-danger': rating === 'FirstAvailable', 'bg-panel-background': (!rating || rating === 'None') && !isOdd, diff --git a/src/components/Collection/Tmdb/TopPanel.tsx b/src/components/Collection/Tmdb/TopPanel.tsx index 051dcf0ad..345f044e0 100644 --- a/src/components/Collection/Tmdb/TopPanel.tsx +++ b/src/components/Collection/Tmdb/TopPanel.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { mdiLinkPlus } from '@mdi/js'; +import { mdiLinkPlus, mdiRestore } from '@mdi/js'; import { Icon } from '@mdi/react'; import cx from 'classnames'; import { countBy, filter, flatMap } from 'lodash'; @@ -10,19 +10,24 @@ import ItemCount from '@/components/Utilities/ItemCount'; import useNavigateVoid from '@/hooks/useNavigateVoid'; import type { MatchRatingValues } from '@/core/types/api/episode'; -import type { TmdbEpisodeXrefType } from '@/core/types/api/tmdb'; + +type RatedXrefType = { + Rating: MatchRatingValues; +}; type Props = { createInProgress: boolean; disableCreateLink: boolean; handleCreateLink: () => void; + handleResetLinks?: () => void; seriesId: number; - xrefs?: Record; + xrefs?: Record; xrefsCount?: number; }; const TopPanel = (props: Props) => { - const { createInProgress, disableCreateLink, handleCreateLink, seriesId, xrefs, xrefsCount } = props; + const { createInProgress, disableCreateLink, handleCreateLink, handleResetLinks, seriesId, xrefs, xrefsCount } = + props; const navigate = useNavigateVoid(); const flatXrefs = useMemo( @@ -58,14 +63,16 @@ const TopPanel = (props: Props) => { |
- {(matchRatingCounts.DateAndTitleMatches ?? 0) + (matchRatingCounts.TitleMatches ?? 0)} + {(matchRatingCounts.DateAndTitleMatches ?? 0) + (matchRatingCounts.TitleMatches ?? 0) + + (matchRatingCounts.DateAndNumberMatches ?? 0)}
Perfect
{(matchRatingCounts.DateAndTitleKindaMatches ?? 0) + (matchRatingCounts.DateMatches ?? 0) - + (matchRatingCounts.TitleKindaMatches ?? 0) + (matchRatingCounts.DateKindaMatches ?? 0)} + + (matchRatingCounts.TitleKindaMatches ?? 0) + (matchRatingCounts.DateKindaMatches ?? 0) + + (matchRatingCounts.DateOffsetMatches ?? 0)}
Approximate
@@ -82,6 +89,19 @@ const TopPanel = (props: Props) => { Override + {handleResetLinks && ( + + )} + )} + + )} + + {anilistAnimeQuery.isPending && ( + + )} + + ) + : } + +
+ {virtualItems.map((virtualItem) => { + const episode = episodes[virtualItem.index]; + const isOdd = virtualItem.index % 2 === 1; + + if (!episode && !episodesQuery.isFetchingNextPage) fetchNextPageDebounced(); + + const overrides = episode + ? (linkOverrides[episode.IDs.AniDB] ?? finalEpisodeXrefs?.[episode.IDs.AniDB] ?? [0]) + : [0]; + + const existingXrefs = episode + ? episodeXrefs?.[episode.IDs.AniDB]?.map(xref => xref.AnilistEpisodeID) + : undefined; + + return ( +
+ {episode && anilistId !== 0 && ( + map( + overrides, + (_, index) => ( +
+ +
+ ), + ) + )} + + {/* To render only anidb episodes (left panel) for new links */} + {episode && anilistId === 0 && } + + {!episode && ( + <> +
+ +
+ {anilistId !== 0 && ( +
+ )} +
+ +
+ + )} +
+ ); + })} +
+
+ )} + + + ); +}; + +export default AnilistLinking; diff --git a/src/pages/collection/series/SeriesCredits.tsx b/src/pages/collection/series/SeriesCredits.tsx index 60c5eb542..848deb3d6 100644 --- a/src/pages/collection/series/SeriesCredits.tsx +++ b/src/pages/collection/series/SeriesCredits.tsx @@ -5,31 +5,59 @@ import { useOutletContext } from 'react-router'; import CreditsSearchAndFilterPanel from '@/components/Collection/Credits/CreditsSearchAndFilterPanel'; import StaffPanelVirtualizer from '@/components/Collection/Credits/CreditsStaffVirtualizer'; import MultiStateButton from '@/components/Input/MultiStateButton'; -import { useRefreshSeriesAniDBInfoMutation } from '@/core/react-query/series/mutations'; +import SelectSmall from '@/components/Input/SelectSmall'; +import { useAnilistAnimeCreditsQueries } from '@/core/react-query/anilist/queries'; +import { + useRefreshSeriesAniDBInfoMutation, + useRefreshSeriesAnilistInfoMutation, +} from '@/core/react-query/series/mutations'; import { useSeriesCastQuery } from '@/core/react-query/series/queries'; +import { useSupportedLanguagesQuery } from '@/core/react-query/settings/queries'; import type { SeriesContextType } from '@/components/Collection/constants'; import type { SeriesCast } from '@/core/types/api/series'; export type CreditsModeType = 'Character' | 'Staff'; +export type CreditsSourceType = 'AniDB' | 'AniList'; + const cleanString = (input = '') => input.replaceAll(' ', '').toLowerCase(); const getUniqueRoles = (castList: SeriesCast[]) => [...new Set(castList.map(cast => cast.RoleDetails))]; +const getUniqueLanguages = (castList: SeriesCast[]) => + [...new Set(castList.map(cast => cast.Language).filter((language): language is string => !!language))] + .sort((languageA, languageB) => languageA.localeCompare(languageB)); + +const allLanguages = 'all'; + const modeStates: { label?: string, value: CreditsModeType }[] = [ { label: 'Characters', value: 'Character' }, { value: 'Staff' }, ]; +const sourceStates: { value: CreditsSourceType }[] = [ + { value: 'AniDB' }, + { value: 'AniList' }, +]; + const SeriesCredits = () => { const { series } = useOutletContext(); const { isPending: pendingRefreshAniDb, mutate: refreshAniDbMutation } = useRefreshSeriesAniDBInfoMutation( series.IDs.ID, ); + const { isPending: pendingRefreshAnilist, mutate: refreshAnilistMutation } = useRefreshSeriesAnilistInfoMutation( + series.IDs.ID, + ); + + const [source, setSource] = useState(sourceStates[0].value); - const refreshAniDb = () => { + const refreshSource = () => { + if (source === 'AniList') { + refreshAnilistMutation(); + return; + } refreshAniDbMutation({ force: true }); }; @@ -39,14 +67,26 @@ const SeriesCredits = () => { const [roleFilter, setRoleFilter] = useState>(new Set()); + const [language, setLanguage] = useState(allLanguages); + const handleModeChange = (newMode: CreditsModeType) => { setMode(() => { setSearch(''); setRoleFilter(new Set()); + setLanguage(allLanguages); return newMode; }); }; + const handleSourceChange = (newSource: CreditsSourceType) => { + setSource(() => { + setSearch(''); + setRoleFilter(new Set()); + setLanguage(allLanguages); + return newSource; + }); + }; + const handleFilterChange = (event: ChangeEvent) => { const { id: description } = event.target; setRoleFilter((prevState) => { @@ -60,7 +100,15 @@ const SeriesCredits = () => { setSearch(event.target.value); }; - const cast = useSeriesCastQuery(series.IDs.ID).data; + const handleLanguageChange = (event: ChangeEvent) => { + setLanguage(event.target.value); + }; + + const languageNames = useSupportedLanguagesQuery().data; + + const anidbCast = useSeriesCastQuery(series.IDs.ID, source === 'AniDB').data; + const anilistCredits = useAnilistAnimeCreditsQueries(series.IDs.AniList, source === 'AniList').data; + const cast = source === 'AniList' ? anilistCredits : anidbCast; const castByType = useMemo(() => ({ Character: cast?.filter(credit => credit.RoleName === 'Actor') ?? [], Staff: cast?.filter(credit => credit.RoleName !== 'Actor') ?? [], @@ -71,17 +119,22 @@ const SeriesCredits = () => { Staff: getUniqueRoles(castByType.Staff), }), [castByType]); + const languages = useMemo(() => getUniqueLanguages(castByType[mode]), [castByType, mode]); + // Only worth showing when there is actually something to choose between, eg. multiple dubs. + const showLanguageFilter = languages.length > 1; + const filteredCast = useMemo(() => (castByType[mode].filter(item => ( (search === '' || ([item?.Character?.Name, item?.Staff?.Name].some(name => cleanString(name).includes(cleanString(search))))) && !roleFilter.has(item?.RoleDetails) + && (language === allLanguages || item.Language === language) )).sort((castA, castB) => { const nameA = castA[mode]?.Name ?? ''; const nameB = castB[mode]?.Name ?? ''; if (nameA > nameB) return 1; if (nameA < nameB) return -1; return 0; - })), [castByType, mode, search, roleFilter]); + })), [castByType, language, mode, search, roleFilter]); return ( <> @@ -95,8 +148,8 @@ const SeriesCredits = () => { uniqueRoles={uniqueRoles[mode]} handleSearchChange={handleSearchChange} handleFilterChange={handleFilterChange} - refreshAniDbAction={refreshAniDb} - aniDbRefreshing={pendingRefreshAniDb} + refreshAniDbAction={refreshSource} + aniDbRefreshing={source === 'AniList' ? pendingRefreshAnilist : pendingRefreshAniDb} /> @@ -104,7 +157,7 @@ const SeriesCredits = () => {
Credits |  - {(search !== '' || roleFilter.size > 0) && ( + {(search !== '' || roleFilter.size > 0 || language !== allLanguages) && ( <> {filteredCast.length} @@ -119,7 +172,26 @@ const SeriesCredits = () => { {mode === 'Character' ? 'Characters' : mode}  Listed
- +
+ {showLanguageFilter && ( + + + {languages.map(code => ( + + ))} + + )} + + +
diff --git a/src/pages/collection/series/SeriesImages.tsx b/src/pages/collection/series/SeriesImages.tsx index 799af4a2a..83d9366ba 100644 --- a/src/pages/collection/series/SeriesImages.tsx +++ b/src/pages/collection/series/SeriesImages.tsx @@ -32,12 +32,14 @@ import type { ImageTabType } from '@/core/types/api/image'; const tabToImageTypeMap: Record = { Posters: 'Primary', Backdrops: 'Backdrop', + Banners: 'Banner', Logos: 'Logo', }; const tabStates = [ { value: 'Posters' as const }, { value: 'Backdrops' as const }, + { value: 'Banners' as const }, { value: 'Logos' as const }, ]; @@ -51,6 +53,7 @@ const InfoLine = ({ title, value }: { title: string, value: string }) => ( const imageItemSize: Record = { Posters: { width: 13, height: 19.5 }, Backdrops: { width: 27, height: 16 }, + Banners: { width: 38, height: 8 }, // Wide strips, eg. AniList banners are 1900x400 (4.75:1). Logos: { width: 15, height: 15 }, }; diff --git a/src/pages/collection/series/SeriesOverview.tsx b/src/pages/collection/series/SeriesOverview.tsx index 3816ab049..b15bf8372 100644 --- a/src/pages/collection/series/SeriesOverview.tsx +++ b/src/pages/collection/series/SeriesOverview.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react'; import { useOutletContext } from 'react-router'; -import { mdiEarth, mdiOpenInNew } from '@mdi/js'; +import { mdiEarth, mdiMoviePlayOutline, mdiOpenInNew, mdiShareVariantOutline } from '@mdi/js'; import { Icon } from '@mdi/react'; import cx from 'classnames'; import { flatMap, get, map, round } from 'lodash'; @@ -20,10 +20,16 @@ import { import type { SeriesContextType } from '@/components/Collection/constants'; import type { ImageType } from '@/core/types/api/common'; -import type { SeriesCast } from '@/core/types/api/series'; +import type { SeriesCast, SeriesLinkTypeValues } from '@/core/types/api/series'; // Links -const MetadataLinks = ['AniDB', 'TMDB'] as const; +const MetadataLinks = ['AniDB', 'TMDB', 'AniList'] as const; + +// Icons for the link types that are not a plain website; everything else keeps the globe. +const linkTypeIcons: Partial> = { + Social: mdiShareVariantOutline, + Trailer: mdiMoviePlayOutline, +}; const SeriesOverview = () => { const { series } = useOutletContext(); @@ -105,7 +111,22 @@ const SeriesOverview = () => { ]; } - // Site is not TMDB, so it's either a single ID or an array of IDs + if (site === 'AniList') { + const anilistIds = series.IDs.AniList; + if (anilistIds.length === 0) { + return ; + } + + return [ + ...anilistIds.map(id => ( + + )), + /* Show row to add new AniList links */ + , + ]; + } + + // Site is not TMDB or AniList, so it's either a single ID or an array of IDs const idOrIds = series?.IDs[site] ?? [0]; const linkIds = typeof idOrIds === 'number' ? [idOrIds] : idOrIds; if (linkIds.length === 0) linkIds.push(0); @@ -125,19 +146,28 @@ const SeriesOverview = () => { > {series.Links.map(link => ( {link.Name} + {link.LanguageCode && ( + + {link.LanguageCode} + + )} return ; case 'moviedb': return ; + case 'anilist': + return ; default: return ; } @@ -91,6 +94,13 @@ const MetadataSources = () => { tabKey="moviedb" title="TMDB" /> + | +
diff --git a/src/pages/firstrun/MetadataSourcesTabs/AnilistTab.tsx b/src/pages/firstrun/MetadataSourcesTabs/AnilistTab.tsx new file mode 100644 index 000000000..74441c0ea --- /dev/null +++ b/src/pages/firstrun/MetadataSourcesTabs/AnilistTab.tsx @@ -0,0 +1,24 @@ +import AnilistDownloadSettings from '@/components/Settings/MetadataSitesSettings/AnilistDownloadSettings'; +import AnilistSettings from '@/components/Settings/MetadataSitesSettings/AnilistSettings'; +import TransitionDiv from '@/components/TransitionDiv'; +import useFirstRunSettingsContext from '@/hooks/useFirstRunSettingsContext'; + +const AnilistTab = () => { + const { newSettings, setNewSettings, updateSetting } = useFirstRunSettingsContext(); + + return ( + +
Linking Options
+
+ +
+ +
Download Options
+
+ +
+
+ ); +}; + +export default AnilistTab; diff --git a/src/pages/settings/SettingsPage.tsx b/src/pages/settings/SettingsPage.tsx index 6576e2322..ae6941c5a 100644 --- a/src/pages/settings/SettingsPage.tsx +++ b/src/pages/settings/SettingsPage.tsx @@ -32,6 +32,7 @@ const items = [ { name: 'Hashing & Release', path: 'hashing-release' }, { name: 'AniDB', path: 'anidb' }, { name: 'TMDB', path: 'tmdb' }, + { name: 'AniList', path: 'anilist' }, { name: 'Collection', path: 'collection' }, { name: 'Integrations', path: 'integrations' }, { name: 'Plugin Management', path: 'plugin-management' }, diff --git a/src/pages/settings/tabs/AnilistSettings.tsx b/src/pages/settings/tabs/AnilistSettings.tsx new file mode 100644 index 000000000..878f00355 --- /dev/null +++ b/src/pages/settings/tabs/AnilistSettings.tsx @@ -0,0 +1,45 @@ +import AnilistDownloadSettings from '@/components/Settings/MetadataSitesSettings/AnilistDownloadSettings'; +import AnilistSettingsOptions from '@/components/Settings/MetadataSitesSettings/AnilistSettings'; +import useSettingsContext from '@/hooks/useSettingsContext'; + +const AnilistSettings = () => { + const { newSettings, setNewSettings, updateSetting } = useSettingsContext(); + + return ( + <> + Settings > AniList | Shoko +
+
AniList
+
+ Customize the information and images that Shoko downloads for the series in your collection +
+
+ +
+ +
+
AniList Options
+
+ +
+
+ +
+ +
+
AniList Download Options
+
+ +
+
+ +
+ + ); +}; + +export default AnilistSettings; diff --git a/src/pages/utilities/Calendar.tsx b/src/pages/utilities/Calendar.tsx new file mode 100644 index 000000000..a01af36dc --- /dev/null +++ b/src/pages/utilities/Calendar.tsx @@ -0,0 +1,319 @@ +import { useMemo, useState } from 'react'; +import { Link } from 'react-router'; +import { mdiCalendarTodayOutline, mdiChevronLeft, mdiChevronRight, mdiLoading } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import cx from 'classnames'; +import { groupBy, range, sortBy, xor } from 'lodash'; +import { useToggle } from 'usehooks-ts'; + +import BackgroundImagePlaceholderDiv from '@/components/BackgroundImagePlaceholderDiv'; +import Button from '@/components/Input/Button'; +import Checkbox from '@/components/Input/Checkbox'; +import MultiStateButton from '@/components/Input/MultiStateButton'; +import ShokoPanel from '@/components/Panels/ShokoPanel'; +import ItemCount from '@/components/Utilities/ItemCount'; +import { useDashboardCalendarEpisodesQuery } from '@/core/react-query/dashboard/queries'; +import { useSettingsQuery } from '@/core/react-query/settings/queries'; +import { dayjs, getAnidbAnimeLink } from '@/core/util'; +import { getEpisodePrefixAlt } from '@/core/utilities/getEpisodePrefix'; + +import type { DashboardEpisodeDetailsType } from '@/core/types/api/dashboard'; +import type { EpisodeTypeValues } from '@/core/types/api/episode'; +import type { Dayjs } from 'dayjs'; + +type CalendarViewType = 'month' | 'week'; + +const viewStates: { label?: string, value: CalendarViewType }[] = [ + { label: 'Month', value: 'month' }, + { label: 'Week', value: 'week' }, +]; + +const dateKeyFormat = 'YYYY-MM-DD'; + +const episodeTypeOptions: { label: string, value: EpisodeTypeValues }[] = [ + { label: 'Episodes', value: 'Episode' }, + { label: 'Specials', value: 'Special' }, + { label: 'Credits', value: 'Credits' }, + { label: 'Trailers', value: 'Trailer' }, + { label: 'Parodies', value: 'Parody' }, + { label: 'Others', value: 'Other' }, +]; + +// What the server assumes when no `type` is sent. +const defaultEpisodeTypes: EpisodeTypeValues[] = ['Episode']; + +// The day an episode belongs to for the viewer: the local day of the broadcast when the time is known, +// otherwise the (UTC) air date as-is so a date-only entry never drifts to a neighbouring day. +const getLocalDay = (episode: DashboardEpisodeDetailsType) => { + if (episode.HasAirTime && episode.AiredAt) return dayjs(episode.AiredAt); + return dayjs(episode.AirDate ?? episode.AiredAt ?? undefined); +}; + +const getVisibleRange = (anchor: Dayjs, view: CalendarViewType) => { + if (view === 'week') { + const start = anchor.startOf('week'); + return { start, end: start.add(6, 'day') }; + } + return { + start: anchor.startOf('month').startOf('week'), + end: anchor.endOf('month').endOf('week').startOf('day'), + }; +}; + +const weekdayNames = range(7).map(day => dayjs().day(day).format('ddd')); + +type EpisodeCardProps = { + episode: DashboardEpisodeDetailsType; +}; + +const EpisodeCard = ({ episode }: EpisodeCardProps) => { + const inCollection = episode.IDs.ShokoSeries !== null; + const episodeLabel = `${getEpisodePrefixAlt(episode.Type)}${episode.Number} - ${episode.Title}`; + const airTime = episode.HasAirTime && episode.AiredAt ? dayjs(episode.AiredAt).format('HH:mm') : null; + + const content = ( + <> + +
+
+ {episode.SeriesTitle} +
+
+ {episodeLabel} +
+ {airTime && ( +
+ {episode.IsAirTimeEstimated ? `~${airTime}` : airTime} +
+ )} +
+ + ); + + const cardClassName = cx( + 'flex gap-x-2 rounded-md border border-panel-border p-1.5 text-xs transition-colors hover:bg-panel-toggle-background-hover', + inCollection ? 'bg-panel-background-alt' : 'bg-panel-background opacity-75', + ); + + if (inCollection) { + return ( + + {content} + + ); + } + + return ( +
+ {content} + + ); +}; + +const Calendar = () => { + const { hideR18Content } = useSettingsQuery().data.WebUI_Settings.dashboard; + + const [view, setView] = useState('month'); + const [anchor, setAnchor] = useState(() => dayjs().startOf('day')); + const [includeMissing, toggleIncludeMissing] = useToggle(false); + const [includeRestricted, toggleIncludeRestricted] = useToggle(!hideR18Content); + const [onlyWithAirTime, toggleOnlyWithAirTime] = useToggle(false); + const [episodeTypes, setEpisodeTypes] = useState(defaultEpisodeTypes); + + const handleViewChange = (newView: CalendarViewType) => { + setView(newView); + }; + + const toggleEpisodeType = (type: EpisodeTypeValues) => { + setEpisodeTypes(prev => xor(prev, [type])); + }; + + // Leave the parameter out when the selection matches the server default. + const isDefaultEpisodeTypes = xor(episodeTypes, defaultEpisodeTypes).length === 0; + + const visibleRange = useMemo(() => getVisibleRange(anchor, view), [anchor, view]); + + // The server works in UTC days while the grid is in local days, so pad the request by a day on each side. + const episodesQuery = useDashboardCalendarEpisodesQuery({ + startDate: visibleRange.start.subtract(1, 'day').format(dateKeyFormat), + endDate: visibleRange.end.add(1, 'day').format(dateKeyFormat), + includeMissing: includeMissing ? 'True' : 'False', + includeRestricted: includeRestricted ? 'True' : 'False', + includeWithAirTime: onlyWithAirTime ? 'Only' : 'True', + type: isDefaultEpisodeTypes ? undefined : episodeTypes, + }); + + const days = useMemo(() => { + const dayCount = visibleRange.end.diff(visibleRange.start, 'day') + 1; + return range(dayCount).map(offset => visibleRange.start.add(offset, 'day')); + }, [visibleRange]); + + const episodesByDay = useMemo(() => { + const sorted = sortBy(episodesQuery.data ?? [], [ + episode => (episode.HasAirTime && episode.AiredAt ? dayjs(episode.AiredAt).valueOf() : 0), + 'SeriesTitle', + 'Number', + ]); + return groupBy(sorted, episode => getLocalDay(episode).format(dateKeyFormat)); + }, [episodesQuery.data]); + + const visibleEpisodeCount = useMemo( + () => days.reduce((count, day) => count + (episodesByDay[day.format(dateKeyFormat)]?.length ?? 0), 0), + [days, episodesByDay], + ); + + const today = dayjs().format(dateKeyFormat); + const unit = view === 'week' ? 'week' : 'month'; + const rangeLabel = view === 'week' + ? `${visibleRange.start.format('MMM D')} – ${visibleRange.end.format('MMM D, YYYY')}` + : anchor.format('MMMM YYYY'); + + return ( + <> + Calendar | Shoko +
+
+ } + > +
+
+ + + +
+
+ {rangeLabel} + {episodesQuery.isFetching && !episodesQuery.isPending && ( + + )} +
+
+ + + +
+
+ {episodeTypeOptions.map(option => ( + + ))} +
+ +
+
+
+ +
+ {episodesQuery.isPending && ( +
+ +
+ )} + + {!episodesQuery.isPending && ( +
+
+ {weekdayNames.map(name => ( +
+ {name} +
+ ))} +
+
+ {days.map((day) => { + const dateKey = day.format(dateKeyFormat); + const isToday = dateKey === today; + const isOutsideMonth = view === 'month' && !day.isSame(anchor, 'month'); + const episodes = episodesByDay[dateKey] ?? []; + + return ( +
+
+ {day.date() === 1 || view === 'week' ? day.format('MMM D') : day.date()} + {episodes.length > 0 && {episodes.length}} +
+ {episodes.map(episode => )} +
+ ); + })} +
+
+ )} +
+
+ + ); +}; + +export default Calendar;