ani-mcp 0.8.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -95,6 +95,19 @@ Works with any MCP-compatible client.
95
95
  | `anilist_sequels` | Sequels airing this season for titles you've completed |
96
96
  | `anilist_watch_order` | Viewing order for a franchise |
97
97
  | `anilist_session` | Plan a viewing session within a time budget |
98
+ | `anilist_mal_import` | Import a MyAnimeList user's list and generate recommendations |
99
+
100
+ ### Cards
101
+
102
+ | Tool | Description |
103
+ | --- | --- |
104
+ | `anilist_taste_card` | Generate a shareable taste profile card as a PNG image |
105
+ | `anilist_compat_card` | Generate a compatibility card comparing two users as a PNG image |
106
+
107
+ <p align="center">
108
+ <img src="assets/taste-card.png" width="400" alt="Taste Profile Card">
109
+ <img src="assets/compat-card.png" width="400" alt="Compatibility Card">
110
+ </p>
98
111
 
99
112
  ### Info
100
113
 
@@ -104,6 +117,7 @@ Works with any MCP-compatible client.
104
117
  | `anilist_staff_search` | Search for a person by name and see all their works |
105
118
  | `anilist_studio_search` | Search for a studio and see their productions |
106
119
  | `anilist_schedule` | Airing schedule and next episode countdown |
120
+ | `anilist_airing` | Upcoming episodes for titles you're currently watching |
107
121
  | `anilist_characters` | Search characters by name with appearances and VAs |
108
122
  | `anilist_whoami` | Check authentication status and score format |
109
123
 
@@ -13,7 +13,7 @@ const ANILIST_API_URL = process.env.ANILIST_API_URL || "https://graphql.anilist.
13
13
  const RATE_LIMIT_PER_MINUTE = process.env.VITEST ? 10_000 : 85;
14
14
  const MAX_RETRIES = 3;
15
15
  // Hard timeout per fetch attempt (retries get their own timeout)
16
- const FETCH_TIMEOUT_MS = 15_000;
16
+ const FETCH_TIMEOUT_MS = process.env.VITEST ? 500 : 15_000;
17
17
  // === Logging ===
18
18
  const DEBUG = process.env.DEBUG === "true" || process.env.DEBUG === "1";
19
19
  // Extract query operation name (e.g. "SearchMedia" from "query SearchMedia(...)")
