ani-mcp 0.1.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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/dist/api/client.d.ts +45 -0
  4. package/dist/api/client.js +192 -0
  5. package/dist/api/queries.d.ts +30 -0
  6. package/dist/api/queries.js +401 -0
  7. package/dist/engine/compare.d.ts +18 -0
  8. package/dist/engine/compare.js +72 -0
  9. package/dist/engine/matcher.d.ts +12 -0
  10. package/dist/engine/matcher.js +146 -0
  11. package/dist/engine/mood.d.ts +17 -0
  12. package/dist/engine/mood.js +165 -0
  13. package/dist/engine/taste.d.ts +30 -0
  14. package/dist/engine/taste.js +202 -0
  15. package/dist/index.d.ts +3 -0
  16. package/dist/index.js +26 -0
  17. package/dist/intelligence/matcher.d.ts +12 -0
  18. package/dist/intelligence/matcher.js +134 -0
  19. package/dist/intelligence/mood.d.ts +17 -0
  20. package/dist/intelligence/mood.js +125 -0
  21. package/dist/intelligence/taste.d.ts +30 -0
  22. package/dist/intelligence/taste.js +172 -0
  23. package/dist/schemas.d.ts +190 -0
  24. package/dist/schemas.js +316 -0
  25. package/dist/tools/discover.d.ts +4 -0
  26. package/dist/tools/discover.js +94 -0
  27. package/dist/tools/info.d.ts +4 -0
  28. package/dist/tools/info.js +172 -0
  29. package/dist/tools/lists.d.ts +4 -0
  30. package/dist/tools/lists.js +178 -0
  31. package/dist/tools/recommend.d.ts +4 -0
  32. package/dist/tools/recommend.js +405 -0
  33. package/dist/tools/search.d.ts +4 -0
  34. package/dist/tools/search.js +243 -0
  35. package/dist/tools/smart.d.ts +4 -0
  36. package/dist/tools/smart.js +311 -0
  37. package/dist/types.d.ts +303 -0
  38. package/dist/types.js +2 -0
  39. package/dist/utils.d.ts +12 -0
  40. package/dist/utils.js +69 -0
  41. package/package.json +60 -0
