diff options
| author | Fuwn <[email protected]> | 2024-07-07 21:55:32 -0700 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2024-07-07 21:55:32 -0700 |
| commit | 3c85251b260d7abbb1b3f48f2621be9b4e45124c (patch) | |
| tree | 654ba8280d2c7105049527a00e9c2fde121d15fa /src | |
| parent | feat(attributions): add a few (diff) | |
| download | due.moe-3c85251b260d7abbb1b3f48f2621be9b4e45124c.tar.xz due.moe-3c85251b260d7abbb1b3f48f2621be9b4e45124c.zip | |
feat(tools): sequel catcher
Diffstat (limited to 'src')
| -rw-r--r-- | src/lib/Data/AniList/media.ts | 133 | ||||
| -rw-r--r-- | src/lib/Data/AniList/prequels.ts | 10 | ||||
| -rw-r--r-- | src/lib/Tools/SequelCatcher.svelte | 112 | ||||
| -rw-r--r-- | src/lib/Tools/tools.ts | 6 | ||||
| -rw-r--r-- | src/routes/tools/[tool]/+page.svelte | 3 |
5 files changed, 231 insertions, 33 deletions
diff --git a/src/lib/Data/AniList/media.ts b/src/lib/Data/AniList/media.ts index 17b428cd..89d6e339 100644 --- a/src/lib/Data/AniList/media.ts +++ b/src/lib/Data/AniList/media.ts @@ -5,6 +5,7 @@ import manga from '$stores/manga'; import settings from '$stores/settings'; import lastPruneTimes from '$stores/lastPruneTimes'; import { options as getOptions, type Options } from '$lib/Notification/options'; +import type { PrequelRelations } from './prequels'; export enum Type { Anime, @@ -17,26 +18,38 @@ export interface MediaTitle { native: string; } +export type MediaStatus = 'FINISHED' | 'RELEASING' | 'NOT_YET_RELEASED' | 'CANCELLED' | 'HIATUS'; +export type MediaListEntryStatus = + | 'CURRENT' + | 'PLANNING' + | 'COMPLETED' + | 'DROPPED' + | 'PAUSED' + | 'REPEATING'; +export type MediaType = 'ANIME' | 'MANGA'; +export type MediaFormat = + | 'TV' + | 'TV_SHORT' + | 'MOVIE' + | 'SPECIAL' + | 'OVA' + | 'ONA' + | 'MUSIC' + | 'MANGA' + | 'NOVEL' + | 'ONE_SHOT'; +export type MediaSeason = 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL'; + export interface Media { id: number; idMal: number; - status: 'FINISHED' | 'RELEASING' | 'NOT_YET_RELEASED' | 'CANCELLED' | 'HIATUS'; - type: 'ANIME' | 'MANGA'; + status: MediaStatus; + type: MediaType; episodes: number; chapters: number; volumes: number; duration: number; - format: - | 'TV' - | 'TV_SHORT' - | 'MOVIE' - | 'SPECIAL' - | 'OVA' - | 'ONA' - | 'MUSIC' - | 'MANGA' - | 'NOVEL' - | 'ONE_SHOT'; + format: MediaFormat; title: MediaTitle; nextAiringEpisode?: { episode: number; @@ -47,7 +60,7 @@ export interface Media { mediaListEntry?: { progress: number; progressVolumes: number; - status: 'CURRENT' | 'PLANNING' | 'COMPLETED' | 'DROPPED' | 'PAUSED' | 'REPEATING'; + status: MediaListEntryStatus; score: number; repeat: number; startedAt: { @@ -73,8 +86,9 @@ export interface Media { rank: number; }[]; genres: string[]; - season: 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL'; + season: MediaSeason; isAdult: boolean; + relations: PrequelRelations; } export const flattenLists = ( @@ -118,12 +132,12 @@ export const flattenLists = ( return processedList(flattenedList, dueInclude); }; -const collectionQueryTemplate = (type: Type, userId: number, includeCompleted: boolean) => +const collectionQueryTemplate = (type: Type, userId: number, options: CollectionOptions = {}) => `{ MediaListCollection( userId: ${userId}, type: ${type === Type.Anime ? 'ANIME' : 'MANGA'} - ${includeCompleted ? '' : ', status_not_in: [ COMPLETED ]'}) { + ${options.includeCompleted ? '' : ', status_not_in: [ COMPLETED ]'}) { lists { name entries { media { @@ -138,6 +152,31 @@ const collectionQueryTemplate = (type: Type, userId: number, includeCompleted: b startDate { year } endDate { year } coverImage { extraLarge } + ${ + options.includeRelations + ? ` + relations { + edges { + relationType + node { + id + status + title { + english + romaji + } + episodes + mediaListEntry { + status + progress + } + coverImage { extraLarge } + } + } + } + ` + : '' + } } } } @@ -150,23 +189,17 @@ interface CollectionOptions { all?: boolean; addNotification?: (preferences: Options) => void; notificationType?: string; -} - -interface NonNullCollectionOptions { - includeCompleted: boolean; - forcePrune: boolean; - all?: boolean; - addNotification?: (preferences: Options) => void; - notificationType?: string; + includeRelations?: boolean; } const assignDefaultOptions = (options: CollectionOptions) => { - const nonNullOptions: NonNullCollectionOptions = { + const nonNullOptions: CollectionOptions = { includeCompleted: false, forcePrune: false, all: false, addNotification: undefined, - notificationType: undefined + notificationType: undefined, + includeRelations: false }; if (options.includeCompleted !== undefined) @@ -177,6 +210,8 @@ const assignDefaultOptions = (options: CollectionOptions) => { nonNullOptions.addNotification = options.addNotification; if (options.notificationType !== undefined) nonNullOptions.notificationType = options.notificationType; + if (options.includeRelations !== undefined) + nonNullOptions.includeRelations = options.includeRelations; return nonNullOptions; }; @@ -229,7 +264,7 @@ export const mediaListCollection = async ( Accept: 'application/json' }, body: JSON.stringify({ - query: collectionQueryTemplate(type, userIdentity.id, options.includeCompleted) + query: collectionQueryTemplate(type, userIdentity.id, options) }) }) ).json(); @@ -277,7 +312,7 @@ export const publicMediaListCollection = async (userId: number, type: Type): Pro Accept: 'application/json' }, body: JSON.stringify({ - query: collectionQueryTemplate(type, userId, false) + query: collectionQueryTemplate(type, userId, {}) }) }) ).json() @@ -441,3 +476,43 @@ export const mediaCover = async (id: number) => }) ).json() )['data']['Media']['coverImage']['extraLarge']; + +export interface UnwatchedRelationMap { + media: Media; + unwatchedRelations: Media[]; +} + +export const filterRelations = (media: Media[]) => { + const unwatchedRelationsMap: UnwatchedRelationMap[] = []; + + for (const mediaItem of media) { + const sequels = mediaItem.relations.edges.filter( + (relation) => + relation.relationType === 'SEQUEL' && + !media.some((mediaItem) => mediaItem.id === relation.node.id) && + (relation.node.mediaListEntry + ? relation.node.mediaListEntry.status !== 'COMPLETED' + : true) && + relation.node.episodes && + relation.node.status !== 'NOT_YET_RELEASED' && + relation.node.status !== 'CANCELLED' + ); + + if (sequels) { + const unwatchedRelations: Media[] = []; + + unwatchedRelations.push(...sequels); + + if (unwatchedRelations.length === 0) { + continue; + } + + unwatchedRelationsMap.push({ + media: mediaItem, + unwatchedRelations + }); + } + } + + return unwatchedRelationsMap; +}; diff --git a/src/lib/Data/AniList/prequels.ts b/src/lib/Data/AniList/prequels.ts index 3009e9ba..f92cde26 100644 --- a/src/lib/Data/AniList/prequels.ts +++ b/src/lib/Data/AniList/prequels.ts @@ -1,5 +1,5 @@ import type { AniListAuthorisation } from './identity'; -import type { MediaTitle } from './media'; +import type { MediaListEntryStatus, MediaSeason, MediaStatus, MediaTitle } from './media'; export interface MediaPrequel { id: number; @@ -20,14 +20,16 @@ export interface MediaPrequel { }; } -interface PrequelRelations { +export interface PrequelRelations { edges: { relationType: string; node: { + id: number; title: MediaTitle; episodes: number; + status: MediaStatus; mediaListEntry: { - status: string; + status: MediaListEntryStatus; progress: number; }; coverImage: { @@ -68,7 +70,7 @@ const prequelsPage = async ( page: number, anilistAuthorisation: AniListAuthorisation, year: number, - season: 'WINTER' | 'SPRING' | 'SUMMER' | 'FALL' + season: MediaSeason ): Promise<PrequelsPage> => await ( await fetch('https://graphql.anilist.co', { diff --git a/src/lib/Tools/SequelCatcher.svelte b/src/lib/Tools/SequelCatcher.svelte new file mode 100644 index 00000000..df6c0d3a --- /dev/null +++ b/src/lib/Tools/SequelCatcher.svelte @@ -0,0 +1,112 @@ +<script lang="ts"> + import { type AniListAuthorisation } from '$lib/Data/AniList/identity'; + import userIdentity from '$stores/identity'; + import { filterRelations, type Media, mediaListCollection, Type } from '$lib/Data/AniList/media'; + import LogInRestricted from '$lib/Error/LogInRestricted.svelte'; + import LinkedTooltip from '$lib/Tooltip/LinkedTooltip.svelte'; + import anime from '$stores/anime'; + + import identity from '$stores/identity'; + import { onMount } from 'svelte'; + import lastPruneTimes from '$stores/lastPruneTimes'; + import MediaTitleDisplay from '$lib/List/MediaTitleDisplay.svelte'; + import { outboundLink } from '$lib/Media/links'; + import settings from '$stores/settings'; + import Message from '$lib/Loading/Message.svelte'; + import Skeleton from '$lib/Loading/Skeleton.svelte'; + + export let user: AniListAuthorisation; + + let mediaList: Promise<Media[]>; + + onMount(async () => { + if (user === undefined || $identity.id === -2) return; + + mediaList = mediaListCollection( + user, + $userIdentity, + Type.Anime, + $anime, + $lastPruneTimes.anime, + { + forcePrune: true, + includeCompleted: true, + all: true, + includeRelations: true + } + ); + }); +</script> + +{#if user === undefined || $identity.id === -2} + <LogInRestricted /> +{:else} + <div class="card"> + {#await mediaList} + <Message message="Cross-checking media ..." /> + + <Skeleton + card={false} + count={8} + pad={false} + height={'0.9rem'} + width={'100%'} + list + grid={false} + /> + {:then mediaListUnchecked} + {#if mediaListUnchecked} + {@const mediaList = mediaListUnchecked.filter( + (media) => media.mediaListEntry?.status === 'COMPLETED' + )} + + <ul> + {#each filterRelations(mediaList) as { media, unwatchedRelations }} + <a href={outboundLink(media, 'anime', $settings.displayOutboundLinksTo)}> + <MediaTitleDisplay title={media.title} /> + </a> + + <ul> + {#each unwatchedRelations as relation} + <li> + <a href={outboundLink(relation.node, 'anime', $settings.displayOutboundLinksTo)}> + <MediaTitleDisplay title={relation.node.title} /> + </a> + </li> + {/each} + </ul> + {/each} + </ul> + {:else} + <Message message="Cross-checking media ..." /> + + <Skeleton + card={false} + count={8} + pad={false} + height={'0.9rem'} + width={'100%'} + list + grid={false} + /> + {/if} + {:catch} + <Message message="" loader="ripple" slot withReload fullscreen>Error fetching media.</Message> + {/await} + + <p /> + + <blockquote style="margin: 0 0 0 1.5rem;"> + Thanks to <a href="https://anilist.co/user/sevengirl/">@sevengirl</a> and + <a href="https://anilist.co/user/esthereae/">@esthereae</a> for the idea! + </blockquote> + </div> +{/if} + +<style> + .hint-toggle { + box-shadow: rgba(0, 0, 11, 0.2) 0px 7px 29px 0px, 0 0 0 5px var(--base02); + float: right; + border-radius: 8px; + } +</style> diff --git a/src/lib/Tools/tools.ts b/src/lib/Tools/tools.ts index 3feaf9e0..2593ea49 100644 --- a/src/lib/Tools/tools.ts +++ b/src/lib/Tools/tools.ts @@ -83,5 +83,11 @@ export const tools: { name: () => 'Anime Girls Holding Programming Books', id: 'girls', description: () => 'Find anime girls holding programming books by language' + }, + sequel_catcher: { + name: () => 'Sequel Catcher', + description: () => + 'Check if any completed anime on your lists have sequels you have not yet seen', + id: 'sequel_catcher' } }; diff --git a/src/routes/tools/[tool]/+page.svelte b/src/routes/tools/[tool]/+page.svelte index 63596844..dee9b4e6 100644 --- a/src/routes/tools/[tool]/+page.svelte +++ b/src/routes/tools/[tool]/+page.svelte @@ -18,6 +18,7 @@ import root from '$lib/Utility/root.js'; import Popup from '$lib/Layout/Popup.svelte'; import HololiveBirthdays from '$lib/Tools/HololiveBirthdays.svelte'; + import SequelCatcher from '$lib/Tools/SequelCatcher.svelte'; export let data; @@ -77,5 +78,7 @@ <Hayai /> {:else if tool === 'hololive_birthdays'} <HololiveBirthdays /> + {:else if tool === 'sequel_catcher'} + <SequelCatcher user={data.user} /> {/if} {/if} |