From a4fc2aba3d5d2ac50ed371349fc4ab74bb7b0ed2 Mon Sep 17 00:00:00 2001 From: revam Date: Mon, 14 Sep 2026 05:50:58 +0200 Subject: [PATCH 1/6] feat(api): add anilist types and react-query endpoints Added the client-side API layer for the server's new AniList integration, mirroring the existing TMDB slices. - Added `src/core/types/api/anilist.ts` with the `AnilistAnime`, `AnilistEpisode`, remote search, auto-match and cross-reference DTOs - Added `src/core/react-query/anilist/{types,queries,mutations}.ts` covering the series-scoped, episode-scoped and `/Anilist` routes, including cast/crew lookups and the refresh, link, episode-mapping, export and import mutations - Added `getAnilistUnavailableState` and `retryUnlessAnilistUnavailable` so `502`/`503` + `Retry-After` responses from AniList-facing endpoints are surfaced instead of retried - Added the AniList auto-search, refresh and image download series mutations next to the TMDB ones - Added `IDs.AniList` to `SeriesIDsType`, extended `MatchRatingValues` with `DateAndNumberMatches` and `DateOffsetMatches`, and added `getAnilistAnimeLink` to `@/core/util` --- src/core/anilistUtils.ts | 3 + src/core/react-query/anilist/helpers.ts | 52 +++++ src/core/react-query/anilist/mutations.ts | 89 ++++++++ src/core/react-query/anilist/queries.ts | 238 ++++++++++++++++++++++ src/core/react-query/anilist/types.ts | 98 +++++++++ src/core/react-query/series/mutations.ts | 19 ++ src/core/types/api/anilist.ts | 133 ++++++++++++ src/core/types/api/episode.ts | 2 + src/core/types/api/series.ts | 1 + src/core/util.ts | 1 + 10 files changed, 636 insertions(+) create mode 100644 src/core/anilistUtils.ts create mode 100644 src/core/react-query/anilist/helpers.ts create mode 100644 src/core/react-query/anilist/mutations.ts create mode 100644 src/core/react-query/anilist/queries.ts create mode 100644 src/core/react-query/anilist/types.ts create mode 100644 src/core/types/api/anilist.ts diff --git a/src/core/anilistUtils.ts b/src/core/anilistUtils.ts new file mode 100644 index 000000000..ef6fa100a --- /dev/null +++ b/src/core/anilistUtils.ts @@ -0,0 +1,3 @@ +const ANILIST_BASE_URL = 'https://anilist.co'; + +export const getAnilistAnimeLink = (anilistId: number | string) => `${ANILIST_BASE_URL}/anime/${anilistId}`; diff --git a/src/core/react-query/anilist/helpers.ts b/src/core/react-query/anilist/helpers.ts new file mode 100644 index 000000000..9e2254e67 --- /dev/null +++ b/src/core/react-query/anilist/helpers.ts @@ -0,0 +1,52 @@ +import { isAxiosError } from 'axios'; +import { toNumber } from 'lodash'; + +import queryClient from '@/core/react-query/queryClient'; + +export type AnilistUnavailableStateType = { + /** + * `503` when the AniList rate limiter is paused, `502` when the upstream + * request failed mid-flight. + */ + status: 502 | 503; + + /** + * Seconds until the request can be retried, from the `Retry-After` + * header, if present. + */ + retryAfter: number | null; +}; + +/** + * Detects the "AniList is temporarily unavailable" responses returned by + * endpoints that talk to AniList directly. + */ +export const getAnilistUnavailableState = (error: unknown): AnilistUnavailableStateType | undefined => { + if (!isAxiosError(error)) return undefined; + const status = error.response?.status; + if (status !== 502 && status !== 503) return undefined; + + const retryAfterHeader = (error.response?.headers as Record | undefined)?.['retry-after']; + const retryAfter = toNumber(retryAfterHeader); + + return { + status, + retryAfter: Number.isFinite(retryAfter) && retryAfter > 0 ? Math.ceil(retryAfter) : null, + }; +}; + +export const getAnilistUnavailableMessage = (state: AnilistUnavailableStateType) => { + if (state.retryAfter) return `AniList is temporarily unavailable, try again in ${state.retryAfter} seconds.`; + return 'AniList is temporarily unavailable, try again later.'; +}; + +/** + * Retry policy for queries that talk to AniList directly. The server already + * tells us when to retry through `Retry-After`, so we don't retry those + * responses ourselves; everything else falls through to the default policy. + */ +export const retryUnlessAnilistUnavailable = (failureCount: number, error: Error) => { + if (getAnilistUnavailableState(error)) return false; + const defaultRetry = queryClient.getDefaultOptions().queries?.retry; + return typeof defaultRetry === 'function' ? defaultRetry(failureCount, error) : false; +}; diff --git a/src/core/react-query/anilist/mutations.ts b/src/core/react-query/anilist/mutations.ts new file mode 100644 index 000000000..12725245f --- /dev/null +++ b/src/core/react-query/anilist/mutations.ts @@ -0,0 +1,89 @@ +import { useMutation } from '@tanstack/react-query'; + +import { axios } from '@/core/axios'; +import { invalidateQueries } from '@/core/react-query/queryClient'; + +import type { + AnilistAddAutoXrefsRequestType, + AnilistAddLinkRequestType, + AnilistDeleteLinkRequestType, + AnilistEditEpisodeXrefsRequestType, + AnilistExportRequestType, + AnilistImportRequestType, + AnilistRefreshRequestType, + AnilistSeriesRefreshRequestType, +} from '@/core/react-query/anilist/types'; + +export const useAnilistRefreshMutation = () => + useMutation({ + mutationFn: ({ anilistId, ...data }: AnilistRefreshRequestType) => + axios.post(`Anilist/Anime/${anilistId}/Action/Refresh`, data), + }); + +export const useAnilistSeriesRefreshMutation = (seriesId: number) => + useMutation({ + mutationFn: (data: AnilistSeriesRefreshRequestType = {}) => + axios.post(`Series/${seriesId}/Anilist/Anime/Action/Refresh`, data), + }); + +export const useAnilistScheduleAutoSearchMutation = (seriesId: number) => + useMutation({ + mutationFn: (force?: boolean) => + axios.post(`Series/${seriesId}/Anilist/Action/AutoSearch`, null, { params: { force: force ?? false } }), + }); + +export const useAnilistAddLinkMutation = (seriesId: number) => + useMutation({ + mutationFn: (data: AnilistAddLinkRequestType) => axios.post(`Series/${seriesId}/Anilist/Anime`, data), + }); + +export const useDeleteAnilistLinkMutation = (seriesId: number) => + useMutation({ + mutationFn: (data: AnilistDeleteLinkRequestType = {}) => axios.delete(`Series/${seriesId}/Anilist/Anime`, { data }), + }); + +export const useAnilistEditEpisodeXrefsMutation = (seriesId: number) => + useMutation({ + mutationFn: (data: AnilistEditEpisodeXrefsRequestType) => + axios.post(`Series/${seriesId}/Anilist/Anime/CrossReferences/Episode`, data), + }); + +export const useAnilistResetEpisodeXrefsMutation = (seriesId: number) => + useMutation({ + mutationFn: () => axios.delete(`Series/${seriesId}/Anilist/Anime/CrossReferences/Episode`), + onSuccess: () => { + invalidateQueries(['series', seriesId, 'anilist', 'cross-references']); + }, + }); + +export const useAnilistAddAutoXrefsMutation = (seriesId: number) => + useMutation({ + mutationFn: (data: AnilistAddAutoXrefsRequestType = {}) => + axios.post(`Series/${seriesId}/Anilist/Anime/CrossReferences/Episode/Auto`, data), + }); + +export const useDeleteAnilistAnimeMutation = () => + useMutation({ + mutationFn: (anilistId: number) => axios.delete(`Anilist/Anime/${anilistId}`), + onSuccess: () => { + invalidateQueries(['series', 'anilist', 'anime']); + }, + }); + +export const useAnilistExportMutation = () => + useMutation({ + mutationFn: (data: AnilistExportRequestType = {}) => + axios.post('Anilist/Export', data, { responseType: 'text' }), + }); + +export const useAnilistImportMutation = () => + useMutation({ + mutationFn: ({ addMissingAnime, file, removeExisting }: AnilistImportRequestType) => { + const formData = new FormData(); + formData.append('file', file); + return axios.post('Anilist/Import', formData, { params: { removeExisting, addMissingAnime } }); + }, + onSuccess: () => { + invalidateQueries(['series']); + }, + }); diff --git a/src/core/react-query/anilist/queries.ts b/src/core/react-query/anilist/queries.ts new file mode 100644 index 000000000..4f0a62e03 --- /dev/null +++ b/src/core/react-query/anilist/queries.ts @@ -0,0 +1,238 @@ +import { useInfiniteQuery, useQueries, useQuery } from '@tanstack/react-query'; +import { toNumber } from 'lodash'; + +import { axios } from '@/core/axios'; +import { getAnilistUnavailableState, retryUnlessAnilistUnavailable } from '@/core/react-query/anilist/helpers'; +import { transformListResultSimplified } from '@/core/react-query/helpers'; + +import type { + AnilistAnimeEpisodesRequestType, + AnilistAnimeListRequestType, + AnilistAnimeRequestType, + AnilistAutoXrefsPreviewRequestType, + AnilistEpisodeIncludeValues, + AnilistSearchRequestType, +} from '@/core/react-query/anilist/types'; +import type { ListResultType } from '@/core/types/api'; +import type { + AnilistAnimeType, + AnilistAnimeXrefType, + AnilistAutoSearchResultType, + AnilistEpisodeType, + AnilistEpisodeXrefType, + AnilistSearchResultType, +} from '@/core/types/api/anilist'; +import type { AniDBSeriesType, SeriesCast, SeriesType } from '@/core/types/api/series'; + +export const useAnilistAutoSearchQuery = (seriesId: number, enabled = true) => + useQuery({ + queryKey: ['series', seriesId, 'anilist', 'auto-search'], + queryFn: () => axios.get(`Series/${seriesId}/Anilist/Action/AutoSearch`), + retry: retryUnlessAnilistUnavailable, + enabled, + }); + +export const useSeriesAnilistAnimeQuery = (seriesId: number, params: AnilistAnimeRequestType = {}, enabled = true) => + useQuery({ + queryKey: ['series', seriesId, 'anilist', 'anime', params], + queryFn: () => axios.get(`Series/${seriesId}/Anilist/Anime`, { params }), + enabled, + }); + +export const useAnilistAnimeXrefsQuery = (seriesId: number, enabled = true) => + useQuery({ + queryKey: ['series', seriesId, 'anilist', 'cross-references', 'anime'], + queryFn: () => axios.get(`Series/${seriesId}/Anilist/Anime/CrossReferences`), + enabled, + }); + +export const useAnilistEpisodeXrefsQuery = ( + seriesId: number, + isNewLink: boolean, + anilistAnimeID: number, + enabled = true, +) => + useQuery, unknown, AnilistEpisodeXrefType[]>({ + queryKey: ['series', seriesId, 'anilist', 'cross-references', 'episode', isNewLink, anilistAnimeID], + queryFn: () => + axios.get( + `Series/${seriesId}/Anilist/Anime/CrossReferences/Episode${isNewLink ? '/Auto' : ''}`, + { params: { pageSize: 0, anilistAnimeID: isNewLink ? anilistAnimeID : undefined } }, + ), + select: transformListResultSimplified, + enabled, + }); + +export const useAnilistAutoEpisodeXrefsPreviewQuery = ( + seriesId: number, + params: AnilistAutoXrefsPreviewRequestType, + enabled = true, +) => + useQuery, unknown, AnilistEpisodeXrefType[]>({ + queryKey: ['series', seriesId, 'anilist', 'cross-references', 'episode', 'auto-preview', params], + queryFn: () => + axios.get( + `Series/${seriesId}/Anilist/Anime/CrossReferences/Episode/Auto`, + { params: { pageSize: 0, ...params } }, + ), + select: transformListResultSimplified, + enabled, + }); + +export const useEpisodeAnilistEpisodesQuery = ( + episodeId: number, + include: AnilistEpisodeIncludeValues[] = [], + enabled = true, +) => + useQuery({ + queryKey: ['episode', episodeId, 'anilist', 'episode', include], + queryFn: () => axios.get(`Episode/${episodeId}/Anilist/Episode`, { params: { include } }), + enabled, + }); + +export const useEpisodeAnilistEpisodeXrefsQuery = (episodeId: number, enabled = true) => + useQuery({ + queryKey: ['episode', episodeId, 'anilist', 'cross-references'], + queryFn: () => axios.get(`Episode/${episodeId}/Anilist/Episode/CrossReferences`), + enabled, + }); + +export const useAnilistAnimeListQuery = (params: AnilistAnimeListRequestType, enabled = true) => + useInfiniteQuery>({ + queryKey: ['series', 'anilist', 'anime', 'list', params], + queryFn: ({ pageParam }) => axios.get('Anilist/Anime', { 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 useAnilistAnimeQuery = (anilistId: number, params: AnilistAnimeRequestType = {}, enabled = true) => + useQuery({ + queryKey: ['series', 'anilist', 'anime', anilistId, params], + queryFn: () => axios.get(`Anilist/Anime/${anilistId}`, { params }), + enabled, + }); + +export const useAnilistAnimeOnlineQuery = (anilistId: number, enabled = true) => + useQuery({ + queryKey: ['series', 'anilist', 'anime', 'online', anilistId], + queryFn: () => axios.get(`Anilist/Anime/Online/${anilistId}`), + retry: retryUnlessAnilistUnavailable, + enabled, + }); + +export const useAnilistAnimeXrefsByAnimeQuery = (anilistId: number, enabled = true) => + useQuery({ + queryKey: ['series', 'anilist', 'anime', anilistId, 'cross-references'], + queryFn: () => axios.get(`Anilist/Anime/${anilistId}/CrossReferences`), + enabled, + }); + +export const useAnilistAnimeEpisodesQuery = ( + anilistId: number, + params: AnilistAnimeEpisodesRequestType, + enabled = true, +) => + useInfiniteQuery>({ + queryKey: ['series', 'anilist', 'episodes', anilistId, params], + queryFn: ({ pageParam }) => + axios.get( + `Anilist/Anime/${anilistId}/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, + staleTime: Infinity, + }); + +export const useAnilistAnimeAllEpisodesQuery = (anilistId: number, enabled = true) => + useQuery, unknown, AnilistEpisodeType[]>({ + queryKey: ['series', 'anilist', 'episodes', anilistId, 'all'], + queryFn: () => axios.get(`Anilist/Anime/${anilistId}/Episode`, { params: { pageSize: 0 } }), + select: transformListResultSimplified, + enabled, + staleTime: Infinity, + }); + +export const useAnilistAnimeAnidbAnimeQuery = (anilistId: number, enabled = true) => + useQuery({ + queryKey: ['series', 'anilist', 'anime', anilistId, 'anidb'], + queryFn: () => axios.get(`Anilist/Anime/${anilistId}/AniDB/Anime`), + enabled, + }); + +export const useAnilistAnimeShokoSeriesQuery = (anilistId: number, enabled = true) => + useQuery({ + queryKey: ['series', 'anilist', 'anime', anilistId, 'shoko'], + queryFn: () => axios.get(`Anilist/Anime/${anilistId}/Shoko/Series`), + enabled, + }); + +export const useAnilistSearchQuery = (query: string, params: AnilistSearchRequestType) => + useQuery({ + queryKey: ['series', 'anilist', 'search', query, params], + queryFn: async () => { + const finalData: AnilistSearchResultType[] = []; + + if (toNumber(query) !== 0) { + try { + const idLookupData: AnilistSearchResultType = await axios.get(`Anilist/Anime/Online/${query}`); + finalData.push(idLookupData); + } catch (error) { + // Surface upstream outages, otherwise ignore, anime not found on AniList with provided ID + if (getAnilistUnavailableState(error)) throw error; + } + } + + const searchData: ListResultType = await axios.get('Anilist/Anime/Online/Search', { + params: { ...params, query }, + }); + finalData.push(...searchData.List.filter(result => result.ID !== finalData[0]?.ID)); + + return finalData; + }, + retry: retryUnlessAnilistUnavailable, + enabled: query.length > 0, + }); + +const anilistAnimeCastQueryOptions = (anilistId: number, enabled: boolean) => ({ + queryKey: ['series', 'anilist', 'anime', anilistId, 'cast'], + queryFn: (): Promise => axios.get(`Anilist/Anime/${anilistId}/Cast`), + enabled, +}); + +const anilistAnimeCrewQueryOptions = (anilistId: number, enabled: boolean) => ({ + queryKey: ['series', 'anilist', 'anime', anilistId, 'crew'], + queryFn: (): Promise => axios.get(`Anilist/Anime/${anilistId}/Crew`), + enabled, +}); + +export const useAnilistAnimeCastQuery = (anilistId: number, enabled = true) => + useQuery(anilistAnimeCastQueryOptions(anilistId, enabled)); + +export const useAnilistAnimeCrewQuery = (anilistId: number, enabled = true) => + useQuery(anilistAnimeCrewQueryOptions(anilistId, enabled)); + +/** + * Fetches the cast and crew for every given AniList anime and concatenates + * them into a single list. + */ +export const useAnilistAnimeCreditsQueries = (anilistIds: number[], enabled = true) => + useQueries({ + queries: anilistIds.flatMap(anilistId => [ + anilistAnimeCastQueryOptions(anilistId, enabled), + anilistAnimeCrewQueryOptions(anilistId, enabled), + ]), + combine: results => ({ + data: results.flatMap(result => result.data ?? []), + isPending: results.some(result => result.isPending), + isFetching: results.some(result => result.isFetching), + }), + }); diff --git a/src/core/react-query/anilist/types.ts b/src/core/react-query/anilist/types.ts new file mode 100644 index 000000000..1ada31a26 --- /dev/null +++ b/src/core/react-query/anilist/types.ts @@ -0,0 +1,98 @@ +import type { PaginationType } from '@/core/types/api'; + +export type AnilistSeriesRefreshRequestType = { + Force?: boolean; + DownloadImages?: boolean; + /** `null` follows the settings. */ + DownloadCharactersAndStaff?: boolean | null; + Immediate?: boolean; + /** Fetch only the anime and its episodes, skipping images, characters and staff. */ + QuickRefresh?: boolean; +}; + +export type AnilistRefreshRequestType = { + anilistId: number; +} & AnilistSeriesRefreshRequestType; + +export type AnilistAddLinkRequestType = { + ID: number; + Replace?: boolean; + Refresh?: boolean; +}; + +export type AnilistDeleteLinkRequestType = { + ID?: number; + Purge?: boolean; +}; + +export type AnilistAddAutoXrefsRequestType = { + AnilistAnimeID?: number; + KeepExisting?: boolean; + ConsiderExistingOtherLinks?: boolean | null; +}; + +export type AnilistAutoXrefsPreviewRequestType = { + anilistAnimeID?: number; + keepExisting?: boolean; + considerExistingOtherLinks?: boolean | null; +} & PaginationType; + +export type AnilistEpisodeXrefMappingRequestType = { + AniDBID: number; + AnilistID: number; + Replace?: boolean; + Index?: number | null; +}; + +export type AnilistEditEpisodeXrefsRequestType = { + UnsetAll?: boolean; + Mapping: AnilistEpisodeXrefMappingRequestType[]; +}; + +export type AnilistSearchRequestType = { + includeRestricted?: boolean; +} & PaginationType; + +export type AnilistAnimeIncludeValues = + | 'Synonyms' + | 'Titles' + | 'Overviews' + | 'Images' + | 'MalIDs' + | 'Tags' + | 'Studios' + | 'CrossReferences'; + +export type AnilistEpisodeIncludeValues = 'CrossReferences'; + +export type AnilistAnimeListRequestType = { + search?: string; + fuzzy?: boolean; + include?: AnilistAnimeIncludeValues[]; + restricted?: 'True' | 'False' | 'Only'; +} & PaginationType; + +export type AnilistAnimeRequestType = { + include?: AnilistAnimeIncludeValues[]; +}; + +export type AnilistAnimeEpisodesRequestType = { + include?: AnilistEpisodeIncludeValues[]; +} & PaginationType; + +export type AnilistExportRequestType = { + AnidbEpisodeID?: number | null; + AnidbAnimeID?: number | null; + AnilistAnimeID?: number | null; + AnilistEpisodeID?: number | null; + Automatic?: 'True' | 'False' | 'Only'; + WithEpisodes?: 'True' | 'False' | 'Only'; + IncludeComments?: boolean; + SectionSet?: ('Anime' | 'Episode')[] | null; +}; + +export type AnilistImportRequestType = { + file: File; + removeExisting?: boolean; + addMissingAnime?: boolean; +}; diff --git a/src/core/react-query/series/mutations.ts b/src/core/react-query/series/mutations.ts index 3223488d3..29318866a 100644 --- a/src/core/react-query/series/mutations.ts +++ b/src/core/react-query/series/mutations.ts @@ -121,6 +121,25 @@ export const useUpdateSeriesTMDBImagesMutation = (seriesId: number) => onSuccess: () => toast.success('TMDB image download queued!'), }); +export const useAutoSearchAnilistMatchMutation = (seriesId: number) => + useMutation({ + mutationFn: () => axios.post(`Series/${seriesId}/Anilist/Action/AutoSearch`), + onSuccess: () => toast.success('AniList auto-search queued!'), + }); + +export const useRefreshSeriesAnilistInfoMutation = (seriesId: number) => + useMutation({ + mutationFn: () => axios.post(`Series/${seriesId}/Anilist/Anime/Action/Refresh`, {}), + onSuccess: () => toast.success('AniList refresh queued!'), + }); + +export const useUpdateSeriesAnilistImagesMutation = (seriesId: number) => + useMutation({ + mutationFn: ({ force = false }: { force?: boolean }) => + axios.post(`Series/${seriesId}/Anilist/Anime/Action/DownloadImages`, { force }), + onSuccess: () => toast.success('AniList image download queued!'), + }); + export const useRelocateSeriesFilesMutation = (seriesId: number) => useMutation({ mutationFn: () => axios.post(`Series/${seriesId}/File/Relocate`), diff --git a/src/core/types/api/anilist.ts b/src/core/types/api/anilist.ts new file mode 100644 index 000000000..34b038c5f --- /dev/null +++ b/src/core/types/api/anilist.ts @@ -0,0 +1,133 @@ +import type { ImageType, RatingType } from '@/core/types/api/common'; +import type { MatchRatingValues } from '@/core/types/api/episode'; + +export type AnilistAnimeTypeValues = + | 'Unknown' + | 'TVSeries' + | 'TVShort' + | 'Movie' + | 'OVA' + | 'Web' + | 'TVSpecial' + | 'MusicVideo'; + +export type AnilistMediaStatusValues = + | 'Unknown' + | 'Finished' + | 'Releasing' + | 'NotYetReleased' + | 'Cancelled' + | 'Hiatus'; + +export type AnilistSeasonValues = 'Winter' | 'Spring' | 'Summer' | 'Fall'; + +/** + * A partial date as serialized by the server. Only the year is guaranteed to + * be present. + */ +export type AnilistPartialDateType = string; + +export type AnilistRatingType = Omit & { + Type: string; +}; + +export type AnilistTagType = { + ID: number; + Name: string; + Description?: string | null; + Rank: number; + IsSpoiler: boolean; +}; + +export type AnilistStudioType = { + ID: number; + Name: string; + CountryOfOrigin: string; + Size: number; + Logos: ImageType[]; + Source: string; +}; + +export type AnilistAnimeXrefType = { + AnidbAnimeID: number; + AnilistAnimeID: number; + Rating: MatchRatingValues; +}; + +export type AnilistEpisodeXrefType = { + AnidbAnimeID: number; + AnidbEpisodeID: number; + AnilistAnimeID: number; + AnilistEpisodeID: number; + EpisodeNumber: number; + Index: number; + Rating: MatchRatingValues; +}; + +export type AnilistAnimeType = { + ID: number; + Title: string; + /** Transcribed canonical title; romaji for japanese anime, pinyin for chinese. */ + MainTitle: string; + NativeTitle: string; + Synonyms?: string[]; + Overview: string; + OriginalLanguage: string; + IsRestricted: boolean; + UserRating: AnilistRatingType; + MeanScore: number; + Popularity: number; + FavoriteCount: number; + Type: AnilistAnimeTypeValues; + Status: AnilistMediaStatusValues; + Source: string; + Season?: AnilistSeasonValues | null; + SeasonYear?: number | null; + EpisodeCount: number; + EpisodeDuration?: number | null; + Genres: string[]; + CoverImage?: string | null; + BannerImage?: string | null; + Color?: string | null; + MalIDs?: number[]; + Tags?: AnilistTagType[]; + Studios?: AnilistStudioType[]; + CrossReferences?: AnilistAnimeXrefType[]; + StartedAt?: AnilistPartialDateType | null; + EndedAt?: AnilistPartialDateType | null; + CreatedAt: string; + LastUpdatedAt: string; +}; + +export type AnilistEpisodeType = { + ID: number; + AnimeID: number; + ScheduleID?: number | null; + EpisodeNumber: number; + Runtime: string; + AiredAt?: string | null; + CreatedAt: string; + LastUpdatedAt: string; + CrossReferences?: AnilistEpisodeXrefType[]; +}; + +export type AnilistSearchResultType = { + ID: number; + Title: string; + OriginalTitle: string; + OriginalLanguage: string; + Overview: string; + IsRestricted: boolean; + Type: AnilistAnimeTypeValues; + FirstAiredAt?: AnilistPartialDateType | null; + CoverImage?: string | null; + BannerImage?: string | null; + UserRating: AnilistRatingType; +}; + +export type AnilistAutoSearchResultType = { + AnimeID: number; + IsLocal: boolean; + IsRemote: boolean; + Anime: AnilistSearchResultType; +}; diff --git a/src/core/types/api/episode.ts b/src/core/types/api/episode.ts index 1c006f8e4..14356a8b9 100644 --- a/src/core/types/api/episode.ts +++ b/src/core/types/api/episode.ts @@ -58,10 +58,12 @@ export type AniDBEpisodeType = { }; export type MatchRatingValues = + | 'DateAndNumberMatches' | 'DateAndTitleKindaMatches' | 'DateAndTitleMatches' | 'DateKindaMatches' | 'DateMatches' + | 'DateOffsetMatches' | 'FirstAvailable' | 'None' | 'TitleKindaMatches' diff --git a/src/core/types/api/series.ts b/src/core/types/api/series.ts index ee0605d70..e780ad234 100644 --- a/src/core/types/api/series.ts +++ b/src/core/types/api/series.ts @@ -28,6 +28,7 @@ export type SeriesIDsType = { ParentGroup: number; TopLevelGroup: number; AniDB: number; + AniList: number[]; MAL: number[]; TMDB: { Movie: number[]; diff --git a/src/core/util.ts b/src/core/util.ts index 0ebdd3612..9cf658275 100644 --- a/src/core/util.ts +++ b/src/core/util.ts @@ -29,6 +29,7 @@ dayjs.extend(customParseFormatPlugin); export { default as dayjs } from 'dayjs'; export * from '@/core/anidbUtils'; +export * from '@/core/anilistUtils'; /** * Shared stale time: ~100 days. Used for relatively static data that rarely changes server-side. From 86e792622f79743570df9239b6e940aaac76009d Mon Sep 17 00:00:00 2001 From: revam Date: Mon, 14 Sep 2026 05:51:00 +0200 Subject: [PATCH 2/6] feat: add anilist linking, settings and series actions Wired AniList into the WebUI using the same component structure as the TMDB linking flow. - Added the `AnilistLinking` page at `series/:seriesId/anilist-linking` with auto-search preview, online search, linking, episode mapping overrides, auto-match apply and a reset-to-auto action; picking a result runs an immediate quick refresh before linking - Added `AnilistLinkSelectPanel`, `EpisodeRow` and `EpisodeSelect` under `components/Collection/Anilist`, reusing the TMDB `AniDBEpisode`, `MatchRating` and `TopPanel` components - Generalised `TopPanel` to any rated cross-reference with an optional reset handler and taught `MatchRating` the new rating values - Listed linked AniList anime from `IDs.AniList` on the series overview and extended `SeriesMetadata` with add/edit/remove for AniList - Added AniList auto-search, refresh and force image download entries to the series update actions tab - Added the AniList settings tab, the first-run metadata source tab, the `Anilist` server settings type (including `ImageCdnUrl` and `RateLimit`) and the `collection.anilist.includeRestricted` WebUI setting - Auto-search and online search show a "temporarily unavailable, try again in N seconds" state with a retry button on upstream outages --- .../Anilist/AnilistLinkSelectPanel.tsx | 197 ++++++++ .../Collection/Anilist/EpisodeRow.tsx | 170 +++++++ .../Collection/Anilist/EpisodeSelect.tsx | 213 +++++++++ .../EditSeriesTabs/UpdateActionsTab.tsx | 25 + src/components/Collection/SeriesMetadata.tsx | 32 +- .../Collection/Tmdb/MatchRating.tsx | 9 +- src/components/Collection/Tmdb/TopPanel.tsx | 32 +- .../AnilistDownloadSettings.tsx | 94 ++++ .../MetadataSitesSettings/AnilistSettings.tsx | 68 +++ src/core/react-query/settings/helpers.ts | 20 + src/core/router/index.tsx | 4 + src/core/types/api/settings.ts | 108 +++++ .../collection/series/AnilistLinking.tsx | 437 ++++++++++++++++++ .../collection/series/SeriesOverview.tsx | 19 +- src/pages/firstrun/MetadataSources.tsx | 10 + .../MetadataSourcesTabs/AnilistTab.tsx | 24 + src/pages/settings/SettingsPage.tsx | 1 + src/pages/settings/tabs/AnilistSettings.tsx | 45 ++ 18 files changed, 1492 insertions(+), 16 deletions(-) create mode 100644 src/components/Collection/Anilist/AnilistLinkSelectPanel.tsx create mode 100644 src/components/Collection/Anilist/EpisodeRow.tsx create mode 100644 src/components/Collection/Anilist/EpisodeSelect.tsx create mode 100644 src/components/Settings/MetadataSitesSettings/AnilistDownloadSettings.tsx create mode 100644 src/components/Settings/MetadataSitesSettings/AnilistSettings.tsx create mode 100644 src/pages/collection/series/AnilistLinking.tsx create mode 100644 src/pages/firstrun/MetadataSourcesTabs/AnilistTab.tsx create mode 100644 src/pages/settings/tabs/AnilistSettings.tsx 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/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/SeriesOverview.tsx b/src/pages/collection/series/SeriesOverview.tsx index 3816ab049..cd30c62d1 100644 --- a/src/pages/collection/series/SeriesOverview.tsx +++ b/src/pages/collection/series/SeriesOverview.tsx @@ -23,7 +23,7 @@ import type { ImageType } from '@/core/types/api/common'; import type { SeriesCast } from '@/core/types/api/series'; // Links -const MetadataLinks = ['AniDB', 'TMDB'] as const; +const MetadataLinks = ['AniDB', 'TMDB', 'AniList'] as const; const SeriesOverview = () => { const { series } = useOutletContext(); @@ -105,7 +105,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); diff --git a/src/pages/firstrun/MetadataSources.tsx b/src/pages/firstrun/MetadataSources.tsx index f8702b0a4..a3435feec 100644 --- a/src/pages/firstrun/MetadataSources.tsx +++ b/src/pages/firstrun/MetadataSources.tsx @@ -11,6 +11,7 @@ import useFirstRunSettingsContext from '@/hooks/useFirstRunSettingsContext'; import Footer from './Footer'; import AniDBTab from './MetadataSourcesTabs/AniDBTab'; +import AnilistTab from './MetadataSourcesTabs/AnilistTab'; import TMDBTab from './MetadataSourcesTabs/TMDBTab'; import type { TestStatusType } from '@/core/slices/firstrun'; @@ -46,6 +47,8 @@ const TabContent = ({ setStatus, tab }: { setStatus: (status: TestStatusType) => 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; From c1ee18b0d05d5236a7c2f8b119354203059ca5da Mon Sep 17 00:00:00 2001 From: revam Date: Mon, 14 Sep 2026 05:51:08 +0200 Subject: [PATCH 3/6] feat: add language filter and anilist source to the series credits page Every cast and crew role now carries an optional `Language` code (the work's original language for AniDB and TMDB, the dub language for AniList), so the credits page gained a "Language" select listing the languages present in the active Characters/Staff list. It feeds the filtered list and the "X of Y listed" count, resets on mode change and stays hidden while there are fewer than two languages to choose from. Also added an AniDB/AniList source switch: the AniList mode fetches cast and crew for every linked AniList anime, concatenates them and feeds the same panels and filters, and the refresh action refreshes whichever source is selected. --- src/core/types/api/series.ts | 7 +- src/pages/collection/series/SeriesCredits.tsx | 88 +++++++++++++++++-- 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/src/core/types/api/series.ts b/src/core/types/api/series.ts index e780ad234..2d6fc922b 100644 --- a/src/core/types/api/series.ts +++ b/src/core/types/api/series.ts @@ -173,7 +173,12 @@ export type SeriesRolePerson = { }; export type SeriesCast = { - Language: string; + /** + * Language code of the role, eg. `ja`, `en`. AniDB and TMDB roles report + * the work's original language, AniList roles report the language the + * voice actor or dub staff member performs in. Omitted when unknown. + */ + Language?: string; Staff: SeriesRolePerson; Character?: SeriesRolePerson; RoleName: string; 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 => ( + + ))} + + )} + + +
From 8c34322e5e0dbbe4b52d40a9839ba77ca36009eb Mon Sep 17 00:00:00 2001 From: revam Date: Mon, 14 Sep 2026 05:51:14 +0200 Subject: [PATCH 4/6] feat: add banners tab to the series images page Added a `Banners` category to `SeriesImages`, mapped to the `Banner` image entity type, so wide banner strips (eg. the 1900x400 AniList banner) can be browsed, preferred and uploaded like the other image types. - Extended `ImageTabType` and the upload modal's tab map - Added a wide 38x8 rem grid item size to suit ~4.75:1 strips --- src/components/Collection/Series/ImageUploadModal.tsx | 1 + src/core/types/api/image.ts | 2 +- src/pages/collection/series/SeriesImages.tsx | 3 +++ 3 files changed, 5 insertions(+), 1 deletion(-) 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/core/types/api/image.ts b/src/core/types/api/image.ts index 2ec29f089..5b1e12151 100644 --- a/src/core/types/api/image.ts +++ b/src/core/types/api/image.ts @@ -15,4 +15,4 @@ export type RandomImageMetadataResultType = { }; }; -export type ImageTabType = 'Posters' | 'Backdrops' | 'Logos'; +export type ImageTabType = 'Posters' | 'Backdrops' | 'Banners' | 'Logos'; 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 }, }; From 1ca70bca5b85010b9523151fc9b80b00441a5b1f Mon Sep 17 00:00:00 2001 From: revam Date: Mon, 14 Sep 2026 05:51:19 +0200 Subject: [PATCH 5/6] feat: show social and trailer links with region codes on the series overview Series `Links` may now be of type `Social` or `Trailer` (from AniList external links and trailers), and per-region links such as AniList streaming links carry a `LanguageCode`. - Added `SeriesLinkTypeValues` and the optional `LanguageCode` to `SeriesLinkType` - Social and trailer links get their own icons on the overview links panel; every other type keeps the existing globe - A small uppercase region badge is shown next to a link when it has a `LanguageCode`, and the row key includes type and region so per-region duplicates of the same URL stay distinct --- src/core/types/api/series.ts | 12 ++++++++- .../collection/series/SeriesOverview.tsx | 25 +++++++++++++++---- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/core/types/api/series.ts b/src/core/types/api/series.ts index 2d6fc922b..1b556dde0 100644 --- a/src/core/types/api/series.ts +++ b/src/core/types/api/series.ts @@ -90,10 +90,20 @@ export type SeriesTitleType = { Type: string; }; +export type SeriesLinkTypeValues = + | 'Website' + | 'Streaming' + | 'Metadata' + | 'CrossReference' + | 'Social' + | 'Trailer'; + export type SeriesLinkType = { - Type: string; + Type: SeriesLinkTypeValues; Name: string; URL: string; + /** Language/region code for per-region links, eg. AniList streaming links. */ + LanguageCode?: string | null; }; export type SeriesSizesType = { diff --git a/src/pages/collection/series/SeriesOverview.tsx b/src/pages/collection/series/SeriesOverview.tsx index cd30c62d1..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,11 +20,17 @@ 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', '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(); @@ -140,19 +146,28 @@ const SeriesOverview = () => { > {series.Links.map(link => ( {link.Name} + {link.LanguageCode && ( + + {link.LanguageCode} + + )} Date: Mon, 14 Sep 2026 05:51:24 +0200 Subject: [PATCH 6/6] feat: add a calendar utility page Added a month/week calendar of airing episodes under `utilities/calendar`, reachable from the utilities bar, backed by the ranged `Dashboard/CalendarEpisodes` endpoint. - Added `useDashboardCalendarEpisodesQuery` and `DashboardCalendarEpisodesRequestType`; one request covers the visible range, padded a day each side so UTC/local day shifts are covered - Added `AiredAt`, `HasAirTime` and `IsAirTimeEstimated` to `DashboardEpisodeDetailsType` - Episodes are grouped into the viewer's local day from `AiredAt` when a broadcast time is known, otherwise by `AirDate`; cards show the poster, series title, episode number/title and the local time, with estimated times muted and marked with a tilde - Cards link to the series page for series in the collection and to AniDB otherwise, like the dashboard's upcoming anime panel - Previous/next/today navigation, a Month/Week switch, "Include Missing", "Include Restricted" and "Only With Air Time" toggles, and episode type chips that send the `type` parameter only when the selection differs from the server default --- src/components/Layout/TopNav.tsx | 7 + src/core/react-query/dashboard/queries.ts | 8 + src/core/react-query/dashboard/types.ts | 14 + src/core/router/index.tsx | 2 + src/core/types/api/dashboard.ts | 7 + src/pages/utilities/Calendar.tsx | 319 ++++++++++++++++++++++ 6 files changed, 357 insertions(+) create mode 100644 src/pages/utilities/Calendar.tsx diff --git a/src/components/Layout/TopNav.tsx b/src/components/Layout/TopNav.tsx index 09c384763..df1a67d23 100644 --- a/src/components/Layout/TopNav.tsx +++ b/src/components/Layout/TopNav.tsx @@ -3,6 +3,7 @@ import type { MouseEvent } from 'react'; import AnimateHeight from 'react-animate-height'; import { Link, NavLink, useLocation } from 'react-router'; import { + mdiCalendarMonthOutline, mdiCogOutline, mdiDownloadCircleOutline, mdiFileDocumentAlertOutline, @@ -336,6 +337,12 @@ const TopNav = () => { path="utilities/missing-episodes" text="Missing Episodes" /> +
diff --git a/src/core/react-query/dashboard/queries.ts b/src/core/react-query/dashboard/queries.ts index f091dac54..188b0f1b4 100644 --- a/src/core/react-query/dashboard/queries.ts +++ b/src/core/react-query/dashboard/queries.ts @@ -5,6 +5,7 @@ import { transformSeriesSummary } from '@/core/react-query/dashboard/helpers'; import { transformListResultSimplified } from '@/core/react-query/helpers'; import type { + DashboardCalendarEpisodesRequestType, DashboardCalendarRequestType, DashboardContinueWatchingRequestType, DashboardNextUpRequestType, @@ -24,6 +25,13 @@ export const useDashboardCalendarQuery = (params: DashboardCalendarRequestType) queryFn: () => axios.get('Dashboard/AniDBCalendar', { params }), }); +export const useDashboardCalendarEpisodesQuery = (params: DashboardCalendarEpisodesRequestType, enabled = true) => + useQuery({ + queryKey: ['dashboard', 'calendar', 'episodes', params], + queryFn: () => axios.get('Dashboard/CalendarEpisodes', { params }), + enabled, + }); + export const useDashboardContinueWatchingQuery = (params: DashboardContinueWatchingRequestType) => useQuery, unknown, DashboardEpisodeDetailsType[]>({ queryKey: ['dashboard', 'continue-watching', params], diff --git a/src/core/react-query/dashboard/types.ts b/src/core/react-query/dashboard/types.ts index bcaa10be2..9dd877a66 100644 --- a/src/core/react-query/dashboard/types.ts +++ b/src/core/react-query/dashboard/types.ts @@ -1,4 +1,5 @@ import type { DashboardRequestType } from '@/core/react-query/types'; +import type { EpisodeTypeValues } from '@/core/types/api/episode'; export type DashboardCalendarRequestType = { includeRestricted?: boolean; @@ -12,3 +13,16 @@ export type DashboardContinueWatchingRequestType = { export type DashboardNextUpRequestType = { onlyUnwatched?: boolean; } & DashboardRequestType; + +export type DashboardCalendarEpisodesRequestType = { + /** Inclusive start of the range, `YYYY-MM-DD`. */ + startDate: string; + /** Inclusive end of the range, `YYYY-MM-DD`. */ + endDate: string; + includeMissing?: 'True' | 'False' | 'Only'; + includeRestricted?: 'True' | 'False' | 'Only'; + /** `Only` keeps episodes with a known broadcast time, `False` those without, `True` (default) all. */ + includeWithAirTime?: 'True' | 'False' | 'Only'; + /** AniDB episode types to include. Omitted, the server defaults to `Episode` only. */ + type?: EpisodeTypeValues[]; +}; diff --git a/src/core/router/index.tsx b/src/core/router/index.tsx index 10160feae..70558b808 100644 --- a/src/core/router/index.tsx +++ b/src/core/router/index.tsx @@ -46,6 +46,7 @@ import TmdbSettings from '@/pages/settings/tabs/TmdbSettings'; import UserManagementSettings from '@/pages/settings/tabs/UserManagementSettings'; import StatusPage from '@/pages/StatusPage'; import UnsupportedPage from '@/pages/unsupported/UnsupportedPage'; +import Calendar from '@/pages/utilities/Calendar'; import DuplicateFilesLinkedTab from '@/pages/utilities/DuplicateFilesTabs/DuplicateFilesLinkedTab'; import DuplicateFilesUnrecognizedTab from '@/pages/utilities/DuplicateFilesTabs/DuplicateFilesUnrecognizedTab'; import FileSearch from '@/pages/utilities/FileSearch'; @@ -108,6 +109,7 @@ const router = sentryCreateBrowserRouter( } /> } /> } /> + } /> } /> diff --git a/src/core/types/api/dashboard.ts b/src/core/types/api/dashboard.ts index 1d9948ac1..10d56ea07 100644 --- a/src/core/types/api/dashboard.ts +++ b/src/core/types/api/dashboard.ts @@ -41,7 +41,14 @@ export type DashboardEpisodeDetailsType = { Title: string; Number: number; Type: EpisodeTypeValues; + /** UTC calendar day of the broadcast (`YYYY-MM-DD`) when the time is known, otherwise the AniDB air date. */ AirDate: string | null; + /** ISO UTC datetime; midnight UTC on `AirDate` when no broadcast time is known. */ + AiredAt: string | null; + /** `AiredAt` carries a real broadcast time (from AniList, or estimated). */ + HasAirTime: boolean; + /** The broadcast time is an estimate learned from the series' other episodes. */ + IsAirTimeEstimated: boolean; Duration: string; ResumePosition: string | null; Watched: string | null; 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;