@@ -0,0 +1,23 @@
1
+ /** Jikan (MyAnimeList) API client for read-only list import */
2
+ export interface JikanAnimeEntry {
3
+ score: number;
4
+ episodes_watched: number;
5
+ anime: {
6
+ mal_id: number;
7
+ title: string;
8
+ type: string;
9
+ episodes: number | null;
10
+ score: number | null;
11
+ genres: Array<{
12
+ mal_id: number;
13
+ name: string;
14
+ }>;
15
+ year: number | null;
16
+ };
17
+ }
18
+ /** Map a MAL genre name to its AniList equivalent */
19
+ export declare function mapMalGenre(name: string): string;
20
+ /** Map a MAL format string to AniList format */
21
+ export declare function mapMalFormat(malType: string): string;
22
+ /** Fetch a MAL user's completed anime list via Jikan */
23
+ export declare function fetchMalList(username: string, maxPages?: number): Promise<JikanAnimeEntry[]>;
@@ -0,0 +1,61 @@
1
+ /** Jikan (MyAnimeList) API client for read-only list import */
2
+ import pThrottle from "p-throttle";
3
+ import pRetry, { AbortError } from "p-retry";
4
+ const JIKAN_BASE = "https://api.jikan.moe/v4";
5
+ const FETCH_TIMEOUT_MS = 15_000;
6
+ // Jikan rate limit: 3 req/sec (unauthenticated)
7
+ const throttle = pThrottle({
8
+ limit: process.env.VITEST ? 10_000 : 3,
9
+ interval: 1_000,
10
+ });
11
+ const throttled = throttle(() => { });
12
+ // === Genre Mapping ===
13
+ // MAL and AniList share most genre names; only map exceptions
14
+ const MAL_TO_ANILIST_GENRE = {
15
+ "Sci-Fi": "Sci-Fi",
16
+ "Slice of Life": "Slice of Life",
17
+ };
18
+ /** Map a MAL genre name to its AniList equivalent */
19
+ export function mapMalGenre(name) {
20
+ return MAL_TO_ANILIST_GENRE[name] ?? name;
21
+ }
22
+ /** Map a MAL format string to AniList format */
23
+ export function mapMalFormat(malType) {
24
+ const map = {
25
+ TV: "TV",
26
+ Movie: "MOVIE",
27
+ OVA: "OVA",
28
+ ONA: "ONA",
29
+ Special: "SPECIAL",
30
+ Music: "MUSIC",
31
+ };
32
+ return map[malType] ?? "TV";
33
+ }
34
+ // === Client ===
35
+ /** Fetch a MAL user's completed anime list via Jikan */
36
+ export async function fetchMalList(username, maxPages = 5) {
37
+ const entries = [];
38
+ for (let page = 1; page <= maxPages; page++) {
39
+ const data = await fetchPage(username, page);
40
+ entries.push(...data.data);
41
+ if (!data.pagination.has_next_page)
42
+ break;
43
+ }
44
+ return entries;
45
+ }
46
+ async function fetchPage(username, page) {
47
+ return pRetry(async () => {
48
+ await throttled();
49
+ const url = `${JIKAN_BASE}/users/${encodeURIComponent(username)}/animelist?status=completed&page=${page}`;
50
+ const response = await fetch(url, {
51
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
52
+ });
53
+ if (!response.ok) {
54
+ if (response.status === 404) {
55
+ throw new AbortError(`MAL user "${username}" not found.`);
56
+ }
57
+ throw new Error(`Jikan API error (HTTP ${response.status})`);
58
+ }
59
+ return (await response.json());
60
+ }, { retries: 3 });
61
+ }
@@ -5,35 +5,37 @@
5
5
  * if the AniList schema changes.
6
6
  */
7
7
  /** Paginated search with optional genre, year, and format filters */
8
- export declare const SEARCH_MEDIA_QUERY = "\n query SearchMedia(\n $search: String!\n $type: MediaType\n $genre: [String]\n $year: Int\n $format: MediaFormat\n $isAdult: Boolean\n $page: Int\n $perPage: Int\n $sort: [MediaSort]\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo {\n total\n currentPage\n lastPage\n hasNextPage\n }\n media(\n search: $search\n type: $type\n genre_in: $genre\n startDate_year: $year\n format: $format\n isAdult: $isAdult\n sort: $sort\n ) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
8
+ export declare const SEARCH_MEDIA_QUERY = "\n query SearchMedia(\n $search: String!\n $type: MediaType\n $genre: [String]\n $year: Int\n $format: MediaFormat\n $isAdult: Boolean\n $page: Int\n $perPage: Int\n $sort: [MediaSort]\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo {\n total\n currentPage\n lastPage\n hasNextPage\n }\n media(\n search: $search\n type: $type\n genre_in: $genre\n startDate_year: $year\n format: $format\n isAdult: $isAdult\n sort: $sort\n ) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large extraLarge }\n trailer { id site thumbnail }\n siteUrl\n description(asHtml: false)\n }\n\n";
9
9
  /** Full media lookup with relations and recommendations */
10
- export declare const MEDIA_DETAILS_QUERY = "\n query MediaDetails($id: Int, $search: String) {\n Media(id: $id, search: $search) {\n ...MediaFields\n relations {\n edges {\n relationType\n node {\n id\n title { romaji english }\n format\n status\n type\n }\n }\n }\n recommendations(sort: RATING_DESC, perPage: 5) {\n nodes {\n rating\n mediaRecommendation {\n id\n title { romaji english }\n format\n meanScore\n genres\n siteUrl\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
10
+ export declare const MEDIA_DETAILS_QUERY = "\n query MediaDetails($id: Int, $search: String) {\n Media(id: $id, search: $search) {\n ...MediaFields\n relations {\n edges {\n relationType\n node {\n id\n title { romaji english }\n format\n status\n type\n }\n }\n }\n recommendations(sort: RATING_DESC, perPage: 5) {\n nodes {\n rating\n mediaRecommendation {\n id\n title { romaji english }\n format\n meanScore\n genres\n siteUrl\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large extraLarge }\n trailer { id site thumbnail }\n siteUrl\n description(asHtml: false)\n }\n\n";
11
11
  /** Discover top-rated titles by genre without a search term */