@@ -0,0 +1,165 @@
1
+ /** Maps freeform mood strings to genre/tag boost and penalize rules */
2
+ // === Mood Keyword Rules ===
3
+ // Base mood rules keyed by canonical keyword
4
+ const BASE_MOOD_RULES = {
5
+ dark: {
6
+ boost: ["Psychological", "Thriller", "Horror", "Tragedy", "Drama"],
7
+ penalize: ["Comedy", "Slice of Life"],
8
+ },
9
+ chill: {
10
+ boost: ["Slice of Life", "Iyashikei", "Music", "Cgdct"],
11
+ penalize: ["Horror", "Action", "Thriller"],
12
+ },
13
+ hype: {
14
+ boost: ["Action", "Shounen", "Sports", "Mecha", "Super Power"],
15
+ penalize: ["Slice of Life", "Drama"],
16
+ },
17
+ action: {
18
+ boost: ["Action", "Shounen", "Martial Arts", "Super Power", "Mecha"],
19
+ penalize: ["Slice of Life", "Iyashikei"],
20
+ },
21
+ romantic: {
22
+ boost: ["Romance", "Drama", "Love Triangle", "Couples"],
23
+ penalize: ["Horror", "Gore"],
24
+ },
25
+ funny: {
26
+ boost: ["Comedy", "Parody", "Gag Humor", "Slapstick"],
27
+ penalize: ["Tragedy", "Horror"],
28
+ },
29
+ brainy: {
30
+ boost: ["Psychological", "Sci-Fi", "Mystery", "Philosophy", "Mind Games"],
31
+ penalize: ["Ecchi", "Gag Humor"],
32
+ },
33
+ sad: {
34
+ boost: ["Drama", "Tragedy", "Romance", "Coming of Age", "Emotional"],
35
+ penalize: ["Comedy", "Parody"],
36
+ },
37
+ scary: {
38
+ boost: ["Horror", "Thriller", "Psychological", "Survival"],
39
+ penalize: ["Comedy", "Slice of Life", "Romance"],
40
+ },
41
+ epic: {
42
+ boost: ["Fantasy", "Adventure", "Action", "Shounen", "War"],
43
+ penalize: ["Slice of Life", "Cgdct"],
44
+ },
45
+ wholesome: {
46
+ boost: ["Slice of Life", "Comedy", "Iyashikei", "Family Life", "Cgdct"],
47
+ penalize: ["Horror", "Gore", "Tragedy"],
48
+ },
49
+ intense: {
50
+ boost: ["Thriller", "Action", "Psychological", "Survival", "Battle Royale"],
51
+ penalize: ["Slice of Life", "Iyashikei"],
52
+ },
53
+ mystery: {
54
+ boost: ["Mystery", "Thriller", "Psychological", "Detective"],
55
+ penalize: ["Slice of Life", "Sports"],
56
+ },
57
+ fantasy: {
58
+ boost: ["Fantasy", "Adventure", "Magic", "Isekai"],
59
+ penalize: [],
60
+ },
61
+ scifi: {
62
+ boost: ["Sci-Fi", "Mecha", "Space", "Cyberpunk", "Time Travel"],
63
+ penalize: [],
64
+ },
65
+ trippy: {
66
+ boost: ["Psychological", "Avant Garde", "Surreal", "Experimental"],
67
+ penalize: ["Shounen", "Sports"],
68
+ },
69
+ };
70
+ // Synonyms that resolve to a base keyword's rules
71
+ const MOOD_SYNONYMS = {
72
+ // dark
73
+ grim: "dark",
74
+ moody: "dark",
75
+ bleak: "dark",
76
+ // chill
77
+ relaxing: "chill",
78
+ peaceful: "chill",
79
+ cozy: "chill",
80
+ mellow: "chill",
81
+ // hype
82
+ exciting: "hype",
83
+ thrilling: "hype",
84
+ // romantic
85
+ romance: "romantic",
86
+ love: "romantic",
87
+ sweet: "romantic",
88
+ // funny
89
+ comedy: "funny",
90
+ silly: "funny",
91
+ witty: "funny",
92
+ hilarious: "funny",
93
+ // brainy
94
+ smart: "brainy",
95
+ cerebral: "brainy",
96
+ intellectual: "brainy",
97
+ complex: "brainy",
98
+ // sad
99
+ emotional: "sad",
100
+ depressing: "sad",
101
+ melancholic: "sad",
102
+ bittersweet: "sad",
103
+ // scary
104
+ creepy: "scary",
105
+ spooky: "scary",
106
+ eerie: "scary",
107
+ // epic
108
+ grand: "epic",
109
+ ambitious: "epic",
110
+ // wholesome
111
+ comforting: "wholesome",
112
+ heartwarming: "wholesome",
113
+ uplifting: "wholesome",
114
+ // intense
115
+ tense: "intense",
116
+ gripping: "intense",
117
+ // trippy
118
+ surreal: "trippy",
119
+ experimental: "trippy",
120
+ };
121
+ // Merge base rules and synonyms into a single lookup
122
+ const MOOD_RULES = { ...BASE_MOOD_RULES };
123
+ for (const [synonym, base] of Object.entries(MOOD_SYNONYMS)) {
124
+ MOOD_RULES[synonym] = BASE_MOOD_RULES[base];
125
+ }
126
+ // === Mood Parser ===
127
+ /** Parse a freeform mood string into genre/tag boost and penalize sets */
128
+ export function parseMood(mood) {
129
+ // Strip punctuation and split into lowercase tokens for keyword matching
130
+ const words = mood
131
+ .toLowerCase()
132
+ .replace(/[^a-z\s]/g, "")
133
+ .split(/\s+/);
134
+ const boostGenres = new Set();
135
+ const boostTags = new Set();
136
+ const penalizeGenres = new Set();
137
+ const penalizeTags = new Set();
138
+ for (const word of words) {
139
+ const rule = MOOD_RULES[word];
140
+ if (!rule)
141
+ continue;
142
+ // Add to both genre and tag sets since we can't distinguish at parse time
143
+ for (const name of rule.boost) {
144
+ boostGenres.add(name);
145
+ boostTags.add(name);
146
+ }
147
+ for (const name of rule.penalize) {
148
+ penalizeGenres.add(name);
149
+ penalizeTags.add(name);
150
+ }
151
+ }
152
+ return { boostGenres, boostTags, penalizeGenres, penalizeTags };
153
+ }
154
+ /** Check whether a mood string matches any known keywords */
155
+ export function hasMoodMatch(mood) {
156
+ const words = mood
157
+ .toLowerCase()
158
+ .replace(/[^a-z\s]/g, "")
159
+ .split(/\s+/);
160
+ return words.some((w) => w in MOOD_RULES);
161
+ }
162
+ /** List all recognized mood keywords */
163
+ export function getMoodKeywords() {
164
+ return Object.keys(MOOD_RULES);
165
+ }
@@ -0,0 +1,30 @@
1
+ /** Builds a weighted taste profile from a user's scored anime/manga list */
2
+ import type { AniListMediaListEntry } from "../types.js";
3
+ export interface WeightedItem {
4
+ name: string;
5
+ weight: number;
6
+ count: number;
7
+ }
8
+ export interface ScoringPattern {
9
+ meanScore: number;
10
+ median: number;
11
+ totalScored: number;
12
+ distribution: Record<number, number>;
13
+ tendency: "generous" | "harsh" | "average";
14
+ }
15
+ export interface FormatBreakdown {
16
+ format: string;
17
+ count: number;
18
+ percent: number;
19
+ }
20
+ export interface TasteProfile {
21
+ genres: WeightedItem[];
22
+ tags: WeightedItem[];
23
+ scoring: ScoringPattern;
24
+ formats: FormatBreakdown[];
25
+ totalCompleted: number;
26
+ }
27
+ /** Build a taste profile from scored list entries */
28
+ export declare function buildTasteProfile(entries: AniListMediaListEntry[]): TasteProfile;
29
+ /** Summarize a taste profile as natural language */
30
+ export declare function describeTasteProfile(profile: TasteProfile, username: string): string;
@@ -0,0 +1,202 @@
1
+ /** Builds a weighted taste profile from a user's scored anime/manga list */
2
+ // === Constants ===
3
+ // AniList community mean hovers around 7.0-7.2
4
+ const SITE_MEAN = 7.0;
5
+ // Minimum entries to produce a meaningful profile
6
+ const MIN_ENTRIES = 5;
7
+ // Entries scored 0 are unscored on AniList (not a real 0/10)
8
+ const UNSCORED = 0;
9
+ // Cap the number of tags returned to keep output focused
10
+ const MAX_TAGS = 20;
11
+ // Recency decay: entries from HALF_LIFE years ago get ~50% weight
12
+ const DECAY_HALF_LIFE_YEARS = 3;
13
+ const DECAY_LAMBDA = Math.LN2 / DECAY_HALF_LIFE_YEARS;
14
+ // Bayesian smoothing: pull sparse observations toward a neutral prior
15
+ const BAYESIAN_PRIOR_WEIGHT = 0.5;
16
+ const BAYESIAN_PRIOR_COUNT = 3;
17
+ // === Profile Builder ===
18
+ /** Build a taste profile from scored list entries */
19
+ export function buildTasteProfile(entries) {
20
+ // Filter out unscored entries (score 0 means the user didn't rate it)
21
+ const scored = entries.filter((e) => e.score !== UNSCORED);
22
+ if (scored.length < MIN_ENTRIES) {
23
+ return emptyProfile(entries.length);
24
+ }
25
+ const genres = computeGenreWeights(scored);
26
+ const tags = computeTagWeights(scored);
27
+ const scoring = computeScoringPattern(scored);
28
+ // Format breakdown uses all entries, not just scored ones
29
+ const formats = computeFormatBreakdown(entries);
30
+ return {
31
+ genres,
32
+ tags,
33
+ scoring,
34
+ formats,
35
+ totalCompleted: entries.length,
36
+ };
37
+ }
38
+ /** Summarize a taste profile as natural language */
39
+ export function describeTasteProfile(profile, username) {
40
+ if (profile.genres.length === 0) {
41
+ return (`${username} has completed ${profile.totalCompleted} titles, ` +
42
+ `but not enough have scores to build a taste profile. ` +
43
+ `Score more titles on AniList for a detailed breakdown.`);
44
+ }
45
+ const lines = [];
46
+ // Top genres
47
+ const topGenres = profile.genres
48
+ .slice(0, 5)
49
+ .map((g) => g.name)
50
+ .join(", ");
51
+ lines.push(`Top genres: ${topGenres}.`);
52
+ // Top tags (themes)
53
+ if (profile.tags.length > 0) {
54
+ const topTags = profile.tags
55
+ .slice(0, 5)
56
+ .map((t) => t.name)
57
+ .join(", ");
58
+ lines.push(`Strongest themes: ${topTags}.`);
59
+ }
60
+ // Scoring tendency
61
+ const { scoring } = profile;
62
+ const tendencyDesc = scoring.tendency === "generous"
63
+ ? `Scores generously (avg ${scoring.meanScore.toFixed(1)} vs site avg ${SITE_MEAN})`
64
+ : scoring.tendency === "harsh"
65
+ ? `Scores harshly (avg ${scoring.meanScore.toFixed(1)} vs site avg ${SITE_MEAN})`
66
+ : `Scores close to average (avg ${scoring.meanScore.toFixed(1)})`;
67
+ lines.push(`${tendencyDesc} across ${scoring.totalScored} rated titles.`);
68
+ // Format preferences
69
+ if (profile.formats.length > 0) {
70
+ const fmtParts = profile.formats
71
+ .slice(0, 3)
72
+ .map((f) => `${f.format} ${f.percent}%`);
73
+ lines.push(`Format split: ${fmtParts.join(", ")}.`);
74
+ }
75
+ lines.push(`Total completed: ${profile.totalCompleted}.`);
76
+ return lines.join("\n");
77
+ }
78
+ // === Weighting Algorithms ===
79
+ /** Weight genres by how much the user liked shows in that genre */
80
+ function computeGenreWeights(entries) {
81
+ const genreMap = new Map();
82
+ for (const entry of entries) {
83
+ // Higher-scored and more recent shows contribute more
84
+ const scoreWeight = (entry.score / 10) * computeDecay(entry);
85
+ for (const genre of entry.media.genres) {
86
+ const existing = genreMap.get(genre) ?? { weight: 0, count: 0 };
87
+ existing.weight += scoreWeight;
88
+ existing.count += 1;
89
+ genreMap.set(genre, existing);
90
+ }
91
+ }
92
+ return mapToSortedItems(genreMap);
93
+ }
94
+ /** Weight tags by user score multiplied by tag relevance */
95
+ function computeTagWeights(entries) {
96
+ const tagMap = new Map();
97
+ for (const entry of entries) {
98
+ const scoreWeight = (entry.score / 10) * computeDecay(entry);
99
+ for (const tag of entry.media.tags) {
100
+ if (tag.isMediaSpoiler)
101
+ continue;
102
+ // Tag rank (0-100) indicates how relevant the tag is to this media
103
+ const relevance = tag.rank / 100;
104
+ const existing = tagMap.get(tag.name) ?? { weight: 0, count: 0 };
105
+ existing.weight += scoreWeight * relevance;
106
+ existing.count += 1;
107
+ tagMap.set(tag.name, existing);
108
+ }
109
+ }
110
+ return mapToSortedItems(tagMap).slice(0, MAX_TAGS);
111
+ }
112
+ /** Score distribution and tendency classification */
113
+ function computeScoringPattern(entries) {
114
+ const scores = entries.map((e) => e.score);
115
+ const sorted = [...scores].sort((a, b) => a - b);
116
+ const mean = scores.reduce((sum, s) => sum + s, 0) / scores.length;
117
+ // Middle value of sorted scores (average of two middle values if even count)
118
+ const median = sorted.length % 2 === 0
119
+ ? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2
120
+ : sorted[Math.floor(sorted.length / 2)];
121
+ // Build histogram: count of each score value (1-10)
122
+ const distribution = {};
123
+ for (const s of scores) {
124
+ distribution[s] = (distribution[s] ?? 0) + 1;
125
+ }
126
+ // Classify based on distance from site average (7.0)
127
+ const tendency = mean >= SITE_MEAN + 0.5
128
+ ? "generous"
129
+ : mean <= SITE_MEAN - 0.5
130
+ ? "harsh"
131
+ : "average";
132
+ return {
133
+ meanScore: mean,
134
+ median,
135
+ totalScored: scores.length,
136
+ distribution,
137
+ tendency,
138
+ };
139
+ }
140
+ /** Format preferences as percentages */
141
+ function computeFormatBreakdown(entries) {
142
+ const counts = new Map();
143
+ // Count entries per format (TV, MOVIE, OVA, etc.)
144
+ for (const entry of entries) {
145
+ const format = entry.media.format ?? "UNKNOWN";
146
+ counts.set(format, (counts.get(format) ?? 0) + 1);
147
+ }
148
+ return [...counts.entries()]
149
+ .map(([format, count]) => ({
150
+ format,
151
+ count,
152
+ percent: Math.round((count / entries.length) * 100),
153
+ }))
154
+ .sort((a, b) => b.count - a.count);
155
+ }
156
+ // === Helpers ===
157
+ /** Convert a name->weight Map into a Bayesian-smoothed sorted array */
158
+ function mapToSortedItems(map) {
159
+ return [...map.entries()]
160
+ .map(([name, { weight, count }]) => ({
161
+ name,
162
+ // Pull sparse observations toward a neutral prior
163
+ weight: (weight + BAYESIAN_PRIOR_WEIGHT * BAYESIAN_PRIOR_COUNT) /
164
+ (count + BAYESIAN_PRIOR_COUNT),
165
+ count,
166
+ }))
167
+ .sort((a, b) => b.weight - a.weight);
168
+ }
169
+ /** Recency multiplier (0-1) - recent entries weigh more than old ones */
170
+ function computeDecay(entry) {
171
+ const now = Date.now() / 1000;
172
+ const completedEpoch = dateToEpoch(entry.completedAt);
173
+ const epoch = completedEpoch ?? entry.updatedAt;
174
+ if (!epoch)
175
+ return 1;
176
+ const yearsSince = (now - epoch) / (365.25 * 24 * 3600);
177
+ return Math.exp(-DECAY_LAMBDA * Math.max(0, yearsSince));
178
+ }
179
+ /** Convert an AniListDate to Unix epoch, or null if incomplete */
180
+ function dateToEpoch(date) {
181
+ if (date.year == null)
182
+ return null;
183
+ const month = date.month ?? 1;
184
+ const day = date.day ?? 1;
185
+ return new Date(date.year, month - 1, day).getTime() / 1000;
186
+ }
187
+ /** Empty profile for users with too few scored entries */
188
+ function emptyProfile(totalCompleted) {
189
+ return {
190
+ genres: [],
191
+ tags: [],
192
+ scoring: {
193
+ meanScore: 0,
194
+ median: 0,
195
+ totalScored: 0,
196
+ distribution: {},
197
+ tendency: "average",
198
+ },
199
+ formats: [],
200
+ totalCompleted,
201
+ };
202
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ /** ani-mcp - AniList MCP Server */
3
+ import "dotenv/config";
package/dist/index.js ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ /** ani-mcp - AniList MCP Server */
3
+ import "dotenv/config";
4
+ import { FastMCP } from "fastmcp";
5
+ import { registerSearchTools } from "./tools/search.js";
6
+ import { registerListTools } from "./tools/lists.js";
7
+ import { registerRecommendTools } from "./tools/recommend.js";
8
+ import { registerDiscoverTools } from "./tools/discover.js";
9
+ import { registerInfoTools } from "./tools/info.js";
10
+ // Both vars are optional - warn on missing so operators know what's available
11
+ if (!process.env.ANILIST_USERNAME) {
12
+ console.warn("ANILIST_USERNAME not set - tools will require a username argument.");
13
+ }
14
+ if (!process.env.ANILIST_TOKEN) {
15
+ console.warn("ANILIST_TOKEN not set - authenticated features unavailable.");
16
+ }
17
+ const server = new FastMCP({
18
+ name: "ani-mcp",
19
+ version: "0.1.0",
20
+ });
21
+ registerSearchTools(server);
22
+ registerListTools(server);
23
+ registerRecommendTools(server);
24
+ registerDiscoverTools(server);
25
+ registerInfoTools(server);
26
+ server.start({ transportType: "stdio" });
@@ -0,0 +1,12 @@
1
+ /** Scores candidate media against a taste profile with natural-language explanations */
2
+ import type { AniListMedia } from "../types.js";
3
+ import type { TasteProfile } from "./taste.js";
4
+ import type { MoodModifiers } from "./mood.js";
5
+ export interface MatchResult {
6
+ media: AniListMedia;
7
+ score: number;
8
+ reasons: string[];
9
+ moodFit: string | null;
10
+ }
11
+ /** Score and rank candidates against a user's taste profile */
12
+ export declare function matchCandidates(candidates: AniListMedia[], profile: TasteProfile, mood?: MoodModifiers): MatchResult[];
@@ -0,0 +1,134 @@
1
+ /** Scores candidate media against a taste profile with natural-language explanations */
2
+ // === Constants ===
3
+ // How much each signal contributes to the final score
4
+ const GENRE_WEIGHT = 0.5;
5
+ const TAG_WEIGHT = 0.3;
6
+ const COMMUNITY_WEIGHT = 0.2;
7
+ // Mood boost/penalty as a multiplier on the final score
8
+ const MOOD_BOOST = 1.3;
9
+ const MOOD_PENALTY = 0.6;
10
+ // Minimum community score (out of 100) to avoid recommending poorly-rated titles
11
+ const MIN_COMMUNITY_SCORE = 50;
12
+ // === Matcher ===
13
+ /** Score and rank candidates against a user's taste profile */
14
+ export function matchCandidates(candidates, profile, mood) {
15
+ // Build lookup maps for O(1) access during scoring
16
+ const genreWeights = toWeightMap(profile.genres);
17
+ const tagWeights = toWeightMap(profile.tags);
18
+ // Find max weights for normalization
19
+ const maxGenreWeight = profile.genres[0]?.weight ?? 1;
20
+ const maxTagWeight = profile.tags[0]?.weight ?? 1;
21
+ const results = [];
22
+ for (const media of candidates) {
23
+ // Skip titles with very low community scores
24
+ if (media.meanScore !== null && media.meanScore < MIN_COMMUNITY_SCORE) {
25
+ continue;
26
+ }
27
+ const reasons = [];
28
+ // Genre affinity: sum of user's genre weights for this title's genres
29
+ const genreScore = computeGenreAffinity(media, genreWeights, maxGenreWeight, reasons);
30
+ // Tag affinity: sum of user's tag weights for this title's tags
31
+ const tagScore = computeTagAffinity(media, tagWeights, maxTagWeight, reasons);
32
+ // Fall back to 70 for unrated titles so they aren't penalized or boosted
33
+ const communityScore = (media.meanScore ?? 70) / 100;
34
+ // Weighted combination
35
+ let finalScore = genreScore * GENRE_WEIGHT +
36
+ tagScore * TAG_WEIGHT +
37
+ communityScore * COMMUNITY_WEIGHT;
38
+ // Apply mood modifiers
39
+ const moodFit = mood ? applyMood(media, mood, reasons) : null;
40
+ if (moodFit === "boost")
41
+ finalScore *= MOOD_BOOST;
42
+ if (moodFit === "penalty")
43
+ finalScore *= MOOD_PENALTY;
44
+ results.push({
45
+ media,
46
+ score: finalScore,
47
+ reasons,
48
+ moodFit: moodFit
49
+ ? moodFit === "boost"
50
+ ? "Strong mood match"
51
+ : "Weak mood fit"
52
+ : null,
53
+ });
54
+ }
55
+ return results.sort((a, b) => b.score - a.score);
56
+ }
57
+ // === Scoring Components ===
58
+ /** How well a title's genres align with the user's genre preferences */
59
+ function computeGenreAffinity(media, genreWeights, maxWeight, reasons) {
60
+ if (media.genres.length === 0)
61
+ return 0;
62
+ let total = 0;
63
+ const matchedGenres = [];
64
+ // Accumulate user's preference weight for each matching genre
65
+ for (const genre of media.genres) {
66
+ const weight = genreWeights.get(genre);
67
+ if (weight !== undefined) {
68
+ total += weight;
69
+ matchedGenres.push(genre);
70
+ }
71
+ }
72
+ // Cap divisor at 3 so titles with many genres aren't unfairly diluted
73
+ const normalized = total / (maxWeight * Math.min(media.genres.length, 3));
74
+ if (matchedGenres.length > 0) {
75
+ reasons.push(`Matches your taste in ${matchedGenres.join(", ")}`);
76
+ }
77
+ return Math.min(1, normalized);
78
+ }
79
+ /** How well a title's tags align with the user's tag preferences */
80
+ function computeTagAffinity(media, tagWeights, maxWeight, reasons) {
81
+ const nonSpoilerTags = media.tags.filter((t) => !t.isMediaSpoiler);
82
+ if (nonSpoilerTags.length === 0)
83
+ return 0;
84
+ let total = 0;
85
+ const matchedTags = [];
86
+ for (const tag of nonSpoilerTags) {
87
+ const weight = tagWeights.get(tag.name);
88
+ if (weight !== undefined) {
89
+ // Scale by tag relevance
90
+ total += weight * (tag.rank / 100);
91
+ matchedTags.push(tag.name);
92
+ }
93
+ }
94
+ // Cap divisor so titles with many tags aren't unfairly diluted
95
+ const normalized = total / (maxWeight * Math.min(nonSpoilerTags.length, 5));
96
+ if (matchedTags.length >= 2) {
97
+ reasons.push(`Themes you enjoy: ${matchedTags.slice(0, 3).join(", ")}`);
98
+ }
99
+ return Math.min(1, normalized);
100
+ }
101
+ /** Apply mood boost/penalty based on genre and tag overlap */
102
+ function applyMood(media, mood, reasons) {
103
+ let boostCount = 0;
104
+ let penaltyCount = 0;
105
+ // Count mood matches across genres and tags
106
+ for (const genre of media.genres) {
107
+ if (mood.boostGenres.has(genre))
108
+ boostCount++;
109
+ if (mood.penalizeGenres.has(genre))
110
+ penaltyCount++;
111
+ }
112
+ for (const tag of media.tags) {
113
+ if (tag.isMediaSpoiler)
114
+ continue;
115
+ if (mood.boostTags.has(tag.name))
116
+ boostCount++;
117
+ if (mood.penalizeTags.has(tag.name))
118
+ penaltyCount++;
119
+ }
120
+ // Boost wins if it has more matches, penalty if it dominates
121
+ if (boostCount >= 2 && boostCount > penaltyCount) {
122
+ reasons.push("Fits the mood you described");
123
+ return "boost";
124
+ }
125
+ if (penaltyCount >= 2 && penaltyCount > boostCount) {
126
+ return "penalty";
127
+ }
128
+ return null;
129
+ }
130
+ // === Helpers ===
131
+ /** Convert WeightedItem[] to a Map for fast lookup */
132
+ function toWeightMap(items) {
133
+ return new Map(items.map((i) => [i.name, i.weight]));
134
+ }
@@ -0,0 +1,17 @@
1
+ /** Maps freeform mood strings to genre/tag boost and penalize rules */
2
+ export interface MoodRule {
3
+ boost: string[];
4
+ penalize: string[];
5
+ }
6
+ export interface MoodModifiers {
7
+ boostGenres: Set<string>;
8
+ boostTags: Set<string>;
9
+ penalizeGenres: Set<string>;
10
+ penalizeTags: Set<string>;
11
+ }
12
+ /** Parse a freeform mood string into genre/tag boost and penalize sets */
13
+ export declare function parseMood(mood: string): MoodModifiers;
14
+ /** Check whether a mood string matches any known keywords */
15
+ export declare function hasMoodMatch(mood: string): boolean;
16
+ /** List all recognized mood keywords */
17
+ export declare function getMoodKeywords(): string[];