From fe70e7eed65b6ee513f9220220b9bf196b00d02b Mon Sep 17 00:00:00 2001 From: berenger <85639913+princeberenger@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:13:47 +0200 Subject: [PATCH 1/4] Update trakt-manager extension - Keep the merge-date placeholder on the lists changelog entry. - Merge branch \'contributions/merge-1790115164064\' into pr/4-lists - Pull contributions - Add AI tools for Trakt personal lists --- extensions/trakt-manager/CHANGELOG.md | 7 + extensions/trakt-manager/README.md | 3 +- extensions/trakt-manager/ai.yaml | 103 +++++++ extensions/trakt-manager/package.json | 30 ++ extensions/trakt-manager/src/lib/client.ts | 5 +- extensions/trakt-manager/src/lib/contract.ts | 96 ++++++ extensions/trakt-manager/src/lib/schema.ts | 126 ++++++++ .../trakt-manager/src/tools/add-to-list.ts | 105 +++++++ .../trakt-manager/src/tools/compact-media.ts | 90 ++++++ .../trakt-manager/src/tools/create-list.ts | 179 +++++++++++ .../trakt-manager/src/tools/delete-list.ts | 59 ++++ .../trakt-manager/src/tools/get-lists.ts | 291 ++++++++++++++++++ .../trakt-manager/src/tools/list-api.ts | 254 +++++++++++++++ .../trakt-manager/src/tools/list-matching.ts | 197 ++++++++++++ .../trakt-manager/src/tools/list-write.ts | 111 +++++++ .../trakt-manager/src/tools/lists.test.ts | 128 ++++++++ .../src/tools/remove-from-list.ts | 101 ++++++ .../trakt-manager/src/tools/update-list.ts | 167 ++++++++++ 18 files changed, 2050 insertions(+), 2 deletions(-) create mode 100644 extensions/trakt-manager/src/tools/add-to-list.ts create mode 100644 extensions/trakt-manager/src/tools/create-list.ts create mode 100644 extensions/trakt-manager/src/tools/delete-list.ts create mode 100644 extensions/trakt-manager/src/tools/get-lists.ts create mode 100644 extensions/trakt-manager/src/tools/list-api.ts create mode 100644 extensions/trakt-manager/src/tools/list-matching.ts create mode 100644 extensions/trakt-manager/src/tools/list-write.ts create mode 100644 extensions/trakt-manager/src/tools/lists.test.ts create mode 100644 extensions/trakt-manager/src/tools/remove-from-list.ts create mode 100644 extensions/trakt-manager/src/tools/update-list.ts diff --git a/extensions/trakt-manager/CHANGELOG.md b/extensions/trakt-manager/CHANGELOG.md index f0648268e71..3c25725be84 100644 --- a/extensions/trakt-manager/CHANGELOG.md +++ b/extensions/trakt-manager/CHANGELOG.md @@ -1,5 +1,12 @@ # Trakt Manager Changelog +## [Update] - {PR_MERGE_DATE} + +### Added + +- **AI tools for personal lists** β€” Raycast AI can now read your Trakt lists, check whether a title is on one, create, rename or delete a list, and add or remove movies, shows, seasons and episodes. Building a themed list takes a single batched call, and every change asks for confirmation naming the list and the items as Trakt holds them. +- List names keep their emoji and non-Latin characters when matched, so "🎬 Oscars 2026" and "Oscars 2026" stay distinct lists. + ## [Update] - 2026-09-19 ### Added diff --git a/extensions/trakt-manager/README.md b/extensions/trakt-manager/README.md index 82f4bd00118..0fd96d2cd7c 100644 --- a/extensions/trakt-manager/README.md +++ b/extensions/trakt-manager/README.md @@ -45,8 +45,9 @@ Raycast AI can act on your Trakt account using the same sign-in as the commands - _"What episode am I on in Breaking Bad?"_ - _"Rate The Bear 9 out of 10"_ - _"What should I watch next?"_ +- _"Create a list called Emmy 2025 winners and add these shows to it"_ -Anything that changes your account β€” marking as watched, rating, or editing the watchlist β€” asks for confirmation first, and shows exactly which title it resolved so it cannot act on the wrong one. +Anything that changes your account β€” marking as watched, rating, editing the watchlist or your lists β€” asks for confirmation first, and shows exactly which title it resolved so it cannot act on the wrong one. When several titles share a name, mention the year (_"Butterfly 2025"_) to remove the ambiguity. diff --git a/extensions/trakt-manager/ai.yaml b/extensions/trakt-manager/ai.yaml index ed3b66fbed4..32c428259d9 100644 --- a/extensions/trakt-manager/ai.yaml +++ b/extensions/trakt-manager/ai.yaml @@ -31,6 +31,17 @@ instructions: | - Format season and episode numbers clearly as S{season}E{episode} (e.g. S01E03) alongside episode titles. - When presenting ratings, format them out of 10 (e.g. 8.2/10). + Personal Lists: + - To build a themed list (award winners, a marathon, a top 10): `create-list`, then ONE `add-to-list` call carrying every item. Resolve each title with `search-movies` / `search-shows` first; the confirmation names every item from Trakt, so titles are never needed alongside the IDs. + - CRITICAL: `add-to-list` and `remove-from-list` take COMMA-SEPARATED values in a SINGLE call: `movieTraktIds: "329862,16662"`, `showTraktIds: "154784"`, `seasons: "154784:1"` (showTraktId:seasonNumber), `episodes: "154784:1:3"` (showTraktId:season:episode). Never call them once per title, and never pass a bare season or episode ID. At most 50 items per call. + - Use `get-lists` to find a list's `listId`. `found` is true only for an exact list name; lists whose name merely contains the query stay in `lists` and must be confirmed with the user. + - To check whether a title is on a list (e.g. "Is Parasite on my Oscars list?"), call `get-lists` with the list (`listId` or `query`) and `itemQuery` (plus `itemYear` when known). Read `inList` and trust a negative ONLY when `exhaustive: true`. Never answer from a page of `items`. With `itemTraktId`, always pass `itemType`. + - `create-list` reuses a list only when the name matches after folding case and accents. A nearby name that differs by emoji or punctuation comes back `ambiguous: true` without writing: ask the user which list they mean. Lists are `private` unless the user asks otherwise; `link` means anyone with the share link. + - Emojis are allowed in list names (e.g. "🎬 Oscars 2026"). For ranked lists, pass `displayNumbers: true`. + - Renaming with `update-list` keeps the list's `listId` (Trakt keeps the slug). Use `delete-list` only when the user explicitly asks to delete the whole list; to take titles out, use `remove-from-list`. + - Read `added`, `alreadyPresent`, `notFound` and `removed` / `notOnList` and the tool `message`: HTTP success is not proof an item was stored or removed. + - Free Trakt accounts cap lists and list items; a quota error means the account limit was reached, not that the feature is missing. + Write Actions & Safety: - Before performing any write action (watchlist, history, ratings), resolve the correct `traktId` first. `title` / `showTitle` on write tools is optional and ignored: the confirmation names the item Trakt holds for that ID. - To add an item to the watchlist, use `add-to-watchlist`. Read `alreadyPresent` and the tool `message`: HTTP success is not a write when the title was already there, or when Trakt stored nothing. @@ -41,6 +52,7 @@ instructions: | - To remove a movie, TV show, or episode from watch history, use `remove-from-history`. - To rate a movie, TV show, or episode, use `rate-media` with a whole number between 1 and 10. Decimal scores are rejected, so round before calling and tell the user which score you used. Seasons cannot be rated through these tools. - To remove a rating from a movie, TV show, or episode, use `remove-rating`. Season ratings can appear in `get-ratings` but cannot be added or removed through these tools. + - To create, rename, delete or fill a personal list, use `create-list`, `update-list`, `delete-list`, `add-to-list` and `remove-from-list` (see Personal Lists). - All write and destructive tools automatically prompt the user with a confirmation dialog before executing. The dialog names the item by looking its Trakt ID up on Trakt, so a wrong ID surfaces there rather than silently modifying the wrong item. evals: @@ -332,3 +344,94 @@ evals: name: "search-shows" arguments: title: "Severance" + + - input: "@trakt-manager what personal lists do I have on Trakt?" + mocks: + get-lists: + found: true + exhaustive: true + message: "Found 2 list(s)." + lists: + - traktId: 30481001 + slug: "oscars-2026" + listId: "oscars-2026" + name: "🎬 Oscars 2026" + privacy: "private" + itemCount: 7 + - traktId: 30481002 + slug: "rewatch-pile" + listId: "rewatch-pile" + name: "Rewatch pile" + privacy: "private" + itemCount: 12 + expected: + - callsTool: + name: "get-lists" + + # Membership must be checked by the tool over every item, not read off a page of items. + - input: "@trakt-manager is Parasite on my Oscars 2026 list?" + mocks: + get-lists: + found: true + inList: true + exhaustive: true + message: '"Parasite" is on "🎬 Oscars 2026".' + lists: + - traktId: 30481001 + slug: "oscars-2026" + listId: "oscars-2026" + name: "🎬 Oscars 2026" + privacy: "private" + itemCount: 7 + matchedItems: + - listEntryId: 901 + type: "movie" + title: "Parasite" + year: 2019 + traktId: 329862 + totalItems: 7 + expected: + - callsTool: + name: "get-lists" + arguments: + itemQuery: "Parasite" + + - input: "@trakt-manager create a list called 🎬 Oscars 2026 and add Parasite to it" + mocks: + search-movies: + data: + - traktId: 329862 + title: "Parasite" + year: 2019 + matchesForTitle: 1 + truncated: false + hasMore: false + create-list: + success: true + alreadyExisted: false + message: 'Created the private list "🎬 Oscars 2026".' + listId: "oscars-2026" + list: + traktId: 30481001 + slug: "oscars-2026" + listId: "oscars-2026" + name: "🎬 Oscars 2026" + privacy: "private" + itemCount: 0 + add-to-list: + success: true + message: 'Added 1 item(s) to "🎬 Oscars 2026".' + added: 1 + alreadyPresent: 0 + notFound: 0 + listItemCount: 1 + expected: + - callsTool: + name: "create-list" + arguments: + name: "🎬 Oscars 2026" + - callsTool: + name: "add-to-list" + arguments: + listId: "oscars-2026" + movieTraktIds: "329862" diff --git a/extensions/trakt-manager/package.json b/extensions/trakt-manager/package.json index a1011129ce4..17628a6d9d6 100644 --- a/extensions/trakt-manager/package.json +++ b/extensions/trakt-manager/package.json @@ -161,6 +161,36 @@ "name": "get-user-stats", "title": "Get User Stats", "description": "Get your lifetime Trakt viewing statistics including watch times, movies, shows, and ratings counts" + }, + { + "name": "get-lists", + "title": "Get Personal Lists", + "description": "Get your Trakt personal lists, read a list's items, or check whether a title is on a list" + }, + { + "name": "create-list", + "title": "Create Personal List", + "description": "Create a new personal list on your Trakt account, with optional privacy, numbering and sorting" + }, + { + "name": "update-list", + "title": "Update Personal List", + "description": "Rename a Trakt personal list or change its description, privacy, numbering or sorting" + }, + { + "name": "delete-list", + "title": "Delete Personal List", + "description": "Permanently delete a Trakt personal list and every item on it" + }, + { + "name": "add-to-list", + "title": "Add to Personal List", + "description": "Add movies, TV shows, seasons and episodes to a Trakt personal list in a single batch" + }, + { + "name": "remove-from-list", + "title": "Remove from Personal List", + "description": "Remove movies, TV shows, seasons and episodes from a Trakt personal list" } ], "contributors": [ diff --git a/extensions/trakt-manager/src/lib/client.ts b/extensions/trakt-manager/src/lib/client.ts index 5d6766fbe0e..a11e5e67247 100644 --- a/extensions/trakt-manager/src/lib/client.ts +++ b/extensions/trakt-manager/src/lib/client.ts @@ -27,7 +27,10 @@ export const initTraktClient = () => { body, ...fetchOptions, }); - const json = await response.json(); + // DELETE answers 204 with an empty body; parsing it as JSON would turn a success into a 500. + // Any other unparseable body still throws and lands in the catch below, as before. + const text = await response.text(); + const json: unknown = text ? JSON.parse(text) : undefined; // Uncomment this line to log API requests // console.log( diff --git a/extensions/trakt-manager/src/lib/contract.ts b/extensions/trakt-manager/src/lib/contract.ts index 69c0d5ca7d9..aa11c1b19fb 100644 --- a/extensions/trakt-manager/src/lib/contract.ts +++ b/extensions/trakt-manager/src/lib/contract.ts @@ -26,8 +26,18 @@ import { TraktUserStatsSchema, TraktIdLookupQuerySchema, TraktIdLookupSchema, + TraktListEntriesSchema, + TraktListItemsBodySchema, + TraktListSchema, + TraktListsSchema, + TraktListWriteBodySchema, } from "./schema"; +const TraktListPathSchema = z.object({ + id: z.string().default("me"), + listId: z.string(), +}); + const c = initContract(); const TraktMovieContract = c.router({ @@ -445,6 +455,92 @@ const TraktUserContract = c.router({ }), summary: "Get user stats", }, + getLists: { + method: "GET", + path: "/users/:id/lists", + responses: { + 200: TraktListsSchema, + }, + pathParams: z.object({ + id: z.string().default("me"), + }), + query: TraktPaginationSchema, + summary: "Get personal lists (paginated, limit clamped per endpoint)", + }, + getList: { + method: "GET", + path: "/users/:id/lists/:listId", + responses: { + 200: TraktListSchema, + 404: z.unknown(), + }, + pathParams: TraktListPathSchema, + summary: "Get one personal list", + }, + createList: { + method: "POST", + path: "/users/:id/lists", + responses: { + 201: z.unknown(), + }, + pathParams: z.object({ + id: z.string().default("me"), + }), + body: TraktListWriteBodySchema.extend({ name: z.string() }), + summary: "Create a personal list", + }, + updateList: { + method: "PUT", + path: "/users/:id/lists/:listId", + responses: { + 200: z.unknown(), + }, + pathParams: TraktListPathSchema, + body: TraktListWriteBodySchema, + summary: "Update a personal list (the slug is kept when the name changes)", + }, + deleteList: { + method: "DELETE", + path: "/users/:id/lists/:listId", + responses: { + 204: z.unknown(), + }, + pathParams: TraktListPathSchema, + body: c.noBody(), + summary: "Delete a personal list and every item on it", + }, + getListItems: { + method: "GET", + path: "/users/:id/lists/:listId/items/:type", + responses: { + 200: TraktListEntriesSchema, + }, + pathParams: TraktListPathSchema.extend({ + type: z.literal("movie,show,season,episode"), + }), + query: TraktPaginationSchema, + summary: "Get movie, show, season and episode items on a personal list", + }, + addListItems: { + method: "POST", + path: "/users/:id/lists/:listId/items", + responses: { + 201: z.unknown(), + }, + pathParams: TraktListPathSchema, + body: TraktListItemsBodySchema, + summary: "Add items to a personal list", + }, + removeListItems: { + method: "POST", + path: "/users/:id/lists/:listId/items/remove", + responses: { + 200: z.unknown(), + }, + pathParams: TraktListPathSchema, + body: TraktListItemsBodySchema, + summary: "Remove items from a personal list", + }, }); const TraktSearchContract = c.router({ diff --git a/extensions/trakt-manager/src/lib/schema.ts b/extensions/trakt-manager/src/lib/schema.ts index 67b495ff251..234fd8631c8 100644 --- a/extensions/trakt-manager/src/lib/schema.ts +++ b/extensions/trakt-manager/src/lib/schema.ts @@ -402,6 +402,132 @@ export const TraktUserStatsSchema = z.object({ export type TraktUserStats = z.infer; +export const TraktListPrivacySchema = z.enum(["private", "link", "friends", "public"]); + +export const TraktListSortBySchema = z.enum([ + "rank", + "added", + "title", + "released", + "runtime", + "popularity", + "random", + "percentage", + "imdb_rating", + "tmdb_rating", + "rt_tomatometer", + "rt_audience", + "metascore", + "votes", + "imdb_votes", + "tmdb_votes", + "my_rating", + "watched", + "collected", +]); + +export const TraktListSortHowSchema = z.enum(["asc", "desc"]); + +export const TraktListSchema = z.object({ + name: z.string(), + description: z.string().optional().nullable(), + privacy: z.string().optional(), + share_link: z.string().optional().nullable(), + type: z.string().optional(), + display_numbers: z.boolean().optional(), + allow_comments: z.boolean().optional(), + sort_by: z.string().optional(), + sort_how: z.string().optional(), + created_at: z.string().optional(), + updated_at: z.string().optional(), + item_count: z.number().optional(), + comment_count: z.number().optional(), + likes: z.number().optional(), + ids: z.object({ + trakt: z.number(), + slug: z.string().optional().nullable(), + }), +}); + +export const TraktListsSchema = z.array(TraktListSchema); + +export const TraktListWriteBodySchema = z.object({ + name: z.string().optional(), + description: z.string().optional(), + privacy: TraktListPrivacySchema.optional(), + display_numbers: z.boolean().optional(), + allow_comments: z.boolean().optional(), + sort_by: TraktListSortBySchema.optional(), + sort_how: TraktListSortHowSchema.optional(), +}); + +/** + * List entries return lightweight media objects, so external ids may be missing or null. + */ +const TraktListMediaIdsSchema = z.object({ + trakt: z.number(), + slug: z.string().optional().nullable(), + tvdb: z.number().optional().nullable(), + imdb: z.string().optional().nullable(), + tmdb: z.number().optional().nullable(), +}); + +export const TraktListEntrySchema = z.object({ + id: z.number(), + rank: z.number().optional().nullable(), + listed_at: z.string().optional().nullable(), + notes: z.string().optional().nullable(), + type: z.string(), + movie: z + .object({ + title: z.string(), + year: z.number().optional().nullable(), + ids: TraktListMediaIdsSchema, + }) + .optional(), + show: z + .object({ + title: z.string(), + year: z.number().optional().nullable(), + ids: TraktListMediaIdsSchema, + }) + .optional(), + season: z + .object({ + number: z.number(), + ids: TraktListMediaIdsSchema.partial().optional(), + }) + .optional(), + episode: z + .object({ + season: z.number(), + number: z.number(), + title: z.string().optional().nullable(), + ids: TraktListMediaIdsSchema, + }) + .optional(), +}); + +export const TraktListEntriesSchema = z.array(TraktListEntrySchema); + +export const TraktListItemIdSchema = z.object({ + ids: z.object({ + trakt: z.number(), + }), +}); + +export const TraktListItemsBodySchema = z.object({ + movies: z.array(TraktListItemIdSchema).optional(), + shows: z.array(TraktListItemIdSchema).optional(), + seasons: z.array(TraktListItemIdSchema).optional(), + episodes: z.array(TraktListItemIdSchema).optional(), +}); + +export type TraktList = z.infer; +export type TraktListEntry = z.infer; +export type TraktListItemsBody = z.infer; +export type TraktListWriteBody = z.infer; + export const TraktPaginationHeaderSchema = z.object({ "x-pagination-page": z.coerce.number().default(0), "x-pagination-limit": z.coerce.number().default(0), diff --git a/extensions/trakt-manager/src/tools/add-to-list.ts b/extensions/trakt-manager/src/tools/add-to-list.ts new file mode 100644 index 00000000000..06bcfa3e87d --- /dev/null +++ b/extensions/trakt-manager/src/tools/add-to-list.ts @@ -0,0 +1,105 @@ +import { Action, Tool } from "@raycast/api"; +import { getOwnList, resolveListSelection, summarizeLabels } from "./list-api"; +import { assertListId } from "./list-matching"; +import { assertListAdded, readListWrite, totalCount } from "./list-write"; +import { executeToolCall, toolTraktClient } from "./tool-client"; + +type Input = { + /** + * Trakt ID or slug of the target list. Get it from `create-list` or `get-lists`. + */ + listId: string; + /** + * Optional display name. Ignored: the confirmation looks the list up by `listId` on Trakt. + */ + listName?: string; + /** + * Comma-separated Trakt IDs of the movies to add, e.g. "329862,16662,355370". + * Put EVERY movie in this one field; never call this tool once per movie. + */ + movieTraktIds?: string; + /** + * Comma-separated Trakt IDs of the TV shows to add, e.g. "154784,1388". + */ + showTraktIds?: string; + /** + * Comma-separated seasons as "showTraktId:seasonNumber", e.g. "154784:1,1388:2". + * Seasons are addressed through their show; never pass a bare season ID. + */ + seasons?: string; + /** + * Comma-separated episodes as "showTraktId:seasonNumber:episodeNumber", e.g. "154784:1:3". + */ + episodes?: string; +}; + +type Output = { + success: boolean; + message: string; + added: number; + /** Items that were already on the list and were left as they were. */ + alreadyPresent: number; + /** Items Trakt could not match. */ + notFound: number; + listItemCount?: number; +}; + +export const confirmation: Tool.Confirmation = async (input) => { + const [list, selection] = await Promise.all([getOwnList(input.listId), resolveListSelection(input)]); + const { counts } = selection; + + return { + style: Action.Style.Regular, + message: `Add ${selection.total} item(s) to the Trakt list "${list.name}"?`, + info: [ + { name: "List", value: list.name }, + { name: "Movies", value: String(counts.movies) }, + { name: "TV Shows", value: String(counts.shows) }, + { name: "Seasons", value: String(counts.seasons) }, + { name: "Episodes", value: String(counts.episodes) }, + { name: "Items", value: summarizeLabels(selection.labels) }, + ], + }; +}; + +/** + * Add movies, TV shows, seasons and episodes to one of your Trakt personal lists. + * Batch every title into a SINGLE call with comma-separated values instead of calling this + * tool once per title. + */ +export default async function tool(input: Input): Promise { + const listId = assertListId(input.listId); + const [list, selection] = await Promise.all([getOwnList(listId), resolveListSelection(input)]); + + const res = await executeToolCall( + (signal) => + toolTraktClient.users.addListItems({ + params: { id: "me", listId }, + body: selection.body, + fetchOptions: { signal }, + }), + `Failed to add items to the list "${list.name}"`, + ); + + const result = readListWrite(res.body); + assertListAdded(result, list.name); + + const added = totalCount(result.added); + const alreadyPresent = totalCount(result.existing); + const notFound = totalCount(result.notFound); + + const parts = [`Added ${added} item(s) to "${list.name}".`]; + if (alreadyPresent > 0) parts.push(`${alreadyPresent} were already on the list.`); + if (notFound > 0) parts.push(`${notFound} could not be matched on Trakt and were skipped.`); + const unaccounted = selection.total - added - alreadyPresent - notFound; + if (unaccounted > 0) parts.push(`Trakt did not report on ${unaccounted} item(s); check the list before retrying.`); + + return { + success: true, + message: parts.join(" "), + added, + alreadyPresent, + notFound, + listItemCount: result.listItemCount, + }; +} diff --git a/extensions/trakt-manager/src/tools/compact-media.ts b/extensions/trakt-manager/src/tools/compact-media.ts index e54c22c3466..453a1b1137a 100644 --- a/extensions/trakt-manager/src/tools/compact-media.ts +++ b/extensions/trakt-manager/src/tools/compact-media.ts @@ -1,5 +1,7 @@ import { TraktEpisodeListItem, + TraktList, + TraktListEntry, TraktMovieBaseItem, TraktMovieHistoryListItem, TraktMovieListItem, @@ -119,6 +121,35 @@ export type CompactUserStats = { ratingDistribution?: { [rating: string]: number }; }; +export type CompactList = { + traktId: number; + slug?: string; + /** Pass this to the other list tools. */ + listId: string; + name: string; + description?: string; + privacy?: string; + itemCount: number; + displayNumbers?: boolean; + sortBy?: string; + sortHow?: string; + updatedAt?: string; +}; + +export type CompactListEntry = { + listEntryId: number; + rank?: number; + type: "movie" | "show" | "season" | "episode" | string; + title: string; + /** Release year of the movie or show; the parent show's year for seasons and episodes. */ + year?: number; + traktId: number; + showTraktId?: number; + seasonNumber?: number; + episodeNumber?: number; + episodeLabel?: string; +}; + function truncateOverview(overview?: string, maxLength = 220): string | undefined { if (!overview) return undefined; if (overview.length <= maxLength) return overview; @@ -266,6 +297,65 @@ export function toCompactRating(item: TraktUserRatingItem): CompactRatingItem { }; } +export function toCompactList(list: TraktList): CompactList { + const slug = list.ids.slug ?? undefined; + return { + traktId: list.ids.trakt, + slug, + listId: slug ?? String(list.ids.trakt), + name: list.name, + description: truncateOverview(list.description ?? undefined), + privacy: list.privacy, + itemCount: list.item_count ?? 0, + displayNumbers: list.display_numbers, + sortBy: list.sort_by, + sortHow: list.sort_how, + updatedAt: list.updated_at, + }; +} + +export function toCompactListEntry(entry: TraktListEntry): CompactListEntry { + const pad = (n: number) => n.toString().padStart(2, "0"); + const base = { listEntryId: entry.id, rank: entry.rank ?? undefined, type: entry.type }; + + if (entry.type === "movie" && entry.movie) { + return { ...base, title: entry.movie.title, year: entry.movie.year ?? undefined, traktId: entry.movie.ids.trakt }; + } + + if (entry.type === "episode" && entry.episode) { + const label = `S${pad(entry.episode.season)}E${pad(entry.episode.number)}`; + return { + ...base, + title: entry.show + ? `${entry.show.title}: ${entry.episode.title ?? "Episode"}` + : (entry.episode.title ?? "Episode"), + year: entry.show?.year ?? undefined, + traktId: entry.episode.ids.trakt, + showTraktId: entry.show?.ids.trakt, + seasonNumber: entry.episode.season, + episodeNumber: entry.episode.number, + episodeLabel: label, + }; + } + + if (entry.type === "season" && entry.season) { + return { + ...base, + title: entry.show ? `${entry.show.title} (Season ${entry.season.number})` : `Season ${entry.season.number}`, + year: entry.show?.year ?? undefined, + traktId: entry.season.ids?.trakt ?? 0, + showTraktId: entry.show?.ids.trakt, + seasonNumber: entry.season.number, + }; + } + + if (entry.type === "show" && entry.show) { + return { ...base, title: entry.show.title, year: entry.show.year ?? undefined, traktId: entry.show.ids.trakt }; + } + + return { ...base, title: "Unknown", traktId: 0 }; +} + export function toCompactUserStats(stats: TraktUserStats): CompactUserStats { const movieMinutes = stats.movies?.minutes ?? 0; const episodeMinutes = stats.episodes?.minutes ?? 0; diff --git a/extensions/trakt-manager/src/tools/create-list.ts b/extensions/trakt-manager/src/tools/create-list.ts new file mode 100644 index 00000000000..65e2cd64690 --- /dev/null +++ b/extensions/trakt-manager/src/tools/create-list.ts @@ -0,0 +1,179 @@ +import { Action, Tool } from "@raycast/api"; +import { TraktListSchema } from "../lib/schema"; +import { CompactList, toCompactList } from "./compact-media"; +import { fetchAllLists } from "./list-api"; +import { listNameEquals, listNameSimilar } from "./list-matching"; +import { executeToolCall, toolTraktClient } from "./tool-client"; + +type Input = { + /** + * Name of the list. Emojis are supported (e.g. "🎬 Oscars 2026"). + */ + name: string; + /** + * Optional description explaining what the list contains. + */ + description?: string; + /** + * Visibility: "private" (default, only you), "link" (anyone with the share link), + * "friends", or "public". + */ + privacy?: "private" | "link" | "friends" | "public"; + /** + * Show a position number next to each item. Useful for rankings. Defaults to false. + */ + displayNumbers?: boolean; + /** + * Allow comments on the list. Trakt defaults to true. + */ + allowComments?: boolean; + /** + * How items are ordered. Trakt defaults to "rank". + */ + sortBy?: + | "rank" + | "added" + | "title" + | "released" + | "runtime" + | "popularity" + | "random" + | "percentage" + | "imdb_rating" + | "tmdb_rating" + | "rt_tomatometer" + | "rt_audience" + | "metascore" + | "votes" + | "imdb_votes" + | "tmdb_votes" + | "my_rating" + | "watched" + | "collected"; + /** + * Sort direction: "asc" (Trakt default) or "desc". + */ + sortHow?: "asc" | "desc"; +}; + +type Output = { + success: boolean; + /** + * True when a list with the same name already existed and was reused instead of duplicated. + */ + alreadyExisted: boolean; + /** + * True when a nearby name exists but is not the same list (emoji or punctuation differs). + * Nothing was created or reused: confirm with the user first. + */ + ambiguous?: boolean; + message: string; + list: CompactList; + /** + * Identifier to pass to `add-to-list`. + */ + listId: string; +}; + +export const confirmation: Tool.Confirmation = async (input) => { + const info = [ + { name: "Name", value: input.name }, + { name: "Privacy", value: input.privacy ?? "private" }, + ]; + + if (input.description) info.push({ name: "Description", value: input.description }); + if (input.displayNumbers) info.push({ name: "Numbered", value: "Yes" }); + if (input.allowComments === false) info.push({ name: "Comments", value: "Off" }); + if (input.sortBy) info.push({ name: "Sort", value: `${input.sortBy}${input.sortHow ? ` ${input.sortHow}` : ""}` }); + + return { + style: Action.Style.Regular, + message: `Create the personal list "${input.name}" on your Trakt account?`, + info, + }; +}; + +/** + * Create a new personal list on your Trakt account. + * If a list with the same name (case and accents folded) already exists it is reused. + * A nearby name that only matches after stripping emoji or punctuation is reported as + * ambiguous and nothing is written. + * Returns a `listId` to pass to `add-to-list`. + */ +export default async function tool(input: Input): Promise { + const { name, description, privacy = "private", displayNumbers, allowComments, sortBy, sortHow } = input; + + const trimmedName = name?.trim(); + if (!trimmedName) { + throw new Error("A list name is required."); + } + + const existing = await fetchAllLists(); + if (!existing.exhaustive) { + throw new Error( + "Not every existing list could be read, so a duplicate cannot be ruled out. Nothing was created; " + + "use `get-lists` with `query` to check for the name first.", + ); + } + + const duplicate = existing.lists.find((list) => listNameEquals(list.name, trimmedName)); + if (duplicate) { + const list = toCompactList(duplicate); + return { + success: true, + alreadyExisted: true, + message: `A list named "${list.name}" already exists (${list.itemCount} item(s)); reusing it instead of creating a duplicate.`, + list, + listId: list.listId, + }; + } + + const similar = existing.lists.find((list) => listNameSimilar(list.name, trimmedName)); + if (similar) { + const list = toCompactList(similar); + return { + success: false, + alreadyExisted: false, + ambiguous: true, + message: + `A list with a very similar name already exists ("${list.name}"). It was neither reused nor ` + + `duplicated: confirm with the user which list they mean, then pass that \`listId\` to \`add-to-list\`.`, + list, + listId: list.listId, + }; + } + + const res = await executeToolCall( + (signal) => + toolTraktClient.users.createList({ + params: { id: "me" }, + body: { + name: trimmedName, + description, + privacy, + display_numbers: displayNumbers, + allow_comments: allowComments, + sort_by: sortBy, + sort_how: sortHow, + }, + fetchOptions: { signal }, + }), + `Failed to create the list "${trimmedName}"`, + ); + + const parsed = TraktListSchema.safeParse(res.body); + if (!parsed.success) { + throw new Error( + `Trakt answered without returning the new list "${trimmedName}". Check with \`get-lists\` before retrying.`, + ); + } + + const created = toCompactList(parsed.data); + return { + success: true, + alreadyExisted: false, + message: `Created the ${created.privacy ?? privacy} list "${created.name}".`, + list: created, + listId: created.listId, + }; +} diff --git a/extensions/trakt-manager/src/tools/delete-list.ts b/extensions/trakt-manager/src/tools/delete-list.ts new file mode 100644 index 00000000000..c6d0ddb06b6 --- /dev/null +++ b/extensions/trakt-manager/src/tools/delete-list.ts @@ -0,0 +1,59 @@ +import { Action, Tool } from "@raycast/api"; +import { getOwnList } from "./list-api"; +import { assertListId } from "./list-matching"; +import { executeToolCall, toolTraktClient } from "./tool-client"; + +type Input = { + /** + * Trakt ID or slug of the list to delete. Get it from `get-lists`. + */ + listId: string; +}; + +type Output = { + success: boolean; + message: string; +}; + +export const confirmation: Tool.Confirmation = async (input) => { + const list = await getOwnList(input.listId); + + return { + style: Action.Style.Destructive, + message: `Delete the Trakt list "${list.name}" and all ${list.item_count ?? 0} item(s) on it? This cannot be undone.`, + info: [ + { name: "List", value: list.name }, + { name: "Items", value: String(list.item_count ?? 0) }, + { name: "Privacy", value: list.privacy ?? "private" }, + ], + }; +}; + +/** + * Permanently delete one of your Trakt personal lists and every item on it. + * A destructive confirmation dialog names the list before anything is deleted. + * Only use this when the user explicitly asks to delete the whole list; to take titles out, + * use `remove-from-list`. + */ +export default async function tool(input: Input): Promise { + const listId = assertListId(input.listId); + const list = await getOwnList(listId); + + const res = await executeToolCall( + (signal) => + toolTraktClient.users.deleteList({ + params: { id: "me", listId }, + fetchOptions: { signal }, + }), + `Failed to delete the list "${list.name}"`, + ); + + if (res.status !== 204) { + throw new Error(`Trakt answered HTTP ${res.status} instead of confirming the deletion of "${list.name}".`); + } + + return { + success: true, + message: `Deleted the list "${list.name}" and its ${list.item_count ?? 0} item(s).`, + }; +} diff --git a/extensions/trakt-manager/src/tools/get-lists.ts b/extensions/trakt-manager/src/tools/get-lists.ts new file mode 100644 index 00000000000..08476ce8949 --- /dev/null +++ b/extensions/trakt-manager/src/tools/get-lists.ts @@ -0,0 +1,291 @@ +import { TraktListEntry } from "../lib/schema"; +import { CompactList, CompactListEntry, toCompactList, toCompactListEntry } from "./compact-media"; +import { fetchAllLists, fetchListItems, getOwnList } from "./list-api"; +import { listNameContains, listNameEquals } from "./list-matching"; +import { isMatchableTitle, partitionByLookup, resolveLookupQuery } from "./title-text"; + +type ItemType = "movies" | "shows" | "seasons" | "episodes"; + +type Input = { + /** + * Name or partial name of a list to look up (e.g. "Oscars"). Omit to get every personal list. + */ + query?: string; + /** + * Trakt ID or slug of one list. Takes precedence over `query`. + */ + listId?: string; + /** + * Set to true to also return the items of the matched list. Only applies when exactly one + * list matches. + */ + includeItems?: boolean; + /** + * Maximum number of items to return when `includeItems` is true (default: 50, max: 100). + */ + itemLimit?: number; + /** + * Check whether a title is on the matched list (e.g. "Parasite"). ALWAYS use this instead of + * reading `items` yourself: it scans every item, not just the ones returned. + * A year in the text ("Dune 1989") is read as a year filter. + */ + itemQuery?: string; + /** + * Optional release year for `itemQuery`. For seasons and episodes, this is the show's year. + */ + itemYear?: number; + /** + * Trakt ID of the item to check. Movie, show, season and episode IDs overlap, so `itemType` + * is required with it. + */ + itemTraktId?: number; + /** + * Which item type `itemQuery` / `itemTraktId` targets: "movies", "shows", "seasons" or + * "episodes". Defaults to movies and shows. Seasons and episodes are matched by show title. + */ + itemType?: ItemType; +}; + +type Output = { + /** + * True only for a list whose name matches `query` outright, or for the requested `listId`. + * Lists whose name merely contains the query stay in `lists` without making this true. + */ + found: boolean; + /** + * For an item check: true only when the exact title (or the requested ID) is on the list. + */ + inList?: boolean; + /** + * True when every list (and, for item checks, every item of the list) was inspected, so a + * negative answer is definitive. Absence proves nothing while this is false. + */ + exhaustive: boolean; + message: string; + lists: CompactList[]; + /** Items of the matched list, when requested. */ + items?: CompactListEntry[]; + /** For an item check: entries matching the title, the ID, or containing the title. */ + matchedItems?: CompactListEntry[]; + totalItems?: number; +}; + +const ENTRY_TYPE: Record = { + movies: "movie", + shows: "show", + seasons: "season", + episodes: "episode", +}; + +function entryTitles(entry: TraktListEntry): Array { + if (entry.type === "movie") return [entry.movie?.title]; + if (entry.type === "show") return [entry.show?.title]; + if (entry.type === "episode") return [entry.show?.title, entry.episode?.title ?? undefined]; + return [entry.show?.title]; +} + +function plural(count: number, one: string, many: string): string { + return count === 1 ? one : many; +} + +async function checkMembership( + list: CompactList, + input: Input, +): Promise> { + const { itemQuery, itemYear, itemTraktId, itemType } = input; + + if (itemTraktId !== undefined && !itemType) { + return { + inList: false, + exhaustive: false, + message: + `Trakt ID ${itemTraktId} is ambiguous: movie, show, season and episode IDs overlap. ` + + `Call again with \`itemType\` instead of answering from this ID.`, + }; + } + + const lookup = resolveLookupQuery(itemQuery, itemYear); + if (itemQuery && itemTraktId === undefined && !isMatchableTitle(lookup.text ?? itemQuery)) { + return { + inList: false, + exhaustive: false, + message: + `The title ${JSON.stringify(itemQuery)} cannot be compared: after normalization it has no letters or ` + + `digits. This is NOT a confirmed absence from "${list.name}".`, + }; + } + + const fetched = await fetchListItems(list.listId, list.name); + const wantedTypes = itemType ? [ENTRY_TYPE[itemType]] : ["movie", "show"]; + const candidates = fetched.items.filter((entry) => wantedTypes.includes(entry.type)); + const compact = new Map(candidates.map((entry) => [entry, toCompactListEntry(entry)])); + + const pick = partitionByLookup( + candidates, + entryTitles, + (entry) => compact.get(entry)?.traktId ?? 0, + (entry) => compact.get(entry)?.year, + itemQuery, + itemTraktId, + itemYear, + ); + + const toCompact = (entries: TraktListEntry[]) => entries.map((entry) => compact.get(entry) as CompactListEntry); + const exact = toCompact(pick.exact); + const yearHeldBy = toCompact(pick.yearHeldBy); + const yearUnknown = toCompact(pick.yearUnknown); + const related = toCompact(pick.related); + const target = itemQuery ? `"${itemQuery}"` : `Trakt ID ${itemTraktId}`; + const yearLabel = lookup.year !== undefined && itemQuery ? ` (${lookup.year})` : ""; + const inList = exact.length > 0; + const exhaustive = fetched.exhaustive && (inList || (yearHeldBy.length === 0 && yearUnknown.length === 0)); + const describe = (entries: CompactListEntry[]) => + entries.map((entry) => `"${entry.title}"${entry.year ? ` (${entry.year})` : ""}`).join(", "); + + let message: string; + if (inList) { + message = + related.length > 0 + ? `${target}${yearLabel} is on "${list.name}", plus ${related.length} related ` + + `${plural(related.length, "entry", "entries")} whose title contains it.` + : `${target}${yearLabel} is on "${list.name}".`; + } else if (yearHeldBy.length > 0) { + message = + `${target} is on "${list.name}", but not for ${lookup.year}: ${describe(yearHeldBy)}. ` + + `Ask which release they mean instead of reporting a confirmed absence.`; + } else if (yearUnknown.length > 0) { + message = + `${target} is on "${list.name}", but Trakt gave no year for ${describe(yearUnknown)}, so this is NOT proof ` + + `it is the ${lookup.year} release and NOT a confirmed absence.`; + } else if (related.length > 0) { + message = + `${target} itself is not on "${list.name}", but ${related.length} related ` + + `${plural(related.length, "entry shares", "entries share")} part of that title: ${describe(related)}. ` + + `Ask whether they meant one of those rather than answering with a flat no.`; + } else if (exhaustive) { + message = `Confirmed: ${target}${yearLabel} is not on "${list.name}" (checked all ${fetched.totalItems} item(s)).`; + } else { + message = + `${target}${yearLabel} was not found, but "${list.name}" is too large to scan entirely. ` + + `This result is NOT definitive.`; + } + + const matchedItems = [...exact, ...yearHeldBy, ...yearUnknown, ...related]; + return { + inList, + exhaustive, + message, + matchedItems: matchedItems.length > 0 ? matchedItems : undefined, + totalItems: fetched.totalItems, + }; +} + +/** + * Get the personal lists on your Trakt account, read the items of one list, or check whether + * a title is on a list. Use this before `add-to-list`, `remove-from-list`, `update-list` or + * `delete-list` to find the `listId` of an existing list. + */ +export default async function tool(input: Input): Promise { + const { query, listId, includeItems = false, itemLimit = 50, itemQuery, itemTraktId } = input; + const safeItemLimit = Math.min(Math.max(itemLimit, 1), 100); + const wantsMembership = Boolean(itemQuery) || itemTraktId !== undefined; + + let exact: CompactList[] = []; + let partial: CompactList[] = []; + let listsExhaustive = true; + let allLists: CompactList[] = []; + + if (listId) { + exact = [toCompactList(await getOwnList(listId))]; + } else { + const fetched = await fetchAllLists(); + listsExhaustive = fetched.exhaustive; + allLists = fetched.lists.map(toCompactList); + + if (query) { + exact = allLists.filter((list) => listNameEquals(list.name, query)); + partial = allLists.filter((list) => !exact.includes(list) && listNameContains(list.name, query)); + } else { + exact = allLists; + } + } + + const lists = [...exact, ...partial]; + const found = exact.length > 0; + const target = listId ? `list ${listId}` : query ? `"${query}"` : "any list"; + + if (lists.length === 0) { + const names = allLists.map((list) => `"${list.name}"`).join(", ") || "none"; + return { + found: false, + exhaustive: listsExhaustive, + message: listsExhaustive + ? `No personal list matching ${target}. You have ${allLists.length} list(s): ${names}.` + : `No personal list matching ${target} among the ${allLists.length} list(s) Trakt returned, but not every ` + + `list could be read. This is NOT a confirmed absence.`, + lists: allLists, + }; + } + + let message: string; + if (!query || listId) { + message = `Found ${lists.length} list(s).`; + } else if (found) { + message = + partial.length > 0 + ? `Found ${exact.length} list(s) named ${target}, plus ${partial.length} whose name contains it.` + : `Found ${exact.length} list(s) named ${target}.`; + } else { + message = + `No list named exactly ${target}, but ${partial.length} list(s) contain that name: ` + + `${partial.map((list) => `"${list.name}"`).join(", ")}. Ask the user which one they mean ` + + `instead of treating a partial match as the list.`; + } + if (!listsExhaustive) message += " Not every list could be read."; + + if ((includeItems || wantsMembership) && lists.length !== 1) { + return { + found, + exhaustive: false, + message: `${message} Cannot read items: ${lists.length} lists matched β€” pass \`listId\` to pick one.`, + lists, + }; + } + + if (wantsMembership) { + const membership = await checkMembership(lists[0], input); + return { + found, + lists, + ...membership, + message: found ? membership.message : `${message} ${membership.message}`, + }; + } + + if (includeItems) { + const matched = lists[0]; + const fetched = await fetchListItems(matched.listId, matched.name); + const shown = fetched.items.slice(0, safeItemLimit).map(toCompactListEntry); + const complete = fetched.exhaustive && shown.length === fetched.items.length; + const itemMessage = complete + ? `"${matched.name}" contains ${fetched.totalItems} item(s).` + : `Showing ${shown.length} of ${fetched.totalItems} item(s) in "${matched.name}". To check whether a ` + + `title is on the list, call again with \`itemQuery\` instead of reading this page.`; + + return { + found, + exhaustive: complete, + message: found ? itemMessage : `${message} ${itemMessage}`, + lists, + items: shown, + totalItems: fetched.totalItems, + }; + } + + return { + found, + exhaustive: listsExhaustive, + message, + lists, + }; +} diff --git a/extensions/trakt-manager/src/tools/list-api.ts b/extensions/trakt-manager/src/tools/list-api.ts new file mode 100644 index 00000000000..d2b089e7060 --- /dev/null +++ b/extensions/trakt-manager/src/tools/list-api.ts @@ -0,0 +1,254 @@ +import { + scanPageComplete, + TraktList, + TraktListEntry, + TraktListItemsBody, + TraktListSchema, + withPagination, +} from "../lib/schema"; +import { + assertListId, + EpisodeKey, + episodeCode, + parseEpisodeKeys, + parseSeasonKeys, + parseTraktIds, + SeasonKey, +} from "./list-matching"; +import { describeMedia } from "./resolve-media"; +import { + executeToolCall, + executeToolCallAllowingNotFound, + TRAKT_LOOKUP_PAGE_SIZE, + toolTraktClient, +} from "./tool-client"; + +/** Upper bound on items resolved for one add/remove call, so a confirmation stays bounded. */ +export const LIST_BATCH_CAP = 50; + +const MAX_LIST_PAGES = 20; + +/** + * Every personal list on the account. `/users/:id/lists` is paginated and Trakt applies a low + * default limit when none is sent, so a single unpaginated call silently misses lists. + */ +export async function fetchAllLists(): Promise<{ lists: TraktList[]; exhaustive: boolean }> { + const lists: TraktList[] = []; + + for (let page = 1; page <= MAX_LIST_PAGES; page++) { + const res = await executeToolCall( + (signal) => + toolTraktClient.users.getLists({ + params: { id: "me" }, + query: { page, limit: TRAKT_LOOKUP_PAGE_SIZE }, + fetchOptions: { signal }, + }), + "Failed to fetch your Trakt personal lists", + ); + const paginated = withPagination(res); + lists.push(...paginated.data); + + if (scanPageComplete(paginated.data.length, paginated.pagination, TRAKT_LOOKUP_PAGE_SIZE)) { + return { lists, exhaustive: true }; + } + } + + return { lists, exhaustive: false }; +} + +/** + * Fetch one of the user's lists by ID or slug. Throws when it does not exist, which blocks + * any write that would otherwise target a list the confirmation could not name. + */ +export async function getOwnList(listId: string): Promise { + const id = assertListId(listId); + const res = await executeToolCallAllowingNotFound( + (signal) => + toolTraktClient.users.getList({ + params: { id: "me", listId: id }, + fetchOptions: { signal }, + }), + `Failed to look up the list "${id}"`, + ); + + const parsed = res ? TraktListSchema.safeParse(res.body) : undefined; + if (!parsed?.success) { + throw new Error(`No personal list matches "${id}". Use \`get-lists\` to obtain a valid list ID before writing.`); + } + + return parsed.data; +} + +export async function fetchListItems( + listId: string, + listName: string, + maxPages = MAX_LIST_PAGES, +): Promise<{ items: TraktListEntry[]; totalItems: number; exhaustive: boolean }> { + const id = assertListId(listId); + const items: TraktListEntry[] = []; + let totalItems = 0; + + for (let page = 1; page <= maxPages; page++) { + const res = await executeToolCall( + (signal) => + toolTraktClient.users.getListItems({ + params: { id: "me", listId: id, type: "movie,show,season,episode" }, + query: { page, limit: TRAKT_LOOKUP_PAGE_SIZE }, + fetchOptions: { signal }, + }), + `Failed to fetch items of the list "${listName}"`, + ); + const paginated = withPagination(res); + totalItems = paginated.pagination["x-pagination-item-count"] || totalItems; + items.push(...paginated.data); + + if (scanPageComplete(paginated.data.length, paginated.pagination, TRAKT_LOOKUP_PAGE_SIZE)) { + return { items, totalItems: totalItems || items.length, exhaustive: true }; + } + } + + return { items, totalItems: Math.max(totalItems, items.length), exhaustive: false }; +} + +export type ListSelectionInput = { + movieTraktIds?: string; + showTraktIds?: string; + seasons?: string; + episodes?: string; +}; + +export type ResolvedListSelection = { + body: TraktListItemsBody; + labels: string[]; + counts: { movies: number; shows: number; seasons: number; episodes: number }; + total: number; +}; + +async function resolveSeasons(keys: SeasonKey[], showLabels: Map) { + const byShow = new Map(); + for (const key of keys) byShow.set(key.showTraktId, [...(byShow.get(key.showTraktId) ?? []), key.seasonNumber]); + + const resolved = await Promise.all( + [...byShow.entries()].map(async ([showTraktId, numbers]) => { + const res = await executeToolCallAllowingNotFound( + (signal) => + toolTraktClient.shows.getSeasons({ + params: { showid: showTraktId }, + query: { extended: "full" }, + fetchOptions: { signal }, + }), + `Failed to fetch the seasons of the show with Trakt ID ${showTraktId}`, + ); + if (!res) throw new Error(`No Trakt show exists with ID ${showTraktId}. Re-resolve the show before writing.`); + + const showLabel = showLabels.get(showTraktId) ?? `Show ${showTraktId}`; + return numbers.map((seasonNumber) => { + const season = res.body.find((item) => item.number === seasonNumber); + if (!season) { + throw new Error(`${showLabel} has no season ${seasonNumber} on Trakt. Check the season number.`); + } + return { traktId: season.ids.trakt, label: `${showLabel} Season ${seasonNumber}` }; + }); + }), + ); + + return resolved.flat(); +} + +async function resolveEpisodes(keys: EpisodeKey[], showLabels: Map) { + return Promise.all( + keys.map(async ({ showTraktId, seasonNumber, episodeNumber }) => { + const code = episodeCode(seasonNumber, episodeNumber); + const showLabel = showLabels.get(showTraktId) ?? `Show ${showTraktId}`; + const res = await executeToolCallAllowingNotFound( + (signal) => + toolTraktClient.shows.getEpisode({ + params: { showid: showTraktId, seasonNumber, episodeNumber }, + query: { extended: "full" }, + fetchOptions: { signal }, + }), + `Failed to find ${code} of the show with Trakt ID ${showTraktId}`, + ); + if (!res?.body?.ids?.trakt) { + throw new Error(`${showLabel} has no episode ${code} on Trakt. Check the season and episode numbers.`); + } + const title = res.body.title; + return { traktId: res.body.ids.trakt, label: title ? `${showLabel} ${code} "${title}"` : `${showLabel} ${code}` }; + }), + ); +} + +/** + * Resolve every item of an add/remove batch against Trakt before anything is written. + * + * Movie and show IDs share a numeric space, so each one is looked up in the namespace it was + * sent under: an ID that is not a movie (or not a show) blocks the batch instead of writing + * an unrelated title. Seasons and episodes are addressed through their show, because + * `/search/trakt/:id` does not resolve season IDs. + */ +export async function resolveListSelection(input: ListSelectionInput): Promise { + const movies = parseTraktIds(input.movieTraktIds); + const shows = parseTraktIds(input.showTraktIds); + const seasons = parseSeasonKeys(input.seasons); + const episodes = parseEpisodeKeys(input.episodes); + + const invalid = [ + ...movies.invalid.map((value) => `movie "${value}"`), + ...shows.invalid.map((value) => `show "${value}"`), + ...seasons.invalid.map((value) => `season "${value}" (expected showTraktId:seasonNumber)`), + ...episodes.invalid.map((value) => `episode "${value}" (expected showTraktId:season:episode)`), + ]; + if (invalid.length > 0) { + throw new Error(`Invalid item(s): ${invalid.join(", ")}. Nothing was written.`); + } + + const total = movies.ids.length + shows.ids.length + seasons.keys.length + episodes.keys.length; + if (total === 0) { + throw new Error("Provide at least one item in `movieTraktIds`, `showTraktIds`, `seasons` or `episodes`."); + } + if (total > LIST_BATCH_CAP) { + throw new Error(`At most ${LIST_BATCH_CAP} items can be changed per call (received ${total}). Split the batch.`); + } + + const parentShows = [...new Set([...seasons.keys, ...episodes.keys].map((key) => key.showTraktId))]; + const [movieLabels, showLabels, parentLabels] = await Promise.all([ + Promise.all(movies.ids.map((id) => describeMedia("movie", id))), + Promise.all(shows.ids.map((id) => describeMedia("show", id))), + Promise.all(parentShows.map(async (id) => [id, await describeMedia("show", id)] as const)), + ]); + const parentLabelMap = new Map(parentLabels); + + const [resolvedSeasons, resolvedEpisodes] = await Promise.all([ + resolveSeasons(seasons.keys, parentLabelMap), + resolveEpisodes(episodes.keys, parentLabelMap), + ]); + + const ids = (values: number[]) => (values.length > 0 ? values.map((trakt) => ({ ids: { trakt } })) : undefined); + + return { + body: { + movies: ids(movies.ids), + shows: ids(shows.ids), + seasons: ids([...new Set(resolvedSeasons.map((item) => item.traktId))]), + episodes: ids([...new Set(resolvedEpisodes.map((item) => item.traktId))]), + }, + labels: [ + ...movieLabels, + ...showLabels, + ...resolvedSeasons.map((item) => item.label), + ...resolvedEpisodes.map((item) => item.label), + ], + counts: { + movies: movies.ids.length, + shows: shows.ids.length, + seasons: resolvedSeasons.length, + episodes: resolvedEpisodes.length, + }, + total, + }; +} + +export function summarizeLabels(labels: string[], cap = 5): string { + if (labels.length <= cap) return labels.join(", "); + return `${labels.slice(0, cap).join(", ")} and ${labels.length - cap} more`; +} diff --git a/extensions/trakt-manager/src/tools/list-matching.ts b/extensions/trakt-manager/src/tools/list-matching.ts new file mode 100644 index 00000000000..18fd30ed0ad --- /dev/null +++ b/extensions/trakt-manager/src/tools/list-matching.ts @@ -0,0 +1,197 @@ +/** + * Normalize a personal list name for comparison: case and accents are folded, and runs of + * punctuation or symbols collapse into single spaces. + * + * Unicode letters and digits are deliberately preserved. Stripping everything outside + * `a-z0-9` would map every non-Latin name onto the same empty key, so "ζ—₯ζœ¬ζ˜ η”»" and + * "АнимС" would look like duplicates of each other. + * + * A name built only from symbols, such as an emoji-only list name, still normalizes to an + * empty string. Callers must treat that as "fall back to the raw name" rather than as a key + * that matches anything, which is why the comparisons below are exposed as helpers. + */ +export function normalizeListName(value: string): string { + return value + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]+/g, "") + .replace(/[^\p{L}\p{N}]+/gu, " ") + .trim(); +} + +/** + * Case and accents fold, but emoji and punctuation stay. Used when deciding whether two + * names are the same list: "🎬 Oscars 2026" and "Oscars 2026 🎬" must not collapse. + */ +export function foldListName(value: string): string { + return value + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]+/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +function rawName(value: string): string { + return value.trim().toLowerCase(); +} + +/** + * True when two list names refer to the same list. Used to decide whether a list already + * exists, so a false positive silently writes into the wrong list. + */ +export function listNameEquals(left: string, right: string): boolean { + const foldedLeft = foldListName(left); + const foldedRight = foldListName(right); + + if (!foldedLeft || !foldedRight) { + return rawName(left) === rawName(right); + } + + return foldedLeft === foldedRight; +} + +/** + * True when two names share the same letters and digits after punctuation and emoji are + * stripped. A match here is a near-collision, not proof they are the same list. + */ +export function listNameSimilar(left: string, right: string): boolean { + const normalizedLeft = normalizeListName(left); + const normalizedRight = normalizeListName(right); + + if (!normalizedLeft || !normalizedRight) { + return false; + } + + return normalizedLeft === normalizedRight; +} + +/** + * True when `name` contains `query`, for partial lookups such as "Oscars" matching + * "Oscars 2026". An empty query never matches, so it cannot select an arbitrary list. + */ +export function listNameContains(name: string, query: string): boolean { + const normalizedName = normalizeListName(name); + const normalizedQuery = normalizeListName(query); + + if (!normalizedQuery || !normalizedName) { + const raw = rawName(query); + return raw.length > 0 && rawName(name).includes(raw); + } + + return normalizedName.includes(normalizedQuery); +} + +/** + * Trakt list IDs are numeric and slugs are lowercase words joined by dashes. The value is + * inserted into the request path unencoded, so anything else is refused before it can + * address a different endpoint. + */ +export function assertListId(listId: string | number | undefined): string { + const value = String(listId ?? "").trim(); + if (!/^[a-z0-9][a-z0-9-]*$/i.test(value)) { + throw new Error( + `"${listId ?? ""}" is not a Trakt list ID or slug. Use \`get-lists\` or \`create-list\` to obtain one.`, + ); + } + return value; +} + +export type ParsedIds = { ids: number[]; invalid: string[] }; + +/** + * Parse a comma or semicolon separated list of Trakt IDs. Entries that are not positive + * integers are returned in `invalid` instead of being dropped silently, so a typo cannot + * shrink the batch the user approved. + */ +export function parseTraktIds(value?: string): ParsedIds { + if (!value) return { ids: [], invalid: [] }; + + const ids: number[] = []; + const invalid: string[] = []; + + for (const part of value.split(/[,;]/)) { + const trimmed = part.trim(); + if (!trimmed) continue; + const id = Number(trimmed); + if (/^\d+$/.test(trimmed) && Number.isSafeInteger(id) && id > 0) { + if (!ids.includes(id)) ids.push(id); + } else { + invalid.push(trimmed); + } + } + + return { ids, invalid }; +} + +export type SeasonKey = { showTraktId: number; seasonNumber: number }; +export type EpisodeKey = SeasonKey & { episodeNumber: number }; + +function parseKeys( + value: string | undefined, + size: number, + build: (numbers: number[]) => T, + keyOf: (item: T) => string, +): { keys: T[]; invalid: string[] } { + if (!value) return { keys: [], invalid: [] }; + + const keys: T[] = []; + const seen = new Set(); + const invalid: string[] = []; + + for (const part of value.split(/[,;]/)) { + const trimmed = part.trim(); + if (!trimmed) continue; + const pieces = trimmed.split(":").map((piece) => piece.trim()); + const numbers = pieces.map(Number); + const valid = + pieces.length === size && + pieces.every((piece) => /^\d+$/.test(piece)) && + numbers[0] > 0 && + numbers.slice(2).every((n) => n > 0); + + if (!valid) { + invalid.push(trimmed); + continue; + } + + const key = build(numbers); + const id = keyOf(key); + if (!seen.has(id)) { + seen.add(id); + keys.push(key); + } + } + + return { keys, invalid }; +} + +/** + * Parse "showTraktId:seasonNumber" pairs. Seasons are addressed through their show because + * Trakt's ID lookup does not resolve season IDs. Season 0 (specials) is allowed. + */ +export function parseSeasonKeys(value?: string): { keys: SeasonKey[]; invalid: string[] } { + return parseKeys( + value, + 2, + ([showTraktId, seasonNumber]) => ({ showTraktId, seasonNumber }), + (key) => `${key.showTraktId}:${key.seasonNumber}`, + ); +} + +/** + * Parse "showTraktId:seasonNumber:episodeNumber" triples, resolved the same way + * `mark-episode-watched` resolves an episode. + */ +export function parseEpisodeKeys(value?: string): { keys: EpisodeKey[]; invalid: string[] } { + return parseKeys( + value, + 3, + ([showTraktId, seasonNumber, episodeNumber]) => ({ showTraktId, seasonNumber, episodeNumber }), + (key) => `${key.showTraktId}:${key.seasonNumber}:${key.episodeNumber}`, + ); +} + +export function episodeCode(seasonNumber: number, episodeNumber: number): string { + return `S${String(seasonNumber).padStart(2, "0")}E${String(episodeNumber).padStart(2, "0")}`; +} diff --git a/extensions/trakt-manager/src/tools/list-write.ts b/extensions/trakt-manager/src/tools/list-write.ts new file mode 100644 index 00000000000..637e7423757 --- /dev/null +++ b/extensions/trakt-manager/src/tools/list-write.ts @@ -0,0 +1,111 @@ +import { z } from "zod"; + +const ListCountsSchema = z + .object({ + movies: z.number().optional(), + shows: z.number().optional(), + seasons: z.number().optional(), + episodes: z.number().optional(), + }) + .optional(); + +const ListNotFoundSchema = z + .object({ + movies: z.array(z.unknown()).optional(), + shows: z.array(z.unknown()).optional(), + seasons: z.array(z.unknown()).optional(), + episodes: z.array(z.unknown()).optional(), + }) + .optional(); + +const ListItemsWriteSchema = z + .object({ + added: ListCountsSchema, + existing: ListCountsSchema, + deleted: ListCountsSchema, + not_found: ListNotFoundSchema, + list: z.object({ item_count: z.number().optional() }).passthrough().optional(), + }) + .passthrough(); + +export type ListItemKind = "movies" | "shows" | "seasons" | "episodes"; + +export const LIST_ITEM_KINDS: ListItemKind[] = ["movies", "shows", "seasons", "episodes"]; + +export type ListCounts = Record; + +export type ListWriteResult = { + added: ListCounts; + existing: ListCounts; + deleted: ListCounts; + notFound: ListCounts; + listItemCount?: number; + /** False when the body did not look like Trakt's add/remove payload at all. */ + known: boolean; +}; + +function zeroCounts(): ListCounts { + return { movies: 0, shows: 0, seasons: 0, episodes: 0 }; +} + +export function totalCount(counts: ListCounts): number { + return LIST_ITEM_KINDS.reduce((sum, kind) => sum + counts[kind], 0); +} + +/** + * Read Trakt's list add/remove payload. The HTTP status alone is not a write: an item + * already on the list comes back `existing`, and a wrong ID comes back `not_found`. + */ +export function readListWrite(body: unknown): ListWriteResult { + const parsed = ListItemsWriteSchema.safeParse(body); + if (!parsed.success || body === undefined || body === null) { + return { + added: zeroCounts(), + existing: zeroCounts(), + deleted: zeroCounts(), + notFound: zeroCounts(), + known: false, + }; + } + + const data = parsed.data; + const counts = (bucket: typeof data.added): ListCounts => { + const result = zeroCounts(); + for (const kind of LIST_ITEM_KINDS) result[kind] = bucket?.[kind] ?? 0; + return result; + }; + const notFound = zeroCounts(); + for (const kind of LIST_ITEM_KINDS) notFound[kind] = data.not_found?.[kind]?.length ?? 0; + + return { + added: counts(data.added), + existing: counts(data.existing), + deleted: counts(data.deleted), + notFound, + listItemCount: data.list?.item_count, + known: true, + }; +} + +/** + * An add is only a write when Trakt stored or already held something. A 201 with every count + * at 0 means the body was ignored, and a response Trakt did not shape at all proves nothing. + */ +export function assertListAdded(result: ListWriteResult, listName: string): void { + if (!result.known) { + throw new Error(`Trakt did not confirm the write to "${listName}". Check the list before retrying.`); + } + if (totalCount(result.added) === 0 && totalCount(result.existing) === 0) { + throw new Error( + totalCount(result.notFound) > 0 + ? `Trakt matched none of the items for "${listName}". Re-resolve the IDs before writing.` + : `Trakt did not add anything to "${listName}". Re-resolve the items before writing.`, + ); + } +} + +export function assertListRemovalRead(result: ListWriteResult, listName: string): void { + if (!result.known) { + throw new Error(`Trakt did not confirm the removal from "${listName}". Check the list before retrying.`); + } +} diff --git a/extensions/trakt-manager/src/tools/lists.test.ts b/extensions/trakt-manager/src/tools/lists.test.ts new file mode 100644 index 00000000000..23d88392394 --- /dev/null +++ b/extensions/trakt-manager/src/tools/lists.test.ts @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { scanPageComplete } from "../lib/schema"; +import { toCompactList, toCompactListEntry } from "./compact-media"; +import { + assertListId, + listNameContains, + listNameEquals, + listNameSimilar, + parseEpisodeKeys, + parseSeasonKeys, + parseTraktIds, +} from "./list-matching"; +import { assertListAdded, readListWrite, totalCount } from "./list-write"; +import { partitionByLookup } from "./title-text"; + +test("list names fold case and accents but keep emoji apart", () => { + assert.equal(listNameEquals("Films Cultes", "films cultΓ©s"), true); + assert.equal(listNameEquals("🎬 Oscars 2026", "Oscars 2026"), false); + assert.equal(listNameSimilar("🎬 Oscars 2026", "Oscars 2026"), true); + assert.equal(listNameEquals("ζ—₯ζœ¬ζ˜ η”»", "АнимС"), false); + assert.equal(listNameEquals("🎬", "🍿"), false); + assert.equal(listNameContains("🎬 Oscars 2026", "oscars"), true); + assert.equal(listNameContains("Oscars 2026", ""), false); +}); + +test("list IDs must be a numeric ID or a slug", () => { + assert.equal(assertListId("oscars-2026"), "oscars-2026"); + assert.equal(assertListId(30481001), "30481001"); + assert.throws(() => assertListId("../watchlist")); + assert.throws(() => assertListId("oscars 2026")); + assert.throws(() => assertListId("")); +}); + +test("ID batches report typos instead of dropping them", () => { + assert.deepEqual(parseTraktIds("329862, 16662;329862"), { ids: [329862, 16662], invalid: [] }); + assert.deepEqual(parseTraktIds("329862,abc,1.5,-3"), { ids: [329862], invalid: ["abc", "1.5", "-3"] }); +}); + +test("season and episode keys are show-relative", () => { + assert.deepEqual(parseSeasonKeys("154784:1, 154784:0,154784:1"), { + keys: [ + { showTraktId: 154784, seasonNumber: 1 }, + { showTraktId: 154784, seasonNumber: 0 }, + ], + invalid: [], + }); + assert.deepEqual(parseSeasonKeys("88").invalid, ["88"]); + assert.deepEqual(parseEpisodeKeys("154784:1:3").keys, [{ showTraktId: 154784, seasonNumber: 1, episodeNumber: 3 }]); + assert.deepEqual(parseEpisodeKeys("154784:1:0,154784:1").invalid, ["154784:1:0", "154784:1"]); +}); + +test("a 201 with every count at 0 is not a list write", () => { + const empty = readListWrite({ + added: { movies: 0, shows: 0, seasons: 0, episodes: 0 }, + existing: { movies: 0, shows: 0, seasons: 0, episodes: 0 }, + not_found: { movies: [], shows: [], seasons: [], episodes: [] }, + }); + assert.throws(() => assertListAdded(empty, "Oscars")); + + const unmatched = readListWrite({ added: { movies: 0 }, not_found: { movies: [{ ids: { trakt: 1 } }] } }); + assert.throws(() => assertListAdded(unmatched, "Oscars"), /matched none/); + + const alreadyThere = readListWrite({ added: { movies: 0 }, existing: { movies: 1 }, list: { item_count: 7 } }); + assert.doesNotThrow(() => assertListAdded(alreadyThere, "Oscars")); + assert.equal(alreadyThere.listItemCount, 7); + + assert.throws(() => assertListAdded(readListWrite(undefined), "Oscars"), /did not confirm/); +}); + +test("list write counts cover seasons and episodes", () => { + const result = readListWrite({ added: { movies: 1, seasons: 2, episodes: 3 }, deleted: { shows: 1 } }); + assert.equal(totalCount(result.added), 6); + assert.equal(totalCount(result.deleted), 1); +}); + +test("a list scan stops on the served page size, not the requested one", () => { + const clamped = { + "x-pagination-page": 1, + "x-pagination-limit": 100, + "x-pagination-page-count": 3, + "x-pagination-item-count": 260, + }; + assert.equal(scanPageComplete(100, clamped, 250), false); + assert.equal(scanPageComplete(60, { ...clamped, "x-pagination-page": 3 }, 250), true); +}); + +test("compact list uses the slug as listId, falling back to the numeric ID", () => { + assert.equal(toCompactList({ name: "A", ids: { trakt: 5, slug: "a" } }).listId, "a"); + assert.equal(toCompactList({ name: "A", ids: { trakt: 5, slug: null } }).listId, "5"); +}); + +test("season and episode entries carry the show needed to remove them", () => { + const show = { title: "Severance", year: 2022, ids: { trakt: 154784 } }; + const season = toCompactListEntry({ id: 1, type: "season", show, season: { number: 2, ids: { trakt: 88 } } }); + assert.equal(season.showTraktId, 154784); + assert.equal(season.seasonNumber, 2); + assert.equal(season.year, 2022); + + const episode = toCompactListEntry({ + id: 2, + type: "episode", + show, + episode: { season: 1, number: 3, title: "In Perpetuity", ids: { trakt: 99 } }, + }); + assert.equal(episode.episodeLabel, "S01E03"); + assert.equal(episode.showTraktId, 154784); + assert.equal(episode.traktId, 99); +}); + +test("list membership keeps a sequel out of an exact title hit", () => { + const entries = [ + { title: "Dune", year: 2021, traktId: 1 }, + { title: "Dune: Part Two", year: 2024, traktId: 2 }, + ]; + const pick = partitionByLookup( + entries, + (e) => e.title, + (e) => e.traktId, + (e) => e.year, + "Dune 1984", + ); + assert.deepEqual(pick.exact, []); + assert.deepEqual( + pick.yearHeldBy.map((e) => e.traktId), + [1], + ); +}); diff --git a/extensions/trakt-manager/src/tools/remove-from-list.ts b/extensions/trakt-manager/src/tools/remove-from-list.ts new file mode 100644 index 00000000000..871a5ff4036 --- /dev/null +++ b/extensions/trakt-manager/src/tools/remove-from-list.ts @@ -0,0 +1,101 @@ +import { Action, Tool } from "@raycast/api"; +import { getOwnList, resolveListSelection, summarizeLabels } from "./list-api"; +import { assertListId } from "./list-matching"; +import { assertListRemovalRead, readListWrite, totalCount } from "./list-write"; +import { executeToolCall, toolTraktClient } from "./tool-client"; + +type Input = { + /** + * Trakt ID or slug of the target list. Get it from `get-lists`. + */ + listId: string; + /** + * Optional display name. Ignored: the confirmation looks the list up by `listId` on Trakt. + */ + listName?: string; + /** + * Comma-separated Trakt IDs of the movies to remove, e.g. "329862,16662". + * Get them from `get-lists` with `includeItems: true` or `itemQuery`. + */ + movieTraktIds?: string; + /** + * Comma-separated Trakt IDs of the TV shows to remove, e.g. "154784,1388". + */ + showTraktIds?: string; + /** + * Comma-separated seasons as "showTraktId:seasonNumber", e.g. "154784:1". + * `get-lists` returns `showTraktId` and `seasonNumber` for season entries. + */ + seasons?: string; + /** + * Comma-separated episodes as "showTraktId:seasonNumber:episodeNumber", e.g. "154784:1:3". + */ + episodes?: string; +}; + +type Output = { + success: boolean; + message: string; + removed: number; + /** Items that were not on the list, so nothing was removed for them. */ + notOnList: number; + listItemCount?: number; +}; + +export const confirmation: Tool.Confirmation = async (input) => { + const [list, selection] = await Promise.all([getOwnList(input.listId), resolveListSelection(input)]); + const { counts } = selection; + + return { + style: Action.Style.Destructive, + message: `Remove ${selection.total} item(s) from the Trakt list "${list.name}"?`, + info: [ + { name: "List", value: list.name }, + { name: "Movies", value: String(counts.movies) }, + { name: "TV Shows", value: String(counts.shows) }, + { name: "Seasons", value: String(counts.seasons) }, + { name: "Episodes", value: String(counts.episodes) }, + { name: "Items", value: summarizeLabels(selection.labels) }, + ], + }; +}; + +/** + * Remove movies, TV shows, seasons and episodes from one of your Trakt personal lists. + * A destructive confirmation dialog is shown before anything is removed. + */ +export default async function tool(input: Input): Promise { + const listId = assertListId(input.listId); + const [list, selection] = await Promise.all([getOwnList(listId), resolveListSelection(input)]); + + const res = await executeToolCall( + (signal) => + toolTraktClient.users.removeListItems({ + params: { id: "me", listId }, + body: selection.body, + fetchOptions: { signal }, + }), + `Failed to remove items from the list "${list.name}"`, + ); + + const result = readListWrite(res.body); + assertListRemovalRead(result, list.name); + + const removed = totalCount(result.deleted); + const notOnList = Math.max(selection.total - removed, totalCount(result.notFound)); + + const message = + removed === 0 + ? `None of the ${selection.total} item(s) were on "${list.name}". Nothing was removed.` + : notOnList > 0 + ? `Removed ${removed} item(s) from "${list.name}". ${notOnList} were not on the list.` + : `Removed ${removed} item(s) from "${list.name}".`; + + return { + success: true, + message, + removed, + notOnList, + listItemCount: result.listItemCount, + }; +} diff --git a/extensions/trakt-manager/src/tools/update-list.ts b/extensions/trakt-manager/src/tools/update-list.ts new file mode 100644 index 00000000000..2bca98f464c --- /dev/null +++ b/extensions/trakt-manager/src/tools/update-list.ts @@ -0,0 +1,167 @@ +import { Action, Tool } from "@raycast/api"; +import { TraktListSchema, TraktListWriteBody } from "../lib/schema"; +import { CompactList, toCompactList } from "./compact-media"; +import { fetchAllLists, getOwnList } from "./list-api"; +import { assertListId, listNameEquals } from "./list-matching"; +import { executeToolCall, toolTraktClient } from "./tool-client"; + +type Input = { + /** + * Trakt ID or slug of the list to update. Get it from `get-lists`. + */ + listId: string; + /** + * New name. The list keeps its original slug, so existing links keep working. + */ + name?: string; + /** + * New description. Pass an empty string to clear it. + */ + description?: string; + /** + * New visibility: "private", "link" (anyone with the share link), "friends", or "public". + */ + privacy?: "private" | "link" | "friends" | "public"; + /** + * Show a position number next to each item. + */ + displayNumbers?: boolean; + /** + * Allow comments on the list. + */ + allowComments?: boolean; + /** + * How items are ordered. + */ + sortBy?: + | "rank" + | "added" + | "title" + | "released" + | "runtime" + | "popularity" + | "random" + | "percentage" + | "imdb_rating" + | "tmdb_rating" + | "rt_tomatometer" + | "rt_audience" + | "metascore" + | "votes" + | "imdb_votes" + | "tmdb_votes" + | "my_rating" + | "watched" + | "collected"; + /** + * Sort direction: "asc" or "desc". + */ + sortHow?: "asc" | "desc"; +}; + +type Output = { + success: boolean; + message: string; + list: CompactList; + /** Unchanged by a rename: Trakt keeps the original slug. */ + listId: string; +}; + +function buildBody(input: Input): TraktListWriteBody { + const body: TraktListWriteBody = {}; + if (input.name !== undefined) body.name = input.name.trim(); + if (input.description !== undefined) body.description = input.description; + if (input.privacy !== undefined) body.privacy = input.privacy; + if (input.displayNumbers !== undefined) body.display_numbers = input.displayNumbers; + if (input.allowComments !== undefined) body.allow_comments = input.allowComments; + if (input.sortBy !== undefined) body.sort_by = input.sortBy; + if (input.sortHow !== undefined) body.sort_how = input.sortHow; + return body; +} + +function describeChanges(body: TraktListWriteBody): { name: string; value: string }[] { + const changes: { name: string; value: string }[] = []; + if (body.name !== undefined) changes.push({ name: "New name", value: body.name }); + if (body.description !== undefined) changes.push({ name: "Description", value: body.description || "(cleared)" }); + if (body.privacy !== undefined) changes.push({ name: "Privacy", value: body.privacy }); + if (body.display_numbers !== undefined) + changes.push({ name: "Numbered", value: body.display_numbers ? "Yes" : "No" }); + if (body.allow_comments !== undefined) changes.push({ name: "Comments", value: body.allow_comments ? "On" : "Off" }); + if (body.sort_by !== undefined) changes.push({ name: "Sort by", value: body.sort_by }); + if (body.sort_how !== undefined) changes.push({ name: "Sort direction", value: body.sort_how }); + return changes; +} + +function assertBody(body: TraktListWriteBody): void { + if (Object.keys(body).length === 0) { + throw new Error( + "Nothing to update: pass at least one of name, description, privacy, displayNumbers, allowComments, sortBy or sortHow.", + ); + } + if (body.name !== undefined && !body.name) { + throw new Error("A list name cannot be empty."); + } +} + +export const confirmation: Tool.Confirmation = async (input) => { + const body = buildBody(input); + assertBody(body); + const current = await getOwnList(input.listId); + + return { + style: Action.Style.Regular, + message: `Update the Trakt list "${current.name}"?`, + info: [{ name: "List", value: current.name }, ...describeChanges(body)], + }; +}; + +/** + * Update the name, description, privacy, numbering, comments or sorting of one of your Trakt + * personal lists. Renaming keeps the list's slug, so its `listId` does not change. + */ +export default async function tool(input: Input): Promise { + const listId = assertListId(input.listId); + const body = buildBody(input); + assertBody(body); + const current = await getOwnList(listId); + + if (body.name !== undefined && !listNameEquals(body.name, current.name)) { + const { lists } = await fetchAllLists(); + const clash = lists.find( + (list) => list.ids.trakt !== current.ids.trakt && listNameEquals(list.name, body.name ?? ""), + ); + if (clash) { + throw new Error(`Another list is already named "${clash.name}". Nothing was changed; pick a different name.`); + } + } + + const res = await executeToolCall( + (signal) => + toolTraktClient.users.updateList({ + params: { id: "me", listId }, + body, + fetchOptions: { signal }, + }), + `Failed to update the list "${current.name}"`, + ); + + const parsed = TraktListSchema.safeParse(res.body); + if (!parsed.success) { + throw new Error(`Trakt did not return the updated list "${current.name}". Check it with \`get-lists\`.`); + } + + const updated = toCompactList(parsed.data); + if (body.name !== undefined && updated.name !== body.name) { + throw new Error(`Trakt kept the name "${updated.name}" instead of "${body.name}". Check the list before retrying.`); + } + + return { + success: true, + message: + body.name !== undefined && body.name !== current.name + ? `Renamed "${current.name}" to "${updated.name}". Its listId stays "${updated.listId}".` + : `Updated the list "${updated.name}".`, + list: updated, + listId: updated.listId, + }; +} From d98c53458627d4dddbbf87ad2fe0b248ec3d7385 Mon Sep 17 00:00:00 2001 From: berenger <85639913+princeberenger@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:42:48 +0200 Subject: [PATCH 2/4] Address Greptile feedback on personal list tools --- extensions/trakt-manager/ai.yaml | 4 +- extensions/trakt-manager/package.json | 3 +- .../trakt-manager/src/tools/get-lists.ts | 111 ++++++++++++++++-- .../trakt-manager/src/tools/list-api.ts | 7 +- .../trakt-manager/src/tools/list-matching.ts | 41 +++++++ .../trakt-manager/src/tools/lists.test.ts | 54 +++++++++ 6 files changed, 202 insertions(+), 18 deletions(-) diff --git a/extensions/trakt-manager/ai.yaml b/extensions/trakt-manager/ai.yaml index 32c428259d9..d97d0765c41 100644 --- a/extensions/trakt-manager/ai.yaml +++ b/extensions/trakt-manager/ai.yaml @@ -33,9 +33,9 @@ instructions: | Personal Lists: - To build a themed list (award winners, a marathon, a top 10): `create-list`, then ONE `add-to-list` call carrying every item. Resolve each title with `search-movies` / `search-shows` first; the confirmation names every item from Trakt, so titles are never needed alongside the IDs. - - CRITICAL: `add-to-list` and `remove-from-list` take COMMA-SEPARATED values in a SINGLE call: `movieTraktIds: "329862,16662"`, `showTraktIds: "154784"`, `seasons: "154784:1"` (showTraktId:seasonNumber), `episodes: "154784:1:3"` (showTraktId:season:episode). Never call them once per title, and never pass a bare season or episode ID. At most 50 items per call. + - CRITICAL: `add-to-list` and `remove-from-list` take COMMA-SEPARATED values in a SINGLE call: `movieTraktIds: "329862,16662"`, `showTraktIds: "154784"`, `seasons: "154784:1"` (showTraktId:seasonNumber), `episodes: "154784:1:3"` (showTraktId:season:episode). Never call them once per title, and never pass a bare season or episode ID. At most 50 items per call. The confirmation lists every resolved title from Trakt before writing. - Use `get-lists` to find a list's `listId`. `found` is true only for an exact list name; lists whose name merely contains the query stay in `lists` and must be confirmed with the user. - - To check whether a title is on a list (e.g. "Is Parasite on my Oscars list?"), call `get-lists` with the list (`listId` or `query`) and `itemQuery` (plus `itemYear` when known). Read `inList` and trust a negative ONLY when `exhaustive: true`. Never answer from a page of `items`. With `itemTraktId`, always pass `itemType`. + - To check whether a title is on a list (e.g. "Is Parasite on my Oscars list?"), call `get-lists` with the list (`listId` or `query`) and `itemQuery` (plus `itemYear` when known). Read `inList` and trust a negative ONLY when `exhaustive: true`. Never answer from a page of `items`. With `itemTraktId`, always pass `itemType`. For a specific season or episode, also pass `seasonNumber` (and `episodeNumber`), or put them in the query ("Severance season 2", "Severance S01E03") β€” season/episode Trakt IDs are not what the other tools return. - `create-list` reuses a list only when the name matches after folding case and accents. A nearby name that differs by emoji or punctuation comes back `ambiguous: true` without writing: ask the user which list they mean. Lists are `private` unless the user asks otherwise; `link` means anyone with the share link. - Emojis are allowed in list names (e.g. "🎬 Oscars 2026"). For ranked lists, pass `displayNumbers: true`. - Renaming with `update-list` keeps the list's `listId` (Trakt keeps the slug). Use `delete-list` only when the user explicitly asks to delete the whole list; to take titles out, use `remove-from-list`. diff --git a/extensions/trakt-manager/package.json b/extensions/trakt-manager/package.json index 17628a6d9d6..379f323f9e8 100644 --- a/extensions/trakt-manager/package.json +++ b/extensions/trakt-manager/package.json @@ -228,7 +228,8 @@ "fmt": "prettier '**/*' -wu && syncpack-format", "lint": "ray lint", "publish": "ray publish", - "pull": "ray pull-contributions" + "pull": "ray pull-contributions", + "test": "npx --yes tsx --test src/tools/*.test.ts" }, "title": "Trakt Manager" } diff --git a/extensions/trakt-manager/src/tools/get-lists.ts b/extensions/trakt-manager/src/tools/get-lists.ts index 08476ce8949..be196d85bdc 100644 --- a/extensions/trakt-manager/src/tools/get-lists.ts +++ b/extensions/trakt-manager/src/tools/get-lists.ts @@ -1,7 +1,7 @@ import { TraktListEntry } from "../lib/schema"; import { CompactList, CompactListEntry, toCompactList, toCompactListEntry } from "./compact-media"; import { fetchAllLists, fetchListItems, getOwnList } from "./list-api"; -import { listNameContains, listNameEquals } from "./list-matching"; +import { listNameContains, listNameEquals, resolveListItemQuery } from "./list-matching"; import { isMatchableTitle, partitionByLookup, resolveLookupQuery } from "./title-text"; type ItemType = "movies" | "shows" | "seasons" | "episodes"; @@ -41,9 +41,21 @@ type Input = { itemTraktId?: number; /** * Which item type `itemQuery` / `itemTraktId` targets: "movies", "shows", "seasons" or - * "episodes". Defaults to movies and shows. Seasons and episodes are matched by show title. + * "episodes". Defaults to movies and shows. Seasons and episodes are matched by show title + * (and by `seasonNumber` / `episodeNumber` when supplied). */ itemType?: ItemType; + /** + * Season number for season/episode checks. Other tools expose a show ID + season number, + * not a season Trakt ID β€” pass both here (`itemTraktId` = show ID, `itemType: "seasons"`, + * `seasonNumber`) so membership is exact. Also parsed from `itemQuery` ("Severance season 2"). + */ + seasonNumber?: number; + /** + * Episode number for episode checks. Use with `seasonNumber` and `itemType: "episodes"`. + * Also parsed from `itemQuery` ("Severance S01E03"). + */ + episodeNumber?: number; }; type Output = { @@ -80,8 +92,20 @@ const ENTRY_TYPE: Record = { function entryTitles(entry: TraktListEntry): Array { if (entry.type === "movie") return [entry.movie?.title]; if (entry.type === "show") return [entry.show?.title]; - if (entry.type === "episode") return [entry.show?.title, entry.episode?.title ?? undefined]; - return [entry.show?.title]; + + const show = entry.show?.title; + if (entry.type === "episode" && entry.episode) { + const pad = (n: number) => n.toString().padStart(2, "0"); + const code = `S${pad(entry.episode.season)}E${pad(entry.episode.number)}`; + return [show, entry.episode.title ?? undefined, show ? `${show} ${code}` : code]; + } + + if (entry.type === "season" && entry.season) { + const n = entry.season.number; + return [show, show ? `${show} Season ${n}` : undefined, show ? `${show} (Season ${n})` : undefined]; + } + + return [show]; } function plural(count: number, one: string, many: string): string { @@ -92,7 +116,11 @@ async function checkMembership( list: CompactList, input: Input, ): Promise> { - const { itemQuery, itemYear, itemTraktId, itemType } = input; + const { itemYear, itemTraktId, itemType } = input; + const scoped = resolveListItemQuery(input.itemQuery, input.seasonNumber, input.episodeNumber); + const itemQuery = scoped.text; + const seasonNumber = scoped.seasonNumber; + const episodeNumber = scoped.episodeNumber; if (itemTraktId !== undefined && !itemType) { return { @@ -104,26 +132,83 @@ async function checkMembership( }; } + if (episodeNumber !== undefined && seasonNumber === undefined) { + return { + inList: false, + exhaustive: false, + message: + `Episode ${episodeNumber} needs a \`seasonNumber\` (or an \`itemQuery\` like "Show S01E03") ` + + `before membership can be checked on "${list.name}".`, + }; + } + + if (episodeNumber !== undefined && itemType && itemType !== "episodes") { + return { + inList: false, + exhaustive: false, + message: `\`episodeNumber\` only applies when \`itemType\` is "episodes".`, + }; + } + + if (seasonNumber !== undefined && itemType === "movies") { + return { + inList: false, + exhaustive: false, + message: `\`seasonNumber\` does not apply to movies.`, + }; + } + const lookup = resolveLookupQuery(itemQuery, itemYear); if (itemQuery && itemTraktId === undefined && !isMatchableTitle(lookup.text ?? itemQuery)) { return { inList: false, exhaustive: false, message: - `The title ${JSON.stringify(itemQuery)} cannot be compared: after normalization it has no letters or ` + + `The title ${JSON.stringify(input.itemQuery)} cannot be compared: after normalization it has no letters or ` + `digits. This is NOT a confirmed absence from "${list.name}".`, }; } const fetched = await fetchListItems(list.listId, list.name); - const wantedTypes = itemType ? [ENTRY_TYPE[itemType]] : ["movie", "show"]; - const candidates = fetched.items.filter((entry) => wantedTypes.includes(entry.type)); + const wantedTypes = itemType + ? [ENTRY_TYPE[itemType]] + : seasonNumber !== undefined || episodeNumber !== undefined + ? episodeNumber !== undefined + ? ["episode"] + : ["season", "episode"] + : ["movie", "show"]; + + let candidates = fetched.items.filter((entry) => wantedTypes.includes(entry.type)); const compact = new Map(candidates.map((entry) => [entry, toCompactListEntry(entry)])); + if (seasonNumber !== undefined) { + candidates = candidates.filter((entry) => compact.get(entry)?.seasonNumber === seasonNumber); + } + if (episodeNumber !== undefined) { + candidates = candidates.filter((entry) => compact.get(entry)?.episodeNumber === episodeNumber); + } + + // Write tools address seasons/episodes as showTraktId + numbers. When those are supplied, + // treat a matching parent show ID as an exact hit β€” season Trakt IDs are not what callers have. + const idOf = (entry: TraktListEntry): number => { + const item = compact.get(entry); + if (!item) return 0; + if ( + itemTraktId !== undefined && + seasonNumber !== undefined && + item.showTraktId === itemTraktId && + item.seasonNumber === seasonNumber && + (episodeNumber === undefined || item.episodeNumber === episodeNumber) + ) { + return itemTraktId; + } + return item.traktId; + }; + const pick = partitionByLookup( candidates, entryTitles, - (entry) => compact.get(entry)?.traktId ?? 0, + idOf, (entry) => compact.get(entry)?.year, itemQuery, itemTraktId, @@ -135,7 +220,13 @@ async function checkMembership( const yearHeldBy = toCompact(pick.yearHeldBy); const yearUnknown = toCompact(pick.yearUnknown); const related = toCompact(pick.related); - const target = itemQuery ? `"${itemQuery}"` : `Trakt ID ${itemTraktId}`; + const scope = + episodeNumber !== undefined && seasonNumber !== undefined + ? ` S${String(seasonNumber).padStart(2, "0")}E${String(episodeNumber).padStart(2, "0")}` + : seasonNumber !== undefined + ? ` season ${seasonNumber}` + : ""; + const target = itemQuery ? `"${itemQuery}"${scope}` : `Trakt ID ${itemTraktId}${scope}`; const yearLabel = lookup.year !== undefined && itemQuery ? ` (${lookup.year})` : ""; const inList = exact.length > 0; const exhaustive = fetched.exhaustive && (inList || (yearHeldBy.length === 0 && yearUnknown.length === 0)); diff --git a/extensions/trakt-manager/src/tools/list-api.ts b/extensions/trakt-manager/src/tools/list-api.ts index d2b089e7060..a049fb4c5d3 100644 --- a/extensions/trakt-manager/src/tools/list-api.ts +++ b/extensions/trakt-manager/src/tools/list-api.ts @@ -23,6 +23,8 @@ import { toolTraktClient, } from "./tool-client"; +export { summarizeLabels } from "./list-matching"; + /** Upper bound on items resolved for one add/remove call, so a confirmation stays bounded. */ export const LIST_BATCH_CAP = 50; @@ -247,8 +249,3 @@ export async function resolveListSelection(input: ListSelectionInput): Promise { [1], ); }); + +test("membership queries parse season and episode numbers off the title", () => { + assert.deepEqual(resolveListItemQuery("Severance season 2"), { + text: "Severance", + seasonNumber: 2, + episodeNumber: undefined, + }); + assert.deepEqual(resolveListItemQuery("Severance S01E03"), { + text: "Severance", + seasonNumber: 1, + episodeNumber: 3, + }); + assert.deepEqual(resolveListItemQuery("Severance season 2", 9), { + text: "Severance", + seasonNumber: 9, + episodeNumber: undefined, + }); + assert.deepEqual(resolveListItemQuery("Severance"), { + text: "Severance", + seasonNumber: undefined, + episodeNumber: undefined, + }); +}); + +test("list confirmations name every resolved title", () => { + const labels = ["A", "B", "C", "D", "E", "F"]; + assert.equal(summarizeLabels(labels), "A, B, C, D, E, F"); +}); + +test("season entries match by show title plus season number labels", () => { + const show = { title: "Severance", year: 2022, ids: { trakt: 154784 } }; + const entries = [ + { id: 1, type: "season" as const, show, season: { number: 1, ids: { trakt: 88 } } }, + { id: 2, type: "season" as const, show, season: { number: 2, ids: { trakt: 89 } } }, + ]; + const titlesOf = (entry: (typeof entries)[number]) => { + const n = entry.season.number; + return [entry.show.title, `${entry.show.title} Season ${n}`, `${entry.show.title} (Season ${n})`]; + }; + const season2 = entries.filter((entry) => entry.season.number === 2); + const pick = partitionByLookup( + season2, + titlesOf, + (entry) => entry.season.ids.trakt, + (entry) => entry.show.year, + "Severance", + ); + assert.deepEqual( + pick.exact.map((entry) => entry.season.number), + [2], + ); +}); From b1f526cead7082140bb46cedee32f6a4d02ec6ed Mon Sep 17 00:00:00 2001 From: berenger <85639913+princeberenger@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:57:50 +0200 Subject: [PATCH 3/4] Count only season entries when a list check asks about a season. --- .../trakt-manager/src/tools/get-lists.ts | 17 ++--------------- .../trakt-manager/src/tools/list-matching.ts | 18 ++++++++++++++++++ .../trakt-manager/src/tools/lists.test.ts | 12 ++++++++++++ 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/extensions/trakt-manager/src/tools/get-lists.ts b/extensions/trakt-manager/src/tools/get-lists.ts index be196d85bdc..4dfd9fa6951 100644 --- a/extensions/trakt-manager/src/tools/get-lists.ts +++ b/extensions/trakt-manager/src/tools/get-lists.ts @@ -1,7 +1,7 @@ import { TraktListEntry } from "../lib/schema"; import { CompactList, CompactListEntry, toCompactList, toCompactListEntry } from "./compact-media"; import { fetchAllLists, fetchListItems, getOwnList } from "./list-api"; -import { listNameContains, listNameEquals, resolveListItemQuery } from "./list-matching"; +import { listNameContains, listNameEquals, membershipEntryTypes, resolveListItemQuery } from "./list-matching"; import { isMatchableTitle, partitionByLookup, resolveLookupQuery } from "./title-text"; type ItemType = "movies" | "shows" | "seasons" | "episodes"; @@ -82,13 +82,6 @@ type Output = { totalItems?: number; }; -const ENTRY_TYPE: Record = { - movies: "movie", - shows: "show", - seasons: "season", - episodes: "episode", -}; - function entryTitles(entry: TraktListEntry): Array { if (entry.type === "movie") return [entry.movie?.title]; if (entry.type === "show") return [entry.show?.title]; @@ -170,13 +163,7 @@ async function checkMembership( } const fetched = await fetchListItems(list.listId, list.name); - const wantedTypes = itemType - ? [ENTRY_TYPE[itemType]] - : seasonNumber !== undefined || episodeNumber !== undefined - ? episodeNumber !== undefined - ? ["episode"] - : ["season", "episode"] - : ["movie", "show"]; + const wantedTypes = membershipEntryTypes(itemType, seasonNumber, episodeNumber); let candidates = fetched.items.filter((entry) => wantedTypes.includes(entry.type)); const compact = new Map(candidates.map((entry) => [entry, toCompactListEntry(entry)])); diff --git a/extensions/trakt-manager/src/tools/list-matching.ts b/extensions/trakt-manager/src/tools/list-matching.ts index 38c04fdc4ef..00f18c04785 100644 --- a/extensions/trakt-manager/src/tools/list-matching.ts +++ b/extensions/trakt-manager/src/tools/list-matching.ts @@ -232,6 +232,24 @@ export function resolveListItemQuery( return { text: trimmed, seasonNumber, episodeNumber }; } +/** + * Which list entry types a membership check may count as a hit. A season question only counts + * season entries: an episode of that season on the list is not the season itself. + */ +export function membershipEntryTypes( + itemType: "movies" | "shows" | "seasons" | "episodes" | undefined, + seasonNumber?: number, + episodeNumber?: number, +): string[] { + if (itemType === "movies") return ["movie"]; + if (itemType === "shows") return ["show"]; + if (itemType === "seasons") return ["season"]; + if (itemType === "episodes") return ["episode"]; + if (episodeNumber !== undefined) return ["episode"]; + if (seasonNumber !== undefined) return ["season"]; + return ["movie", "show"]; +} + /** Join every resolved title for confirmations β€” never truncate a batch the user is approving. */ export function summarizeLabels(labels: string[]): string { return labels.join(", "); diff --git a/extensions/trakt-manager/src/tools/lists.test.ts b/extensions/trakt-manager/src/tools/lists.test.ts index 66e5363827e..45392bb8873 100644 --- a/extensions/trakt-manager/src/tools/lists.test.ts +++ b/extensions/trakt-manager/src/tools/lists.test.ts @@ -7,6 +7,7 @@ import { listNameContains, listNameEquals, listNameSimilar, + membershipEntryTypes, parseEpisodeKeys, parseSeasonKeys, parseTraktIds, @@ -26,6 +27,17 @@ test("list names fold case and accents but keep emoji apart", () => { assert.equal(listNameContains("Oscars 2026", ""), false); }); +test("a season membership check never counts an episode of that season", () => { + const season = resolveListItemQuery("Severance season 2"); + assert.deepEqual(membershipEntryTypes(undefined, season.seasonNumber, season.episodeNumber), ["season"]); + + const episode = resolveListItemQuery("Severance S02E03"); + assert.deepEqual(membershipEntryTypes(undefined, episode.seasonNumber, episode.episodeNumber), ["episode"]); + + assert.deepEqual(membershipEntryTypes(undefined), ["movie", "show"]); + assert.deepEqual(membershipEntryTypes("episodes", 2), ["episode"]); +}); + test("list IDs must be a numeric ID or a slug", () => { assert.equal(assertListId("oscars-2026"), "oscars-2026"); assert.equal(assertListId(30481001), "30481001"); From 6b53b0f5db847ee9a3d0d0cbcf9c7db3d2763aae Mon Sep 17 00:00:00 2001 From: raycastbot Date: Wed, 23 Sep 2026 17:01:03 +0000 Subject: [PATCH 4/4] Update CHANGELOG.md --- extensions/trakt-manager/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/trakt-manager/CHANGELOG.md b/extensions/trakt-manager/CHANGELOG.md index 3c25725be84..443694db45d 100644 --- a/extensions/trakt-manager/CHANGELOG.md +++ b/extensions/trakt-manager/CHANGELOG.md @@ -1,6 +1,6 @@ # Trakt Manager Changelog -## [Update] - {PR_MERGE_DATE} +## [Update] - 2026-09-23 ### Added