12
- export declare const DISCOVER_MEDIA_QUERY = "\n query DiscoverMedia(\n $type: MediaType\n $genre_in: [String]\n $page: Int\n $perPage: Int\n $sort: [MediaSort]\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n media(type: $type, genre_in: $genre_in, sort: $sort) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
12
+ export declare const DISCOVER_MEDIA_QUERY = "\n query DiscoverMedia(\n $type: MediaType\n $genre_in: [String]\n $page: Int\n $perPage: Int\n $sort: [MediaSort]\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n media(type: $type, genre_in: $genre_in, sort: $sort) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large extraLarge }\n trailer { id site thumbnail }\n siteUrl\n description(asHtml: false)\n }\n\n";
13
13
  /** Browse anime by season and year */
14
- export declare const SEASONAL_MEDIA_QUERY = "\n query SeasonalMedia(\n $season: MediaSeason\n $seasonYear: Int\n $type: MediaType\n $isAdult: Boolean\n $sort: [MediaSort]\n $page: Int\n $perPage: Int\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total currentPage lastPage hasNextPage }\n media(\n season: $season\n seasonYear: $seasonYear\n type: $type\n isAdult: $isAdult\n sort: $sort\n ) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
14
+ export declare const SEASONAL_MEDIA_QUERY = "\n query SeasonalMedia(\n $season: MediaSeason\n $seasonYear: Int\n $type: MediaType\n $isAdult: Boolean\n $sort: [MediaSort]\n $page: Int\n $perPage: Int\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total currentPage lastPage hasNextPage }\n media(\n season: $season\n seasonYear: $seasonYear\n type: $type\n isAdult: $isAdult\n sort: $sort\n ) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large extraLarge }\n trailer { id site thumbnail }\n siteUrl\n description(asHtml: false)\n }\n\n";
15
15
  /** User profile statistics - watching/reading stats, genre/tag/score breakdowns */
16
16
  export declare const USER_STATS_QUERY = "\n query UserStats($name: String!) {\n User(name: $name) {\n id\n name\n mediaListOptions {\n scoreFormat\n }\n statistics {\n anime {\n count\n meanScore\n minutesWatched\n episodesWatched\n genres(sort: COUNT_DESC, limit: 10) {\n genre\n count\n meanScore\n minutesWatched\n }\n scores(sort: MEAN_SCORE_DESC) {\n score\n count\n }\n formats(sort: COUNT_DESC) {\n format\n count\n }\n }\n manga {\n count\n meanScore\n chaptersRead\n volumesRead\n genres(sort: COUNT_DESC, limit: 10) {\n genre\n count\n meanScore\n chaptersRead\n }\n scores(sort: MEAN_SCORE_DESC) {\n score\n count\n }\n formats(sort: COUNT_DESC) {\n format\n count\n }\n }\n }\n }\n }\n";
17
17
  /** Media recommendations for a given title */
18
- export declare const RECOMMENDATIONS_QUERY = "\n query MediaRecommendations($id: Int, $search: String, $perPage: Int) {\n Media(id: $id, search: $search) {\n id\n title { romaji english native }\n recommendations(sort: RATING_DESC, perPage: $perPage) {\n nodes {\n rating\n mediaRecommendation {\n ...MediaFields\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
18
+ export declare const RECOMMENDATIONS_QUERY = "\n query MediaRecommendations($id: Int, $search: String, $perPage: Int) {\n Media(id: $id, search: $search) {\n id\n title { romaji english native }\n recommendations(sort: RATING_DESC, perPage: $perPage) {\n nodes {\n rating\n mediaRecommendation {\n ...MediaFields\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large extraLarge }\n trailer { id site thumbnail }\n siteUrl\n description(asHtml: false)\n }\n\n";
19
19
  /** Trending anime or manga right now */
20
- export declare const TRENDING_MEDIA_QUERY = "\n query TrendingMedia(\n $type: MediaType\n $isAdult: Boolean\n $page: Int\n $perPage: Int\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n media(type: $type, isAdult: $isAdult, sort: TRENDING_DESC) {\n ...MediaFields\n trending\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
20
+ export declare const TRENDING_MEDIA_QUERY = "\n query TrendingMedia(\n $type: MediaType\n $isAdult: Boolean\n $page: Int\n $perPage: Int\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n media(type: $type, isAdult: $isAdult, sort: TRENDING_DESC) {\n ...MediaFields\n trending\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large extraLarge }\n trailer { id site thumbnail }\n siteUrl\n description(asHtml: false)\n }\n\n";
21
21
  /** Browse by genre without a search term, with optional filters */
22
- export declare const GENRE_BROWSE_QUERY = "\n query GenreBrowse(\n $type: MediaType\n $genre_in: [String]\n $year: Int\n $status: MediaStatus\n $format: MediaFormat\n $isAdult: Boolean\n $sort: [MediaSort]\n $page: Int\n $perPage: Int\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n media(\n type: $type\n genre_in: $genre_in\n startDate_year: $year\n status: $status\n format: $format\n isAdult: $isAdult\n sort: $sort\n ) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
22
+ export declare const GENRE_BROWSE_QUERY = "\n query GenreBrowse(\n $type: MediaType\n $genre_in: [String]\n $year: Int\n $status: MediaStatus\n $format: MediaFormat\n $isAdult: Boolean\n $sort: [MediaSort]\n $page: Int\n $perPage: Int\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n media(\n type: $type\n genre_in: $genre_in\n startDate_year: $year\n status: $status\n format: $format\n isAdult: $isAdult\n sort: $sort\n ) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large extraLarge }\n trailer { id site thumbnail }\n siteUrl\n description(asHtml: false)\n }\n\n";
23
23
  /** Staff and voice actors for a media title */
24
24
  export declare const STAFF_QUERY = "\n query MediaStaff($id: Int, $search: String) {\n Media(id: $id, search: $search) {\n id\n title { romaji english native }\n format\n siteUrl\n staff(sort: RELEVANCE, perPage: 15) {\n edges {\n role\n node {\n id\n name { full native }\n siteUrl\n }\n }\n }\n characters(sort: ROLE, perPage: 10) {\n edges {\n role\n node {\n id\n name { full native }\n siteUrl\n }\n voiceActors(language: JAPANESE) {\n id\n name { full native }\n siteUrl\n }\n }\n }\n }\n }\n";
25
25
  /** Airing schedule for currently airing anime */
26
26
  export declare const AIRING_SCHEDULE_QUERY = "\n query AiringSchedule($id: Int, $search: String, $notYetAired: Boolean) {\n Media(id: $id, search: $search) {\n id\n title { romaji english native }\n status\n episodes\n nextAiringEpisode {\n episode\n airingAt\n timeUntilAiring\n }\n airingSchedule(notYetAired: $notYetAired, perPage: 10) {\n nodes {\n episode\n airingAt\n timeUntilAiring\n }\n }\n siteUrl\n }\n }\n";
27
+ /** Batch-fetch next airing episodes for multiple media IDs */
28
+ export declare const BATCH_AIRING_QUERY = "\n query BatchAiring($ids: [Int], $perPage: Int) {\n Page(perPage: $perPage) {\n media(id_in: $ids, status: RELEASING) {\n id\n title { romaji english native }\n format\n episodes\n nextAiringEpisode {\n episode\n airingAt\n timeUntilAiring\n }\n siteUrl\n }\n }\n }\n";
27
29
  /** Search for characters by name */
28
30
  export declare const CHARACTER_SEARCH_QUERY = "\n query CharacterSearch($search: String!, $page: Int, $perPage: Int) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n characters(search: $search, sort: FAVOURITES_DESC) {\n id\n name { full native alternative }\n image { medium }\n favourites\n siteUrl\n media(sort: POPULARITY_DESC, perPage: 5) {\n edges {\n characterRole\n node {\n id\n title { romaji english }\n format\n type\n siteUrl\n }\n voiceActors(language: JAPANESE) {\n id\n name { full }\n siteUrl\n }\n }\n }\n }\n }\n }\n";
29
31
  /** Create or update a list entry */
30
- export declare const SAVE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation SaveMediaListEntry(\n $mediaId: Int\n $status: MediaListStatus\n $scoreRaw: Int\n $progress: Int\n $notes: String\n $private: Boolean\n ) {\n SaveMediaListEntry(\n mediaId: $mediaId\n status: $status\n scoreRaw: $scoreRaw\n progress: $progress\n notes: $notes\n private: $private\n ) {\n id\n mediaId\n status\n score(format: POINT_10)\n progress\n }\n }\n";
32
+ export declare const SAVE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation SaveMediaListEntry(\n $mediaId: Int\n $status: MediaListStatus\n $scoreRaw: Int\n $progress: Int\n $progressVolumes: Int\n $notes: String\n $private: Boolean\n ) {\n SaveMediaListEntry(\n mediaId: $mediaId\n status: $status\n scoreRaw: $scoreRaw\n progress: $progress\n progressVolumes: $progressVolumes\n notes: $notes\n private: $private\n ) {\n id\n mediaId\n status\n score(format: POINT_10)\n progress\n progressVolumes\n }\n }\n";
31
33
  /** Fetch a single list entry for snapshotting before mutations */
32
- export declare const MEDIA_LIST_ENTRY_QUERY = "\n query MediaListEntry($id: Int, $mediaId: Int, $userName: String) {\n MediaList(id: $id, mediaId: $mediaId, userName: $userName) {\n id\n mediaId\n status\n score(format: POINT_10)\n progress\n notes\n private\n }\n }\n";
34
+ export declare const MEDIA_LIST_ENTRY_QUERY = "\n query MediaListEntry($id: Int, $mediaId: Int, $userName: String) {\n MediaList(id: $id, mediaId: $mediaId, userName: $userName) {\n id\n mediaId\n status\n score(format: POINT_10)\n progress\n progressVolumes\n notes\n private\n }\n }\n";
33
35
  /** Remove a list entry */
34
36
  export declare const DELETE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation DeleteMediaListEntry($id: Int!) {\n DeleteMediaListEntry(id: $id) {\n deleted\n }\n }\n";
35
37
  /** User's anime/manga list, grouped by status. Omit $status to get all lists. */
36
- export declare const USER_LIST_QUERY = "\n query UserMediaList(\n $userName: String!\n $type: MediaType\n $status: MediaListStatus\n $sort: [MediaListSort]\n ) {\n MediaListCollection(\n userName: $userName\n type: $type\n status: $status\n sort: $sort\n ) {\n lists {\n name\n status\n isCustomList\n entries {\n id\n score(format: POINT_10) # normalize to 1-10 scale regardless of user's profile setting\n progress\n status\n updatedAt\n startedAt { year month day }\n completedAt { year month day }\n notes\n media {\n ...MediaFields\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
38
+ export declare const USER_LIST_QUERY = "\n query UserMediaList(\n $userName: String!\n $type: MediaType\n $status: MediaListStatus\n $sort: [MediaListSort]\n ) {\n MediaListCollection(\n userName: $userName\n type: $type\n status: $status\n sort: $sort\n ) {\n lists {\n name\n status\n isCustomList\n entries {\n id\n score(format: POINT_10) # normalize to 1-10 scale regardless of user's profile setting\n progress\n progressVolumes\n status\n updatedAt\n startedAt { year month day }\n completedAt { year month day }\n notes\n media {\n ...MediaFields\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large extraLarge }\n trailer { id site thumbnail }\n siteUrl\n description(asHtml: false)\n }\n\n";
37
39
  /** Search for staff by name with their top works */
38
40
  export declare const STAFF_SEARCH_QUERY = "\n query StaffSearch($search: String!, $page: Int, $perPage: Int, $mediaPerPage: Int) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n staff(search: $search, sort: SEARCH_MATCH) {\n id\n name { full native }\n primaryOccupations\n siteUrl\n staffMedia(sort: POPULARITY_DESC, perPage: $mediaPerPage) {\n edges {\n staffRole\n node {\n id\n title { romaji english }\n format\n type\n meanScore\n siteUrl\n }\n }\n }\n }\n }\n }\n";
39
41
  /** Authenticated user info */
@@ -38,7 +38,8 @@ const MEDIA_FRAGMENT = `
38
38
  }
39
39
  source
40
40
  isAdult
41
- coverImage { large }
41
+ coverImage { large extraLarge }
42
+ trailer { id site thumbnail }
42
43
  siteUrl
43
44
  description(asHtml: false)
44
45
  }
@@ -336,6 +337,25 @@ export const AIRING_SCHEDULE_QUERY = `
336
337
  }
337
338
  }
338
339
  `;
340
+ /** Batch-fetch next airing episodes for multiple media IDs */
341
+ export const BATCH_AIRING_QUERY = `
342
+ query BatchAiring($ids: [Int], $perPage: Int) {
343
+ Page(perPage: $perPage) {
344
+ media(id_in: $ids, status: RELEASING) {
345
+ id
346
+ title { romaji english native }
347
+ format
348
+ episodes
349
+ nextAiringEpisode {
350
+ episode
351
+ airingAt
352
+ timeUntilAiring
353
+ }
354
+ siteUrl
355
+ }
356
+ }
357
+ }
358
+ `;
339
359
  /** Search for characters by name */
340
360
  export const CHARACTER_SEARCH_QUERY = `
341
361
  query CharacterSearch($search: String!, $page: Int, $perPage: Int) {
@@ -375,6 +395,7 @@ export const SAVE_MEDIA_LIST_ENTRY_MUTATION = `
375
395
  $status: MediaListStatus
376
396
  $scoreRaw: Int
377
397
  $progress: Int
398
+ $progressVolumes: Int
378
399
  $notes: String
379
400
  $private: Boolean
380
401
  ) {
@@ -383,6 +404,7 @@ export const SAVE_MEDIA_LIST_ENTRY_MUTATION = `
383
404
  status: $status
384
405
  scoreRaw: $scoreRaw
385
406
  progress: $progress
407
+ progressVolumes: $progressVolumes
386
408
  notes: $notes
387
409
  private: $private
388
410
  ) {
@@ -391,6 +413,7 @@ export const SAVE_MEDIA_LIST_ENTRY_MUTATION = `
391
413
  status
392
414
  score(format: POINT_10)
393
415
  progress
416
+ progressVolumes
394
417
  }
395
418
  }
396
419
  `;
@@ -403,6 +426,7 @@ export const MEDIA_LIST_ENTRY_QUERY = `
403
426
  status
404
427
  score(format: POINT_10)
405
428
  progress
429
+ progressVolumes
406
430
  notes
407
431
  private
408
432
  }
@@ -438,6 +462,7 @@ export const USER_LIST_QUERY = `
438
462
  id
439
463
  score(format: POINT_10) # normalize to 1-10 scale regardless of user's profile setting
440
464
  progress
465
+ progressVolumes
441
466
  status
442
467
  updatedAt
443
468
  startedAt { year month day }
@@ -0,0 +1,26 @@
1
+ /** Generates shareable SVG cards for taste profiles and compatibility comparisons */
2
+ import type { TasteProfile } from "./taste.js";
3
+ /** Fetch an image URL and return a base64 data URI, or null on failure */
4
+ export declare function fetchAvatarB64(url: string): Promise<string | null>;
5
+ /** Build an SVG taste profile card */
6
+ export declare function buildTasteCardSvg(username: string, profile: TasteProfile, avatarB64?: string | null): string;
7
+ export interface CompatCardData {
8
+ user1: string;
9
+ user2: string;
10
+ compatibility: number;
11
+ sharedCount: number;
12
+ sharedFavorites: Array<{
13
+ title: string;
14
+ score1: number;
15
+ score2: number;
16
+ }>;
17
+ divergences: string[];
18
+ profile1: TasteProfile;
19
+ profile2: TasteProfile;
20
+ avatar1?: string | null;
21
+ avatar2?: string | null;
22
+ }
23
+ /** Build an SVG compatibility comparison card */
24
+ export declare function buildCompatCardSvg(data: CompatCardData): string;
25
+ /** Render an SVG string to a PNG buffer */
26
+ export declare function svgToPng(svg: string): Promise<Buffer>;