diff --git a/.agents/rules/jsr.md b/.agents/rules/jsr.md index c2d928e3..41e4bf0c 100644 --- a/.agents/rules/jsr.md +++ b/.agents/rules/jsr.md @@ -43,9 +43,8 @@ annotations - they are load-bearing: `UsersRouter = ReturnType>` with an `as unknown as UsersRouter` cast. Keep the internal route groups (`GLOBAL_LEVEL`, `ENTITY_LEVEL`, `syncs`, `plex`) non-exported. -- The query factories (`extendedQuerySchemaFactory`, `searchTypeParamFactory`, - `trendingSearchTypeParamFactory`) have explicit `z.ZodObject<{...}>` returns - (avoids TS5088 cyclic). +- The query factories (`extendedQuerySchemaFactory`, `searchTypeParamFactory`) + have explicit `z.ZodObject<{...}>` returns (avoids TS5088 cyclic). - `builder` is typed `ReturnType` (avoids TS4023). If a new symbol trips TS7056/TS5088/TS4023 during `build:types`, give it an diff --git a/projects/api/src/contracts/_internal/response/listAddResponseSchema.ts b/projects/api/src/contracts/_internal/response/listAddResponseSchema.ts index eb965798..f9c9309a 100644 --- a/projects/api/src/contracts/_internal/response/listAddResponseSchema.ts +++ b/projects/api/src/contracts/_internal/response/listAddResponseSchema.ts @@ -1,3 +1,4 @@ +import { listSummaryResponseSchema } from './listSummaryResponseSchema.ts'; import { bulkMediaRequestSchema } from '../request/bulkMediaRequestSchema.ts'; import { z } from '../z.ts'; import { mediaMutationCountsSchema } from './mediaMutationCountsSchema.ts'; @@ -7,8 +8,5 @@ export const listAddResponseSchema = z.object({ added: mediaMutationCountsSchema, existing: mediaMutationCountsSchema, not_found: bulkMediaRequestSchema, - list: z.object({ - updated_at: z.string().datetime(), - item_count: z.number().int(), - }), + list: listSummaryResponseSchema, }); diff --git a/projects/api/src/contracts/_internal/response/listRemoveResponseSchema.ts b/projects/api/src/contracts/_internal/response/listRemoveResponseSchema.ts index 412edab2..0fce3d38 100644 --- a/projects/api/src/contracts/_internal/response/listRemoveResponseSchema.ts +++ b/projects/api/src/contracts/_internal/response/listRemoveResponseSchema.ts @@ -1,3 +1,4 @@ +import { listSummaryResponseSchema } from './listSummaryResponseSchema.ts'; import { bulkMediaRequestSchema } from '../request/bulkMediaRequestSchema.ts'; import { z } from '../z.ts'; import { mediaMutationCountsSchema } from './mediaMutationCountsSchema.ts'; @@ -6,8 +7,5 @@ import { mediaMutationCountsSchema } from './mediaMutationCountsSchema.ts'; export const listRemoveResponseSchema = z.object({ deleted: mediaMutationCountsSchema, not_found: bulkMediaRequestSchema, - list: z.object({ - updated_at: z.string().datetime(), - item_count: z.number().int(), - }), + list: listSummaryResponseSchema, }); diff --git a/projects/api/src/contracts/_internal/response/listSummaryResponseSchema.ts b/projects/api/src/contracts/_internal/response/listSummaryResponseSchema.ts new file mode 100644 index 00000000..a8e5cc18 --- /dev/null +++ b/projects/api/src/contracts/_internal/response/listSummaryResponseSchema.ts @@ -0,0 +1,7 @@ +import { z } from '../z.ts'; + +/** Zod schema for the list summary returned after a list item change. */ +export const listSummaryResponseSchema = z.object({ + updated_at: z.string().datetime(), + item_count: z.number().int(), +}); diff --git a/projects/api/src/contracts/_internal/response/movieRatingsResponseSchema.ts b/projects/api/src/contracts/_internal/response/movieRatingsResponseSchema.ts index eceb8413..8f372ff2 100644 --- a/projects/api/src/contracts/_internal/response/movieRatingsResponseSchema.ts +++ b/projects/api/src/contracts/_internal/response/movieRatingsResponseSchema.ts @@ -1,7 +1,6 @@ -import { z } from '../z.ts'; import { extendedRatingsResponseExample, - externalRatingsResponseSchema, + externalVotedRatingsResponseSchema, ratingsResponseExample, ratingsResponseSchema, } from './ratingsResponseSchema.ts'; @@ -15,16 +14,12 @@ export const movieRatingsResponseSchema = ratingsResponseSchema.extend({ * Letterboxd audience rating on a 0-5 scale. Films only. * Available if requesting extended `all`. */ - letterboxd: externalRatingsResponseSchema.extend({ - votes: z.number().int().nullish(), - }).nullish(), + letterboxd: externalVotedRatingsResponseSchema.nullish(), /** * MyAnimeList audience rating on a 0-10 scale. Anime only. * Available if requesting extended `all`. */ - mal: externalRatingsResponseSchema.extend({ - votes: z.number().int().nullish(), - }).nullish(), + mal: externalVotedRatingsResponseSchema.nullish(), }).openapi({ mediaExamples: { default: { diff --git a/projects/api/src/contracts/_internal/response/ratingsResponseSchema.ts b/projects/api/src/contracts/_internal/response/ratingsResponseSchema.ts index 7a0bc91a..67ac958c 100644 --- a/projects/api/src/contracts/_internal/response/ratingsResponseSchema.ts +++ b/projects/api/src/contracts/_internal/response/ratingsResponseSchema.ts @@ -66,6 +66,12 @@ export const externalRatingsResponseSchema = z.object({ link: z.string().nullish(), }); +/** Shared shape for an external rating source that also reports its vote count. */ +export const externalVotedRatingsResponseSchema = externalRatingsResponseSchema + .extend({ + votes: z.number().int().nullish(), + }); + /** Zod schema for the ratings response. */ export const ratingsResponseSchema = z.object({ rating: float(z.number()).nullish(), @@ -79,15 +85,11 @@ export const ratingsResponseSchema = z.object({ /** * Available if requesting extended `all`. */ - tmdb: externalRatingsResponseSchema.extend({ - votes: z.number().int().nullish(), - }).nullish(), + tmdb: externalVotedRatingsResponseSchema.nullish(), /** * Available if requesting extended `all`. */ - imdb: externalRatingsResponseSchema.extend({ - votes: z.number().int().nullish(), - }).nullish(), + imdb: externalVotedRatingsResponseSchema.nullish(), /** * Available if requesting extended `all`. */ diff --git a/projects/api/src/contracts/_internal/response/showRatingsResponseSchema.ts b/projects/api/src/contracts/_internal/response/showRatingsResponseSchema.ts index 1c754d27..2601f3db 100644 --- a/projects/api/src/contracts/_internal/response/showRatingsResponseSchema.ts +++ b/projects/api/src/contracts/_internal/response/showRatingsResponseSchema.ts @@ -1,7 +1,6 @@ -import { z } from '../z.ts'; import { extendedRatingsResponseExample, - externalRatingsResponseSchema, + externalVotedRatingsResponseSchema, ratingsResponseExample, ratingsResponseSchema, } from './ratingsResponseSchema.ts'; @@ -15,9 +14,7 @@ export const showRatingsResponseSchema = ratingsResponseSchema.extend({ * MyAnimeList audience rating on a 0-10 scale. Anime only. * Available if requesting extended `all`. */ - mal: externalRatingsResponseSchema.extend({ - votes: z.number().int().nullish(), - }).nullish(), + mal: externalVotedRatingsResponseSchema.nullish(), }).openapi({ mediaExamples: { default: { diff --git a/projects/api/src/contracts/calendars/index.ts b/projects/api/src/contracts/calendars/index.ts index 03410f52..6c2a8b5c 100644 --- a/projects/api/src/contracts/calendars/index.ts +++ b/projects/api/src/contracts/calendars/index.ts @@ -15,6 +15,20 @@ const groupQuery = z.object({ }), }); +const filteredCalendarQuerySchema = extendedMediaQuerySchema + .merge(mediaFilterParamsSchema) + .merge(ignoreQuerySchema); + +const mediaFeedQuerySchema = extendedMediaQuerySchema + .merge(mediaFilterParamsSchema) + .merge(z.object({ + type: z.enum(['movie', 'show']).optional().openapi({ + description: + 'Narrow the feed to a single media type. Omit to return both.', + }), + })) + .merge(groupQuery); + const globalCalendarCurationNotice = 'When `target` is `all`, the global calendar uses automatic quality filters to reduce incorrect or low-quality entries. As a result, it may not include every matching item in the database, even when that item has complete information. An omitted item may appear later after it meets the filter requirements.'; @@ -45,9 +59,7 @@ Returns new shows airing their first season during the requested UTC date range. ${globalCalendarCurationNotice}`, method: 'GET', path: '/:target/shows/new/:start_date/:days', - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(ignoreQuerySchema), + query: filteredCalendarQuerySchema, pathParams: calendarRequestParamsSchema, responses: { 200: calendarShowResponseSchema.array(), @@ -61,9 +73,7 @@ Returns season premieres airing during the requested UTC date range. Use \`targe ${globalCalendarCurationNotice}`, method: 'GET', path: '/:target/shows/premieres/:start_date/:days', - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(ignoreQuerySchema), + query: filteredCalendarQuerySchema, pathParams: calendarRequestParamsSchema, responses: { 200: calendarShowResponseSchema.array(), @@ -77,9 +87,7 @@ Returns show finales airing during the requested UTC date range. Use \`target\` ${globalCalendarCurationNotice}`, method: 'GET', path: '/:target/shows/finales/:start_date/:days', - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(ignoreQuerySchema), + query: filteredCalendarQuerySchema, pathParams: calendarRequestParamsSchema, responses: { 200: calendarShowResponseSchema.array(), @@ -93,9 +101,7 @@ Returns movies with a release date during the requested UTC date range. Use \`ta ${globalCalendarCurationNotice}`, method: 'GET', path: '/:target/movies/:start_date/:days', - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(ignoreQuerySchema), + query: filteredCalendarQuerySchema, pathParams: calendarRequestParamsSchema, responses: { 200: calendarMovieResponseSchema.array(), @@ -109,9 +115,7 @@ Returns all movies with a streaming release date during the requested UTC date r ${globalCalendarCurationNotice}`, method: 'GET', path: '/:target/streaming/:start_date/:days', - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(ignoreQuerySchema), + query: filteredCalendarQuerySchema, pathParams: calendarRequestParamsSchema, responses: { 200: calendarMovieResponseSchema.array(), @@ -125,9 +129,7 @@ Returns DVD and physical media releases during the requested UTC date range. Use ${globalCalendarCurationNotice}`, method: 'GET', path: '/:target/dvd/:start_date/:days', - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(ignoreQuerySchema), + query: filteredCalendarQuerySchema, pathParams: calendarRequestParamsSchema, responses: { 200: calendarMovieResponseSchema.array(), @@ -141,15 +143,7 @@ Returns the merged feed of movies and episodes during the requested UTC date ran ${globalCalendarCurationNotice}`, method: 'GET', path: '/:target/media/:start_date/:days', - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(z.object({ - type: z.enum(['movie', 'show']).optional().openapi({ - description: - 'Narrow the feed to a single media type. Omit to return both.', - }), - })) - .merge(groupQuery), + query: mediaFeedQuerySchema, pathParams: calendarRequestParamsSchema, responses: { 200: hotReleaseResponseSchema.array(), @@ -161,15 +155,7 @@ ${globalCalendarCurationNotice}`, Returns the merged feed of upcoming movies and episodes during the requested UTC date range that are trending or highly anticipated, ordered by availability date. This is the global feed only; use \`type\` to narrow to a single media type.`, method: 'GET', path: '/releases/hot/:start_date/:days', - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(z.object({ - type: z.enum(['movie', 'show']).optional().openapi({ - description: - 'Narrow the feed to a single media type. Omit to return both.', - }), - })) - .merge(groupQuery), + query: mediaFeedQuerySchema, pathParams: calendarRequestParamsSchema.omit({ target: true }), responses: { 200: hotReleaseResponseSchema.array(), diff --git a/projects/api/src/contracts/comments/index.ts b/projects/api/src/contracts/comments/index.ts index 489413d3..30b2b627 100644 --- a/projects/api/src/contracts/comments/index.ts +++ b/projects/api/src/contracts/comments/index.ts @@ -32,6 +32,11 @@ const includeRepliesQuerySchema = z.object({ ), }); +const commentActivityQuerySchema = extendedProfileQuerySchema + .merge(pageQuerySchema) + .merge(limitlessQuerySchema) + .merge(includeRepliesQuerySchema); + const commentItemResponseSchema = z.union([ movieResponseSchema, showResponseSchema, @@ -277,10 +282,7 @@ const GLOBAL_LEVEL = builder.router({ path: '/trending/:comment_type/:type', method: 'GET', pathParams: commentActivityParamsSchema, - query: extendedProfileQuerySchema - .merge(pageQuerySchema) - .merge(limitlessQuerySchema) - .merge(includeRepliesQuerySchema), + query: commentActivityQuerySchema, responses: { 200: commentResponseSchema.array(), }, @@ -292,10 +294,7 @@ const GLOBAL_LEVEL = builder.router({ path: '/recent/:comment_type/:type', method: 'GET', pathParams: commentActivityParamsSchema, - query: extendedProfileQuerySchema - .merge(pageQuerySchema) - .merge(limitlessQuerySchema) - .merge(includeRepliesQuerySchema), + query: commentActivityQuerySchema, responses: { 200: commentResponseSchema.array(), }, @@ -307,10 +306,7 @@ const GLOBAL_LEVEL = builder.router({ path: '/updates/:comment_type/:type', method: 'GET', pathParams: commentActivityParamsSchema, - query: extendedProfileQuerySchema - .merge(pageQuerySchema) - .merge(limitlessQuerySchema) - .merge(includeRepliesQuerySchema), + query: commentActivityQuerySchema, responses: { 200: commentResponseSchema.array(), }, diff --git a/projects/api/src/contracts/lists/index.ts b/projects/api/src/contracts/lists/index.ts index 1875346a..2f0d99be 100644 --- a/projects/api/src/contracts/lists/index.ts +++ b/projects/api/src/contracts/lists/index.ts @@ -21,6 +21,17 @@ import { z } from '../_internal/z.ts'; import { listReportRequestSchema } from './schema/listReportRequestSchema.ts'; import { prominentListResponseSchema } from './schema/prominentListResponseSchema.ts'; +const listItemsQuerySchema = extendedMediaQuerySchema + .merge(sortQuerySchema) + .merge(mediaFilterParamsSchema) + .merge(ignoreQuerySchema) + .merge(pageQuerySchema) + .merge(limitlessQuerySchema); + +const filteredListsQuerySchema = extendedQuerySchemaFactory<['full']>() + .merge(pageQuerySchema) + .merge(mediaFilterParamsSchema); + const listItemsPathParamsSchema = idParamsSchema.extend({ type: z.string().describe('List item type filter.'), sort_by: z.string().describe('Sort by a specific property.'), @@ -61,12 +72,7 @@ Returns movie items on a public list. Use \`id\` to identify the list and query path: '/items/movie', method: 'GET', pathParams: idParamsSchema, - query: extendedMediaQuerySchema - .merge(sortQuerySchema) - .merge(mediaFilterParamsSchema) - .merge(ignoreQuerySchema) - .merge(pageQuerySchema) - .merge(limitlessQuerySchema), + query: listItemsQuerySchema, responses: { 200: listedMovieResponseSchema.array(), }, @@ -78,12 +84,7 @@ Returns show items on a public list. Use \`id\` to identify the list and query s path: '/items/show', method: 'GET', pathParams: idParamsSchema, - query: extendedMediaQuerySchema - .merge(sortQuerySchema) - .merge(mediaFilterParamsSchema) - .merge(ignoreQuerySchema) - .merge(pageQuerySchema) - .merge(limitlessQuerySchema), + query: listItemsQuerySchema, responses: { 200: listedShowResponseSchema.array(), }, @@ -95,12 +96,7 @@ Returns movie and show items on a public list. Use \`id\` to identify the list a path: '/items/movie,show', method: 'GET', pathParams: idParamsSchema, - query: extendedMediaQuerySchema - .merge(sortQuerySchema) - .merge(mediaFilterParamsSchema) - .merge(ignoreQuerySchema) - .merge(pageQuerySchema) - .merge(limitlessQuerySchema), + query: listItemsQuerySchema, responses: { 200: listedMediaResponseSchema.array(), }, @@ -112,12 +108,7 @@ Returns movie, show, episode, and season items on a public list. Use \`id\` to i path: '/items/movie,show,episode,season', method: 'GET', pathParams: idParamsSchema, - query: extendedMediaQuerySchema - .merge(sortQuerySchema) - .merge(mediaFilterParamsSchema) - .merge(ignoreQuerySchema) - .merge(pageQuerySchema) - .merge(limitlessQuerySchema), + query: listItemsQuerySchema, responses: { 200: listedAllResponseSchema.array(), }, @@ -229,9 +220,7 @@ const GLOBAL_LEVEL = builder.router({ Returns trending lists ordered by current activity. Use pagination and filters to control the result set.`, path: '/trending', method: 'GET', - query: extendedQuerySchemaFactory<['full']>() - .merge(pageQuerySchema) - .merge(mediaFilterParamsSchema), + query: filteredListsQuerySchema, responses: { 200: prominentListResponseSchema.array(), }, @@ -243,9 +232,7 @@ Returns trending lists ordered by current activity. Use \`type\`, pagination, an path: '/trending/:type', method: 'GET', pathParams: listTypePathParamsSchema, - query: extendedQuerySchemaFactory<['full']>() - .merge(pageQuerySchema) - .merge(mediaFilterParamsSchema), + query: filteredListsQuerySchema, responses: { 200: prominentListResponseSchema.array(), }, @@ -256,9 +243,7 @@ Returns trending lists ordered by current activity. Use \`type\`, pagination, an Returns popular lists ordered by long-term activity. Use pagination and filters to control the result set.`, path: '/popular', method: 'GET', - query: extendedQuerySchemaFactory<['full']>() - .merge(pageQuerySchema) - .merge(mediaFilterParamsSchema), + query: filteredListsQuerySchema, responses: { 200: prominentListResponseSchema.array(), }, @@ -270,9 +255,7 @@ Returns popular lists ordered by long-term activity. Use \`type\`, pagination, a path: '/popular/:type', method: 'GET', pathParams: listTypePathParamsSchema, - query: extendedQuerySchemaFactory<['full']>() - .merge(pageQuerySchema) - .merge(mediaFilterParamsSchema), + query: filteredListsQuerySchema, responses: { 200: prominentListResponseSchema.array(), }, diff --git a/projects/api/src/contracts/movies/index.ts b/projects/api/src/contracts/movies/index.ts index eb5bd3af..7a9b389d 100644 --- a/projects/api/src/contracts/movies/index.ts +++ b/projects/api/src/contracts/movies/index.ts @@ -49,6 +49,21 @@ import { movieStreamingResponseSchema } from './schema/response/movieStreamingRe import { movieTrendingResponseSchema } from './schema/response/movieTrendingResponseSchema.ts'; import { movieWatchedResponseSchema } from './schema/response/movieWatchedResponseSchema.ts'; +const themedFilteredMediaQuerySchema = extendedMediaQuerySchema + .merge(mediaFilterParamsSchema) + .merge(themeQuerySchema) + .merge(pageQuerySchema) + .merge(ignoreQuerySchema); + +const pagedMediaQuerySchema = extendedMediaQuerySchema + .merge(pageQuerySchema) + .merge(ignoreQuerySchema); + +const filteredMediaQuerySchema = extendedMediaQuerySchema + .merge(mediaFilterParamsSchema) + .merge(pageQuerySchema) + .merge(ignoreQuerySchema); + const startDateParamsSchema = z.object({ start_date: z.string().describe('UTC date to start checking for updates.'), }); @@ -339,11 +354,7 @@ const GLOBAL_LEVEL = builder.router({ Returns the most watched movies over the last 24 hours. Movies with the most \`watchers\` are returned first.`, path: '/trending', method: 'GET', - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(themeQuerySchema) - .merge(pageQuerySchema) - .merge(ignoreQuerySchema), + query: themedFilteredMediaQuerySchema, responses: { 200: movieTrendingResponseSchema.array(), }, @@ -355,9 +366,7 @@ Returns the most watched movies over the last 24 hours. Movies with the most \`w Returns the most watched (unique users) movies in the specified time \`period\`, defaulting to \`weekly\`. All stats are relative to the specific time \`period\`.`, path: '/watched/:period', method: 'GET', - query: extendedMediaQuerySchema - .merge(pageQuerySchema) - .merge(ignoreQuerySchema), + query: pagedMediaQuerySchema, pathParams: periodParamsSchema, responses: { 200: movieWatchedResponseSchema.array(), @@ -370,9 +379,7 @@ Returns the most watched (unique users) movies in the specified time \`period\`, Returns the most favorited movies in the specified time \`period\`, defaulting to \`weekly\`.`, path: '/favorited/:period', method: 'GET', - query: extendedMediaQuerySchema - .merge(pageQuerySchema) - .merge(ignoreQuerySchema), + query: pagedMediaQuerySchema, pathParams: periodParamsSchema, responses: { 200: movieFavoritedResponseSchema.array(), @@ -385,9 +392,7 @@ Returns the most favorited movies in the specified time \`period\`, defaulting t Returns the most played movies in the specified time \`period\`, defaulting to \`weekly\`.`, path: '/played/:period', method: 'GET', - query: extendedMediaQuerySchema - .merge(pageQuerySchema) - .merge(ignoreQuerySchema), + query: pagedMediaQuerySchema, pathParams: periodParamsSchema, responses: { 200: movieWatchedResponseSchema.array(), @@ -400,9 +405,7 @@ Returns the most played movies in the specified time \`period\`, defaulting to \ Returns the most collected movies in the specified time \`period\`, defaulting to \`weekly\`.`, path: '/collected/:period', method: 'GET', - query: extendedMediaQuerySchema - .merge(pageQuerySchema) - .merge(ignoreQuerySchema), + query: pagedMediaQuerySchema, pathParams: periodParamsSchema, responses: { 200: movieWatchedResponseSchema.array(), @@ -451,11 +454,7 @@ Returns the most collected movies in the specified time \`period\`, defaulting t Returns the most anticipated movies based on the number of lists a movie appears on.`, path: '/anticipated', method: 'GET', - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(themeQuerySchema) - .merge(pageQuerySchema) - .merge(ignoreQuerySchema), + query: themedFilteredMediaQuerySchema, responses: { 200: movieAnticipatedResponseSchema.array(), }, @@ -466,10 +465,7 @@ Returns the most anticipated movies based on the number of lists a movie appears Returns movies that are currently hot on Trakt. Results can be filtered by media fields or ignored user state.`, path: '/hot', method: 'GET', - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(pageQuerySchema) - .merge(ignoreQuerySchema), + query: filteredMediaQuerySchema, responses: { 200: movieHotResponseSchema.array(), }, @@ -481,11 +477,7 @@ Returns movies that are currently hot on Trakt. Results can be filtered by media Returns the most popular movies. Popularity is calculated using the rating percentage and the number of ratings.`, path: '/popular', method: 'GET', - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(themeQuerySchema) - .merge(pageQuerySchema) - .merge(ignoreQuerySchema), + query: themedFilteredMediaQuerySchema, responses: { 200: movieResponseSchema.array(), }, @@ -497,10 +489,7 @@ Returns movies recently available on streaming services for the requested \`peri path: '/streaming/:period', method: 'GET', pathParams: recentPeriodParamsSchema, - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(pageQuerySchema) - .merge(ignoreQuerySchema), + query: filteredMediaQuerySchema, responses: { 200: movieStreamingResponseSchema.array(), }, diff --git a/projects/api/src/contracts/scrobble/index.ts b/projects/api/src/contracts/scrobble/index.ts index ef197f4c..5d1640d1 100644 --- a/projects/api/src/contracts/scrobble/index.ts +++ b/projects/api/src/contracts/scrobble/index.ts @@ -5,6 +5,16 @@ import { movieScrobbleRequestSchema } from './schema/request/movieScrobbleReques import { episodeScrobbleResponseSchema } from './schema/response/episodeScrobbleResponseSchema.ts'; import { movieScrobbleResponseSchema } from './schema/response/movieScrobbleResponseSchema.ts'; +const scrobbleRequestSchema = z.union([ + movieScrobbleRequestSchema, + episodeScrobbleRequestSchema, +]); + +const scrobbleResponseSchema = z.union([ + movieScrobbleResponseSchema, + episodeScrobbleResponseSchema, +]); + /** ts-rest contract for the `scrobble` endpoints. */ export const scrobble = builder.router({ start: { @@ -22,12 +32,9 @@ Use this method when the video initially starts playing or is unpaused. This wil | \`progress\` * | float | Progress percentage between 0 and 100. |`, path: '/start', method: 'POST', - body: z.union([movieScrobbleRequestSchema, episodeScrobbleRequestSchema]), + body: scrobbleRequestSchema, responses: { - 201: z.union([ - movieScrobbleResponseSchema, - episodeScrobbleResponseSchema, - ]), + 201: scrobbleResponseSchema, }, }, pause: { @@ -36,12 +43,9 @@ Use this method when the video initially starts playing or is unpaused. This wil Pause an active scrobble for a movie or episode. Send the media object and current progress in the request body; the response returns the updated scrobble state.`, path: '/pause', method: 'POST', - body: z.union([movieScrobbleRequestSchema, episodeScrobbleRequestSchema]), + body: scrobbleRequestSchema, responses: { - 201: z.union([ - movieScrobbleResponseSchema, - episodeScrobbleResponseSchema, - ]), + 201: scrobbleResponseSchema, }, }, stop: { @@ -64,12 +68,9 @@ If the progress is between 1% and 79%, it will be treated as a *pause* and the \ > _If the same item was just scrobbled, a \`409\` HTTP status code will returned to avoid scrobbling a duplicate. The response will contain a \`watched_at\` timestamp when the item was last scrobbled and a \`expires_at\` timestamp when the item can be scrobbled again._`, path: '/stop', method: 'POST', - body: z.union([movieScrobbleRequestSchema, episodeScrobbleRequestSchema]), + body: scrobbleRequestSchema, responses: { - 201: z.union([ - movieScrobbleResponseSchema, - episodeScrobbleResponseSchema, - ]), + 201: scrobbleResponseSchema, }, }, }, { diff --git a/projects/api/src/contracts/search/index.ts b/projects/api/src/contracts/search/index.ts index ea986ad8..673f9804 100644 --- a/projects/api/src/contracts/search/index.ts +++ b/projects/api/src/contracts/search/index.ts @@ -5,7 +5,6 @@ import { z } from '../_internal/z.ts'; import { recentSearchRequestSchema } from './schema/request/recentSearchRequestSchema.ts'; import { searchQuerySchema } from './schema/request/searchQuerySchema.ts'; import { searchTypeParamFactory } from './schema/request/searchTypeParamFactory.ts'; -import { trendingSearchTypeParamFactory } from './schema/request/trendingSearchTypeParamFactory.ts'; import type { searchMovieResponseSchema } from './schema/response/searchMovieResponseSchema.ts'; import type { searchPersonResponseSchema } from './schema/response/searchPersonResponseSchema.ts'; import { searchResultResponseSchema } from './schema/response/searchResultResponseSchema.ts'; @@ -140,9 +139,7 @@ Lookup items by external ID. Use \`id_type\` and \`id\` to identify the external Returns globally trending recent searches by \`type\`. Use \`query\` to narrow the search text and pagination to move through the result set.`, path: '/recent_by_id/global/:type', method: 'GET', - pathParams: trendingSearchTypeParamFactory< - ['movies', 'shows', 'people'] - >(), + pathParams: searchTypeParamFactory(['movies', 'shows', 'people']), query: pageQuerySchema .merge(searchQuerySchema) .merge( diff --git a/projects/api/src/contracts/search/schema/request/trendingSearchTypeParamFactory.ts b/projects/api/src/contracts/search/schema/request/trendingSearchTypeParamFactory.ts deleted file mode 100644 index 48d1f573..00000000 --- a/projects/api/src/contracts/search/schema/request/trendingSearchTypeParamFactory.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { CombinationsFrom } from '../../../../types/CombinationsFrom.ts'; -import { z } from '../../../_internal/z.ts'; - -/** Trending search type param factory. */ -export const trendingSearchTypeParamFactory = < - T extends string[], ->(): z.ZodObject<{ - type: z.ZodOptional>>>; -}> => - z.object({ - type: z - .custom>() - .nullish() - .openapi({ - description: - 'Specify the type of results by sending a single value or a comma delimited string for multiple types.', - enum: ['movies', 'shows', 'people'], - }), - }); diff --git a/projects/api/src/contracts/search/schema/response/searchListResponseSchema.ts b/projects/api/src/contracts/search/schema/response/searchListResponseSchema.ts deleted file mode 100644 index 1851a8ab..00000000 --- a/projects/api/src/contracts/search/schema/response/searchListResponseSchema.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { int64, z } from '../../../_internal/z.ts'; -import { listResponseSchema } from '../../../models/index.ts'; - -/** Zod schema for the search list response. */ -export const searchListResponseSchema = z.object({ - score: int64(z.number().int()), - type: z.literal('list'), - list: listResponseSchema.nullish(), -}); diff --git a/projects/api/src/contracts/shows/index.ts b/projects/api/src/contracts/shows/index.ts index 922969d9..98cb0fcd 100644 --- a/projects/api/src/contracts/shows/index.ts +++ b/projects/api/src/contracts/shows/index.ts @@ -53,6 +53,28 @@ import { showStreamingResponseSchema } from './schema/response/showStreamingResp import { showTrendingResponseSchema } from './schema/response/showTrendingResponseSchema.ts'; import { showWatchedResponseSchema } from './schema/response/showWatchedResponseSchema.ts'; +const episodePathParamsSchema = idParamsSchema + .merge(seasonParamsSchema) + .merge(episodeParamsSchema); + +const seasonPathParamsSchema = idParamsSchema.merge(seasonParamsSchema); + +const filteredMediaQuerySchema = extendedMediaQuerySchema + .merge(mediaFilterParamsSchema) + .merge(pageQuerySchema) + .merge(ignoreQuerySchema); + +const themedFilteredMediaQuerySchema = extendedMediaQuerySchema + .merge(mediaFilterParamsSchema) + .merge(themeQuerySchema) + .merge(pageQuerySchema) + .merge(ignoreQuerySchema); + +const commentsPageQuerySchema = extendedProfileQuerySchema + .merge(pageQuerySchema) + .merge(limitlessQuerySchema) + .merge(languageQuerySchema); + const startDateParamsSchema = z.object({ start_date: z.string().describe('UTC date to start checking for updates.'), }); @@ -81,9 +103,7 @@ Returns a single episode's details. All date and times are in UTC and were calcu path: '', method: 'GET', query: extendedMediaQuerySchema, - pathParams: idParamsSchema - .merge(seasonParamsSchema) - .merge(episodeParamsSchema), + pathParams: episodePathParamsSchema, responses: { 200: episodeResponseSchema, }, @@ -94,9 +114,7 @@ Returns a single episode's details. All date and times are in UTC and were calcu 'Returns all translations for an episode, including language, country, and translated values for title and overview. The `country` field can be used together with `language` to identify regional variants (for example `fr`/`fr` vs `fr`/`ca`). Use the optional `language` query param to filter to a single language.', path: '/translations', method: 'GET', - pathParams: idParamsSchema - .merge(seasonParamsSchema) - .merge(episodeParamsSchema), + pathParams: episodePathParamsSchema, query: translationsLanguageQuerySchema, responses: { 200: episodeTranslationResponseSchema, @@ -107,9 +125,7 @@ Returns a single episode's details. All date and times are in UTC and were calcu description: 'Returns lots of episode stats.', path: '/stats', method: 'GET', - pathParams: idParamsSchema - .merge(seasonParamsSchema) - .merge(episodeParamsSchema), + pathParams: episodePathParamsSchema, responses: { 200: episodeStatsResponseSchema, }, @@ -123,9 +139,7 @@ Use \`?extended=all\` to include ratings from TMDB, IMDb, Metascore, and Rotten path: '/ratings', method: 'GET', query: extendedRatingsQuerySchema, - pathParams: idParamsSchema - .merge(seasonParamsSchema) - .merge(episodeParamsSchema), + pathParams: episodePathParamsSchema, responses: { 200: ratingsResponseSchema, }, @@ -136,9 +150,7 @@ Use \`?extended=all\` to include ratings from TMDB, IMDb, Metascore, and Rotten Returns all users watching this episode right now.`, path: '/watching', method: 'GET', - pathParams: idParamsSchema - .merge(seasonParamsSchema) - .merge(episodeParamsSchema), + pathParams: episodePathParamsSchema, query: extendedQuerySchemaFactory<['full']>(), responses: { 200: profileResponseSchema.array(), @@ -158,10 +170,7 @@ Returns all top level comments for an episode. By default, comments are sorted b .merge(seasonParamsSchema) .merge(episodeParamsSchema) .merge(commentsSortParamsSchema), - query: extendedProfileQuerySchema - .merge(pageQuerySchema) - .merge(limitlessQuerySchema) - .merge(languageQuerySchema), + query: commentsPageQuerySchema, responses: { 200: commentResponseSchema.array(), }, @@ -184,9 +193,7 @@ If you add \`?extended=markdown\` to the URL, person biographies include links t path: '/people', method: 'GET', query: extendedQuerySchemaFactory<['images', 'guest_stars', 'markdown']>(), - pathParams: idParamsSchema - .merge(seasonParamsSchema) - .merge(episodeParamsSchema), + pathParams: episodePathParamsSchema, responses: { 200: peopleResponseSchema, }, @@ -215,9 +222,7 @@ Returns all lists that contain this episode. By default, \`personal\` lists are Returns all videos including trailers, teasers, clips, and featurettes.`, path: '/videos', method: 'GET', - pathParams: idParamsSchema - .merge(seasonParamsSchema) - .merge(episodeParamsSchema), + pathParams: episodePathParamsSchema, responses: { 200: videoResponseSchema.array(), }, @@ -258,9 +263,7 @@ Report an episode for moderator review. Send a \`reason\` and optional \`message | \`other\` | Anything else (add details in \`message\`) |`, path: '/report', method: 'POST', - pathParams: idParamsSchema - .merge(seasonParamsSchema) - .merge(episodeParamsSchema), + pathParams: episodePathParamsSchema, body: mediaReportRequestSchema, responses: { 201: z.undefined(), @@ -568,8 +571,7 @@ If you add \`?extended=episodes\` to the URL, it will return all episodes for al path: '/info', method: 'GET', query: extendedMediaQuerySchema, - pathParams: idParamsSchema - .merge(seasonParamsSchema), + pathParams: seasonPathParamsSchema, responses: { 200: seasonResponseSchema, }, @@ -587,8 +589,7 @@ If you'd like to included translated episode titles and overviews in the respons path: '', method: 'GET', query: extendedMediaQuerySchema, - pathParams: idParamsSchema - .merge(seasonParamsSchema), + pathParams: seasonPathParamsSchema, responses: { 200: episodeResponseSchema.array(), }, @@ -599,8 +600,7 @@ If you'd like to included translated episode titles and overviews in the respons 'Returns all translations for a season, including language, country, and translated title and overview values. Use the optional `language` query param to filter to a single language.', path: '/translations', method: 'GET', - pathParams: idParamsSchema - .merge(seasonParamsSchema), + pathParams: seasonPathParamsSchema, query: translationsLanguageQuerySchema, responses: { 200: translationResponseSchema, @@ -616,10 +616,7 @@ Returns all top level comments for a season. By default, comments are sorted by pathParams: idParamsSchema .merge(seasonParamsSchema) .merge(commentsSortParamsSchema), - query: extendedProfileQuerySchema - .merge(pageQuerySchema) - .merge(limitlessQuerySchema) - .merge(languageQuerySchema), + query: commentsPageQuerySchema, responses: { 200: commentResponseSchema.array(), }, @@ -648,8 +645,7 @@ Returns all lists that contain this season. By default, \`personal\` lists are r path: '/people', method: 'GET', query: extendedQuerySchemaFactory<['images', 'markdown']>(), - pathParams: idParamsSchema - .merge(seasonParamsSchema), + pathParams: seasonPathParamsSchema, responses: { 200: peopleResponseSchema, }, @@ -663,8 +659,7 @@ Use \`?extended=all\` to include ratings from TMDB, IMDb, Metascore, and Rotten path: '/ratings', method: 'GET', query: extendedRatingsQuerySchema, - pathParams: idParamsSchema - .merge(seasonParamsSchema), + pathParams: seasonPathParamsSchema, responses: { 200: ratingsResponseSchema, }, @@ -674,8 +669,7 @@ Use \`?extended=all\` to include ratings from TMDB, IMDb, Metascore, and Rotten description: 'Returns lots of season stats.', path: '/stats', method: 'GET', - pathParams: idParamsSchema - .merge(seasonParamsSchema), + pathParams: seasonPathParamsSchema, responses: { 200: episodeStatsResponseSchema, }, @@ -686,8 +680,7 @@ Use \`?extended=all\` to include ratings from TMDB, IMDb, Metascore, and Rotten Returns all users watching this season right now.`, path: '/watching', method: 'GET', - pathParams: idParamsSchema - .merge(seasonParamsSchema), + pathParams: seasonPathParamsSchema, query: extendedQuerySchemaFactory<['full']>(), responses: { 200: profileResponseSchema.array(), @@ -699,8 +692,7 @@ Returns all users watching this season right now.`, Returns all videos including trailers, teasers, clips, and featurettes.`, path: '/videos', method: 'GET', - pathParams: idParamsSchema - .merge(seasonParamsSchema), + pathParams: seasonPathParamsSchema, responses: { 200: videoResponseSchema.array(), }, @@ -737,7 +729,7 @@ Report a season for moderator review. Send a \`reason\` and optional \`message\` | \`other\` | Anything else (add details in \`message\`) |`, path: '/report', method: 'POST', - pathParams: idParamsSchema.merge(seasonParamsSchema), + pathParams: seasonPathParamsSchema, body: mediaReportRequestSchema, responses: { 201: z.undefined(), @@ -787,10 +779,7 @@ Returns all top level comments for a show. By default, comments are sorted by mo method: 'GET', pathParams: idParamsSchema .merge(commentsSortParamsSchema), - query: extendedProfileQuerySchema - .merge(pageQuerySchema) - .merge(limitlessQuerySchema) - .merge(languageQuerySchema), + query: commentsPageQuerySchema, responses: { 200: commentResponseSchema.array(), }, @@ -846,11 +835,7 @@ const GLOBAL_LEVEL = builder.router({ Returns the most watched shows over the last 24 hours. Shows with the most \`watchers\` are returned first.`, path: '/trending', method: 'GET', - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(themeQuerySchema) - .merge(pageQuerySchema) - .merge(ignoreQuerySchema), + query: themedFilteredMediaQuerySchema, responses: { 200: showTrendingResponseSchema.array(), }, @@ -862,10 +847,7 @@ Returns the most watched shows over the last 24 hours. Shows with the most \`wat Returns the most watched (unique users) shows in the specified time \`period\`, defaulting to \`weekly\`. All stats are relative to the specific time \`period\`.`, path: '/watched/:period', method: 'GET', - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(pageQuerySchema) - .merge(ignoreQuerySchema), + query: filteredMediaQuerySchema, pathParams: periodParamsSchema, responses: { 200: showWatchedResponseSchema.array(), @@ -878,10 +860,7 @@ Returns the most watched (unique users) shows in the specified time \`period\`, Returns the most favorited shows in the specified time \`period\`, defaulting to \`weekly\`.`, path: '/favorited/:period', method: 'GET', - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(pageQuerySchema) - .merge(ignoreQuerySchema), + query: filteredMediaQuerySchema, pathParams: periodParamsSchema, responses: { 200: showFavoritedResponseSchema.array(), @@ -894,10 +873,7 @@ Returns the most favorited shows in the specified time \`period\`, defaulting to Returns the most played shows in the specified time \`period\`, defaulting to \`weekly\`.`, path: '/played/:period', method: 'GET', - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(pageQuerySchema) - .merge(ignoreQuerySchema), + query: filteredMediaQuerySchema, pathParams: periodParamsSchema, responses: { 200: showWatchedResponseSchema.array(), @@ -910,10 +886,7 @@ Returns the most played shows in the specified time \`period\`, defaulting to \` Returns the most collected shows in the specified time \`period\`, defaulting to \`weekly\`.`, path: '/collected/:period', method: 'GET', - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(pageQuerySchema) - .merge(ignoreQuerySchema), + query: filteredMediaQuerySchema, pathParams: periodParamsSchema, responses: { 200: showWatchedResponseSchema.array(), @@ -951,11 +924,7 @@ Returns the most collected shows in the specified time \`period\`, defaulting to Returns the most anticipated shows based on the number of lists a show appears on.`, path: '/anticipated', method: 'GET', - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(themeQuerySchema) - .merge(pageQuerySchema) - .merge(ignoreQuerySchema), + query: themedFilteredMediaQuerySchema, responses: { 200: showAnticipatedResponseSchema.array(), }, @@ -966,10 +935,7 @@ Returns the most anticipated shows based on the number of lists a show appears o Returns shows that are currently hot on Trakt. Results can be filtered by media fields or ignored user state.`, path: '/hot', method: 'GET', - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(pageQuerySchema) - .merge(ignoreQuerySchema), + query: filteredMediaQuerySchema, responses: { 200: showHotResponseSchema.array(), }, @@ -981,11 +947,7 @@ Returns shows that are currently hot on Trakt. Results can be filtered by media Returns the most popular shows. Popularity is calculated using the rating percentage and the number of ratings.`, path: '/popular', method: 'GET', - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(themeQuerySchema) - .merge(pageQuerySchema) - .merge(ignoreQuerySchema), + query: themedFilteredMediaQuerySchema, responses: { 200: showResponseSchema.array(), }, @@ -997,10 +959,7 @@ Returns shows recently available on streaming services for the requested \`perio path: '/streaming/:period', method: 'GET', pathParams: recentPeriodParamsSchema, - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(pageQuerySchema) - .merge(ignoreQuerySchema), + query: filteredMediaQuerySchema, responses: { 200: showStreamingResponseSchema.array(), }, diff --git a/projects/api/src/contracts/sync/index.ts b/projects/api/src/contracts/sync/index.ts index 392737f5..31ab5ec5 100644 --- a/projects/api/src/contracts/sync/index.ts +++ b/projects/api/src/contracts/sync/index.ts @@ -43,6 +43,19 @@ import { ratingsSyncResponseSchema } from './schema/response/ratingsResponseSche import { removeRatingsResponseSchema } from './schema/response/removeRatingsResponseSchema.ts'; import { upNextResponseSchema } from './schema/response/upNextResponseSchema.ts'; +const playbackQuerySchema = extendedQuerySchemaFactory<['full', 'images']>() + .merge(pageQuerySchema) + .merge(progressParamsSchema); + +const pagedImagesQuerySchema = extendedQuerySchemaFactory<['full', 'images']>() + .merge(pageQuerySchema); + +const collectionQuerySchema = extendedQuerySchemaFactory< + ['full', 'images', 'available_on'] +>() + .merge(collectionParamSchema) + .merge(pageQuerySchema); + const syncTypeParamsSchema = z.object({ type: z.string().describe('Sync media type filter.'), }); @@ -138,9 +151,7 @@ Returns in-progress movie playback items for the authenticated user, most recent ${PLAYBACK_PAGINATION_NOTE}`, method: 'GET', path: '/playback/movies', - query: extendedQuerySchemaFactory<['full', 'images']>() - .merge(pageQuerySchema) - .merge(progressParamsSchema), + query: playbackQuerySchema, responses: { 200: movieProgressResponseSchema.array(), }, @@ -153,9 +164,7 @@ Returns in-progress episode playback items for the authenticated user, most rece ${PLAYBACK_PAGINATION_NOTE}`, method: 'GET', path: '/playback/episodes', - query: extendedQuerySchemaFactory<['full', 'images']>() - .merge(pageQuerySchema) - .merge(progressParamsSchema), + query: playbackQuerySchema, responses: { 200: episodeProgressResponseSchema.array(), }, @@ -170,9 +179,7 @@ Discriminate on \`type\`: movie items carry \`movie\`, episode items carry \`epi ${PLAYBACK_PAGINATION_NOTE}`, method: 'GET', path: '/playback', - query: extendedQuerySchemaFactory<['full', 'images']>() - .merge(pageQuerySchema) - .merge(progressParamsSchema), + query: playbackQuerySchema, responses: { 200: playbackResponseSchema.array(), }, @@ -222,9 +229,7 @@ Returns movies and episodes that a user has watched, sorted by most recent. Spec method: 'GET', path: '/:type/:id', pathParams: syncHistoryParamsSchema, - query: extendedQuerySchemaFactory<['full', 'images']>() - .merge(pageQuerySchema) - .merge(progressParamsSchema), + query: playbackQuerySchema, responses: { 200: syncHistoryItemResponseSchema.array(), }, @@ -294,8 +299,7 @@ Returns all items in the authenticated user's watchlist filtered by type.`, method: 'GET', path: '/:type/:sort_by/:sort_how', pathParams: syncSortedListParamsSchema, - query: extendedQuerySchemaFactory<['full', 'images']>() - .merge(pageQuerySchema), + query: pagedImagesQuerySchema, responses: { 200: syncListItemResponseSchema.array(), }, @@ -392,8 +396,7 @@ const ratings = builder.router({ method: 'GET', path: '/:type/:rating', pathParams: syncRatingsParamsSchema, - query: extendedQuerySchemaFactory<['full', 'images']>() - .merge(pageQuerySchema), + query: pagedImagesQuerySchema, responses: { 200: syncRatingItemResponseSchema.array(), }, @@ -458,8 +461,7 @@ Returns all items in the authenticated user's favorites filtered by type.`, method: 'GET', path: '/:type/:sort_by/:sort_how', pathParams: syncSortedListParamsSchema, - query: extendedQuerySchemaFactory<['full', 'images']>() - .merge(pageQuerySchema), + query: pagedImagesQuerySchema, responses: { 200: syncListItemResponseSchema.array(), }, @@ -541,9 +543,7 @@ Returns all collected items for a media \`type\`.`, method: 'GET', path: '/:type', pathParams: syncTypeParamsSchema, - query: extendedQuerySchemaFactory<['full', 'images', 'available_on']>() - .merge(collectionParamSchema) - .merge(pageQuerySchema), + query: collectionQuerySchema, responses: { 200: collectionResponseSchema.array(), }, @@ -554,9 +554,7 @@ Returns all collected items for a media \`type\`.`, Returns movies in the authenticated user collection. Use \`available_on\` and pagination to filter and move through collected movies.`, method: 'GET', path: '/movies', - query: extendedQuerySchemaFactory<['full', 'images', 'available_on']>() - .merge(collectionParamSchema) - .merge(pageQuerySchema), + query: collectionQuerySchema, responses: { 200: collectedMovieSchema.array(), }, @@ -579,9 +577,7 @@ Returns shows in the authenticated user collection, including collected seasons Returns episodes in the authenticated user collection. Use \`available_on\` and pagination to filter and move through collected episodes.`, method: 'GET', path: '/episodes', - query: extendedQuerySchemaFactory<['full', 'images', 'available_on']>() - .merge(collectionParamSchema) - .merge(pageQuerySchema), + query: collectionQuerySchema, responses: { 200: collectedEpisodeSchema.array(), }, @@ -592,9 +588,7 @@ Returns episodes in the authenticated user collection. Use \`available_on\` and Returns movies, shows, and episodes in the authenticated user collection. Use \`available_on\` and pagination to filter and move through collected media.`, method: 'GET', path: '/media', - query: extendedQuerySchemaFactory<['full', 'images', 'available_on']>() - .merge(collectionParamSchema) - .merge(pageQuerySchema), + query: collectionQuerySchema, responses: { 200: collectionResponseSchema.array(), }, diff --git a/projects/api/src/contracts/sync/schema/response/favoritesRemoveResponseSchema.ts b/projects/api/src/contracts/sync/schema/response/favoritesRemoveResponseSchema.ts index 86cd969e..57a2045b 100644 --- a/projects/api/src/contracts/sync/schema/response/favoritesRemoveResponseSchema.ts +++ b/projects/api/src/contracts/sync/schema/response/favoritesRemoveResponseSchema.ts @@ -1,3 +1,4 @@ +import { listSummaryResponseSchema } from '../../../_internal/response/listSummaryResponseSchema.ts'; import { z } from '../../../_internal/z.ts'; import { favoriteParamSchema } from '../request/favoritesParamSchema.ts'; @@ -8,8 +9,5 @@ export const favoritesRemoveResponseSchema = z.object({ shows: z.number().int(), }).nullish(), not_found: favoriteParamSchema, - list: z.object({ - updated_at: z.string().datetime(), - item_count: z.number().int(), - }), + list: listSummaryResponseSchema, }); diff --git a/projects/api/src/contracts/sync/schema/response/favoritesResponseSchema.ts b/projects/api/src/contracts/sync/schema/response/favoritesResponseSchema.ts index de1c74f9..0f3f7b70 100644 --- a/projects/api/src/contracts/sync/schema/response/favoritesResponseSchema.ts +++ b/projects/api/src/contracts/sync/schema/response/favoritesResponseSchema.ts @@ -1,3 +1,4 @@ +import { listSummaryResponseSchema } from '../../../_internal/response/listSummaryResponseSchema.ts'; import { z } from '../../../_internal/z.ts'; import { favoriteParamSchema } from '../request/favoritesParamSchema.ts'; @@ -12,8 +13,5 @@ export const favoritesResponseSchema = z.object({ shows: z.number().int(), }).nullish(), not_found: favoriteParamSchema, - list: z.object({ - updated_at: z.string().datetime(), - item_count: z.number().int(), - }), + list: listSummaryResponseSchema, }); diff --git a/projects/api/src/contracts/sync/schema/response/ratingsResponseSchema.ts b/projects/api/src/contracts/sync/schema/response/ratingsResponseSchema.ts index 4c2d8682..913e9de8 100644 --- a/projects/api/src/contracts/sync/schema/response/ratingsResponseSchema.ts +++ b/projects/api/src/contracts/sync/schema/response/ratingsResponseSchema.ts @@ -1,13 +1,9 @@ +import { mediaMutationCountsSchema } from '../../../_internal/response/mediaMutationCountsSchema.ts'; import { z } from '../../../_internal/z.ts'; import { ratingsParamSchema } from '../request/ratingsParamSchema.ts'; /** Zod schema for the ratings sync response. */ export const ratingsSyncResponseSchema = z.object({ - added: z.object({ - movies: z.number().int(), - shows: z.number().int(), - seasons: z.number().int(), - episodes: z.number().int(), - }), + added: mediaMutationCountsSchema, not_found: ratingsParamSchema, }); diff --git a/projects/api/src/contracts/sync/schema/response/removeRatingsResponseSchema.ts b/projects/api/src/contracts/sync/schema/response/removeRatingsResponseSchema.ts index fdd14164..4bcd8445 100644 --- a/projects/api/src/contracts/sync/schema/response/removeRatingsResponseSchema.ts +++ b/projects/api/src/contracts/sync/schema/response/removeRatingsResponseSchema.ts @@ -1,13 +1,9 @@ +import { mediaMutationCountsSchema } from '../../../_internal/response/mediaMutationCountsSchema.ts'; import { z } from '../../../_internal/z.ts'; import { ratingsParamSchema } from '../request/ratingsParamSchema.ts'; /** Zod schema for the remove ratings response. */ export const removeRatingsResponseSchema = z.object({ - deleted: z.object({ - movies: z.number().int(), - shows: z.number().int(), - seasons: z.number().int(), - episodes: z.number().int(), - }), + deleted: mediaMutationCountsSchema, not_found: ratingsParamSchema, }); diff --git a/projects/api/src/contracts/users/index.ts b/projects/api/src/contracts/users/index.ts index 1ec10e28..5126992d 100644 --- a/projects/api/src/contracts/users/index.ts +++ b/projects/api/src/contracts/users/index.ts @@ -78,6 +78,12 @@ import { userLists } from './subroutes/userLists.ts'; import { watched } from './subroutes/watched.ts'; import { watchlist } from './subroutes/watchlist.ts'; +const commentsLikesQuerySchema = extendedQuerySchemaFactory< + ['comments', 'min', 'full', 'images'] +>() + .merge(pageQuerySchema) + .merge(limitlessQuerySchema); + const userTypeParamsSchema = profileParamsSchema.extend({ type: z.string().describe('User media type filter.'), }); @@ -114,9 +120,7 @@ The \`permissions\` object is also useful to customize your user experience. In Returns comments the authenticated user has reacted to. Use \`extended\`, \`page\`, and \`limit\` to control returned comment detail and pagination.`, path: '/reactions/comments', method: 'GET', - query: extendedQuerySchemaFactory<['comments', 'min', 'full', 'images']>() - .merge(pageQuerySchema) - .merge(limitlessQuerySchema), + query: commentsLikesQuerySchema, responses: { 200: reactedCommentResponseSchema.array(), }, @@ -129,9 +133,7 @@ Returns comments the authenticated user has reacted to. Use \`extended\`, \`page Returns comments the authenticated user has liked. Use \`extended\`, \`page\`, and \`limit\` to control returned comment detail and pagination.`, path: '/likes/comments', method: 'GET', - query: extendedQuerySchemaFactory<['comments', 'min', 'full', 'images']>() - .merge(pageQuerySchema) - .merge(limitlessQuerySchema), + query: commentsLikesQuerySchema, responses: { 200: likedCommentResponseSchema.array(), }, @@ -142,9 +144,7 @@ Returns comments the authenticated user has liked. Use \`extended\`, \`page\`, a Returns lists the authenticated user has liked. Use \`extended\`, \`page\`, and \`limit\` to control returned list detail and pagination.`, path: '/likes/lists', method: 'GET', - query: extendedQuerySchemaFactory<['comments', 'min', 'full', 'images']>() - .merge(pageQuerySchema) - .merge(limitlessQuerySchema), + query: commentsLikesQuerySchema, responses: { 200: likedListResponseSchema.array(), }, @@ -455,9 +455,7 @@ Returns likes for a user filtered by type.`, path: '/likes/:type', method: 'GET', pathParams: userTypeParamsSchema, - query: extendedQuerySchemaFactory<['comments', 'min', 'full', 'images']>() - .merge(pageQuerySchema) - .merge(limitlessQuerySchema), + query: commentsLikesQuerySchema, responses: { 200: z.union([ likedCommentResponseSchema, diff --git a/projects/api/src/contracts/users/schema/response/hiddenRemoveResponseSchema.ts b/projects/api/src/contracts/users/schema/response/hiddenRemoveResponseSchema.ts deleted file mode 100644 index 26dfd96b..00000000 --- a/projects/api/src/contracts/users/schema/response/hiddenRemoveResponseSchema.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { bulkMediaRequestSchema } from '../../../_internal/request/bulkMediaRequestSchema.ts'; -import { z } from '../../../_internal/z.ts'; - -/** Zod schema for the hidden remove response. */ -export const hiddenRemoveResponseSchema = z.object({ - deleted: z.object({ movies: z.number().int(), episodes: z.number().int() }), - not_found: bulkMediaRequestSchema, -}); diff --git a/projects/api/src/contracts/users/schema/response/plexSettingsResponseSchema.ts b/projects/api/src/contracts/users/schema/response/plexSettingsResponseSchema.ts index 824f070a..4ed93e97 100644 --- a/projects/api/src/contracts/users/schema/response/plexSettingsResponseSchema.ts +++ b/projects/api/src/contracts/users/schema/response/plexSettingsResponseSchema.ts @@ -6,6 +6,17 @@ export const plexLibrarySelectionSchema = z.object({ uuid: z.string(), }); +const playTogglesSchema = z.object({ + watching: z.boolean(), + watched: z.boolean(), + rated: z.boolean(), + collected: z.boolean(), +}); + +const ratedToggleSchema = z.object({ + rated: z.boolean(), +}); + /** Zod schema for sync movie toggles. */ export const syncMovieTogglesSchema = z.object({ watching: z.boolean(), @@ -22,43 +33,22 @@ export const syncShowTogglesSchema = z.object({ }); /** Zod schema for sync season toggles. */ -export const syncSeasonTogglesSchema = z.object({ - rated: z.boolean(), -}); +export const syncSeasonTogglesSchema = ratedToggleSchema; /** Zod schema for sync episode toggles. */ -export const syncEpisodeTogglesSchema = z.object({ - watching: z.boolean(), - watched: z.boolean(), - rated: z.boolean(), - collected: z.boolean(), -}); +export const syncEpisodeTogglesSchema = playTogglesSchema; /** Zod schema for scrobbler movie toggles. */ -export const scrobblerMovieTogglesSchema = z.object({ - watching: z.boolean(), - watched: z.boolean(), - rated: z.boolean(), - collected: z.boolean(), -}); +export const scrobblerMovieTogglesSchema = playTogglesSchema; /** Zod schema for scrobbler show toggles. */ -export const scrobblerShowTogglesSchema = z.object({ - rated: z.boolean(), -}); +export const scrobblerShowTogglesSchema = ratedToggleSchema; /** Zod schema for scrobbler season toggles. */ -export const scrobblerSeasonTogglesSchema = z.object({ - rated: z.boolean(), -}); +export const scrobblerSeasonTogglesSchema = ratedToggleSchema; /** Zod schema for scrobbler episode toggles. */ -export const scrobblerEpisodeTogglesSchema = z.object({ - watching: z.boolean(), - watched: z.boolean(), - rated: z.boolean(), - collected: z.boolean(), -}); +export const scrobblerEpisodeTogglesSchema = playTogglesSchema; /** Zod schema for plex sync toggles. */ export const plexSyncTogglesSchema = z.object({ diff --git a/projects/api/src/contracts/users/schema/response/showActivityHistoryResponseSchema.ts b/projects/api/src/contracts/users/schema/response/showActivityHistoryResponseSchema.ts deleted file mode 100644 index fddec006..00000000 --- a/projects/api/src/contracts/users/schema/response/showActivityHistoryResponseSchema.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { typedEpisodeResponseSchema } from '../../../_internal/response/episodeResponseSchema.ts'; -import { int64, z } from '../../../_internal/z.ts'; -import { historyActionSchema } from './historyActionSchema.ts'; - -/** Zod schema for the show activity history response. */ -export const showActivityHistoryResponseSchema = z.object({ - id: int64(z.number().int()), - watched_at: z.string().datetime(), - action: historyActionSchema, -}).merge(typedEpisodeResponseSchema); diff --git a/projects/api/src/contracts/users/subroutes/hidden.ts b/projects/api/src/contracts/users/subroutes/hidden.ts index c93e1c21..a50aaeb6 100644 --- a/projects/api/src/contracts/users/subroutes/hidden.ts +++ b/projects/api/src/contracts/users/subroutes/hidden.ts @@ -6,7 +6,7 @@ import { z } from '../../_internal/z.ts'; import { hiddenParamsSchema } from '../schema/request/hiddenParamsSchema.ts'; import { hiddenShowRequestSchema } from '../schema/request/hiddenShowRequestSchema.ts'; import { hiddenAddResponseSchema } from '../schema/response/hiddenAddResponseSchema.ts'; -import { hiddenRemoveResponseSchema } from '../schema/response/hiddenRemoveResponseSchema.ts'; +import { historyRemoveResponseSchema } from '../../sync/schema/response/historyRemoveResponseSchema.ts'; import { hiddenShowResponseSchema } from '../schema/response/hiddenShowResponseSchema.ts'; const hiddenTypeQuerySchema = z.object({ @@ -91,7 +91,7 @@ Remove shows or seasons from hidden watched progress. Send hideable media object method: 'POST', body: bulkMediaRequestSchema, responses: { - 200: hiddenRemoveResponseSchema, + 200: historyRemoveResponseSchema, }, }, calendar: { @@ -102,7 +102,7 @@ Remove movies or shows from hidden calendar items. Send hideable media objects i method: 'POST', body: bulkMediaRequestSchema, responses: { - 200: hiddenRemoveResponseSchema, + 200: historyRemoveResponseSchema, }, }, section: { @@ -114,7 +114,7 @@ Remove hidden items for a specific section. Send hideable media objects in the r pathParams: hiddenParamsSchema, body: bulkMediaRequestSchema, responses: { - 200: hiddenRemoveResponseSchema, + 200: historyRemoveResponseSchema, }, }, }, diff --git a/projects/api/src/contracts/users/subroutes/history.ts b/projects/api/src/contracts/users/subroutes/history.ts index 57781def..10a2d3bd 100644 --- a/projects/api/src/contracts/users/subroutes/history.ts +++ b/projects/api/src/contracts/users/subroutes/history.ts @@ -9,7 +9,18 @@ import { profileParamsSchema } from '../schema/request/profileParamsSchema.ts'; import { activityHistoryResponseSchema } from '../schema/response/activityHistoryResponseSchema.ts'; import { episodeActivityHistoryResponseSchema } from '../schema/response/episodeActivityHistoryResponseSchema.ts'; import { movieActivityHistoryResponseSchema } from '../schema/response/movieActivityHistoryResponseSchema.ts'; -import { showActivityHistoryResponseSchema } from '../schema/response/showActivityHistoryResponseSchema.ts'; + +const historyQuerySchema = extendedMediaQuerySchema + .merge(mediaFilterParamsSchema) + .merge(dateRangeParamsSchema) + .merge(pageQuerySchema); + +const historyItemPathParamsSchema = profileParamsSchema + .merge(historyItemIdParamsSchema); + +const historyItemQuerySchema = extendedMediaQuerySchema + .merge(dateRangeParamsSchema) + .merge(pageQuerySchema); const historyTypedItemParamsSchema = profileParamsSchema .merge(historyItemIdParamsSchema) @@ -26,10 +37,7 @@ Returns watched history for a user across all supported media types. Use \`start path: '/', method: 'GET', pathParams: profileParamsSchema, - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(dateRangeParamsSchema) - .merge(pageQuerySchema), + query: historyQuerySchema, responses: { 200: activityHistoryResponseSchema.array(), }, @@ -41,10 +49,7 @@ Returns movie watched history for a user. Use \`start_at\` and \`end_at\` to lim path: '/movies', method: 'GET', pathParams: profileParamsSchema, - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(dateRangeParamsSchema) - .merge(pageQuerySchema), + query: historyQuerySchema, responses: { 200: movieActivityHistoryResponseSchema.array(), }, @@ -56,12 +61,9 @@ Returns show watched history for a user. Use \`start_at\` and \`end_at\` to limi path: '/shows', method: 'GET', pathParams: profileParamsSchema, - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(dateRangeParamsSchema) - .merge(pageQuerySchema), + query: historyQuerySchema, responses: { - 200: showActivityHistoryResponseSchema.array(), + 200: episodeActivityHistoryResponseSchema.array(), }, }, episodes: { @@ -71,10 +73,7 @@ Returns episode watched history for a user. Use \`start_at\` and \`end_at\` to l path: '/episodes', method: 'GET', pathParams: profileParamsSchema, - query: extendedMediaQuerySchema - .merge(mediaFilterParamsSchema) - .merge(dateRangeParamsSchema) - .merge(pageQuerySchema), + query: historyQuerySchema, responses: { 200: episodeActivityHistoryResponseSchema.array(), }, @@ -85,11 +84,8 @@ Returns episode watched history for a user. Use \`start_at\` and \`end_at\` to l Returns watched history entries for one movie. Use \`item_id\` to identify the movie and \`start_at\` or \`end_at\` to limit the date range.`, path: '/movies/:item_id', method: 'GET', - pathParams: profileParamsSchema - .merge(historyItemIdParamsSchema), - query: extendedMediaQuerySchema - .merge(dateRangeParamsSchema) - .merge(pageQuerySchema), + pathParams: historyItemPathParamsSchema, + query: historyItemQuerySchema, responses: { 200: movieActivityHistoryResponseSchema.array(), }, @@ -100,13 +96,10 @@ Returns watched history entries for one movie. Use \`item_id\` to identify the m Returns watched history entries for one show. Use \`item_id\` to identify the show and \`start_at\` or \`end_at\` to limit the date range.`, path: '/shows/:item_id', method: 'GET', - pathParams: profileParamsSchema - .merge(historyItemIdParamsSchema), - query: extendedMediaQuerySchema - .merge(dateRangeParamsSchema) - .merge(pageQuerySchema), + pathParams: historyItemPathParamsSchema, + query: historyItemQuerySchema, responses: { - 200: showActivityHistoryResponseSchema.array(), + 200: episodeActivityHistoryResponseSchema.array(), }, }, episode: { @@ -115,11 +108,8 @@ Returns watched history entries for one show. Use \`item_id\` to identify the sh Returns watched history entries for one episode. Use \`item_id\` to identify the episode and \`start_at\` or \`end_at\` to limit the date range.`, path: '/episodes/:item_id', method: 'GET', - pathParams: profileParamsSchema - .merge(historyItemIdParamsSchema), - query: extendedMediaQuerySchema - .merge(dateRangeParamsSchema) - .merge(pageQuerySchema), + pathParams: historyItemPathParamsSchema, + query: historyItemQuerySchema, responses: { 200: episodeActivityHistoryResponseSchema.array(), }, @@ -131,9 +121,7 @@ Returns watched history entries for one item. Use \`type\`, \`item_id\`, \`start path: '/:type/:item_id', method: 'GET', pathParams: historyTypedItemParamsSchema, - query: extendedMediaQuerySchema - .merge(dateRangeParamsSchema) - .merge(pageQuerySchema), + query: historyItemQuerySchema, responses: { 200: activityHistoryResponseSchema.array(), }, @@ -148,7 +136,7 @@ export type MovieActivityHistoryResponse = z.infer< >; /** The show activity history response payload. */ export type ShowActivityHistoryResponse = z.infer< - typeof showActivityHistoryResponseSchema + typeof episodeActivityHistoryResponseSchema >; /** The activity history response payload. */ export type ActivityHistoryResponse = z.infer< diff --git a/projects/api/src/contracts/users/subroutes/userLists.ts b/projects/api/src/contracts/users/subroutes/userLists.ts index 1c6997c8..ba35daf6 100644 --- a/projects/api/src/contracts/users/subroutes/userLists.ts +++ b/projects/api/src/contracts/users/subroutes/userLists.ts @@ -32,6 +32,15 @@ import { reorderListsResponseSchema } from '../schema/response/reorderListsRespo import { smartListDraftResponseSchema } from '../schema/response/smartListDraftResponseSchema.ts'; import { smartListWriteSchema } from '../../_internal/request/smartListWriteSchema.ts'; +const listPathParamsSchema = profileParamsSchema.merge(listParamsSchema); + +const listItemsQuerySchema = extendedMediaQuerySchema + .merge(sortQuerySchema) + .merge(mediaFilterParamsSchema) + .merge(ignoreQuerySchema) + .merge(pageQuerySchema) + .merge(limitlessQuerySchema); + const listItemsPathParamsSchema = profileParamsSchema .merge(listParamsSchema) .extend({ @@ -58,7 +67,7 @@ const list = builder.router({ Returns a single personal list. Use the [**/users/:id/lists/:list_id/items**](#reference/users/list-items) method to get the actual items this list contains.`, path: '/', method: 'GET', - pathParams: profileParamsSchema.merge(listParamsSchema), + pathParams: listPathParamsSchema, query: extendedProfileQuerySchema, responses: { 200: listResponseSchema, @@ -92,7 +101,7 @@ Lists will be \`private\` by default. Here is what each value means. | \`sort_how\` | string | \`asc\`, \`desc\` |`, path: '/', method: 'PUT', - pathParams: profileParamsSchema.merge(listParamsSchema), + pathParams: listPathParamsSchema, body: listUpdateRequestSchema.merge(sortQuerySchema), responses: { 200: listResponseSchema.array(), @@ -104,7 +113,7 @@ Lists will be \`private\` by default. Here is what each value means. Remove a personal list and all items it contains.`, path: '/', method: 'DELETE', - pathParams: profileParamsSchema.merge(listParamsSchema), + pathParams: listPathParamsSchema, responses: { 204: z.undefined(), 403: z.undefined(), @@ -118,13 +127,8 @@ Remove a personal list and all items it contains.`, Returns movie items on a personal list. Use \`list_id\` to identify the list and query sorting, filters, and pagination to control the result set.`, path: '/items/movie', method: 'GET', - pathParams: profileParamsSchema.merge(listParamsSchema), - query: extendedMediaQuerySchema - .merge(sortQuerySchema) - .merge(mediaFilterParamsSchema) - .merge(ignoreQuerySchema) - .merge(pageQuerySchema) - .merge(limitlessQuerySchema), + pathParams: listPathParamsSchema, + query: listItemsQuerySchema, responses: { 200: listedMovieResponseSchema.array(), }, @@ -136,13 +140,8 @@ Returns movie items on a personal list. Use \`list_id\` to identify the list and Returns show items on a personal list. Use \`list_id\` to identify the list and query sorting, filters, and pagination to control the result set.`, path: '/items/show', method: 'GET', - pathParams: profileParamsSchema.merge(listParamsSchema), - query: extendedMediaQuerySchema - .merge(sortQuerySchema) - .merge(mediaFilterParamsSchema) - .merge(ignoreQuerySchema) - .merge(pageQuerySchema) - .merge(limitlessQuerySchema), + pathParams: listPathParamsSchema, + query: listItemsQuerySchema, responses: { 200: listedShowResponseSchema.array(), }, @@ -154,14 +153,8 @@ Returns show items on a personal list. Use \`list_id\` to identify the list and Returns movie and show items on a personal list. Use \`list_id\` to identify the list and query sorting, filters, and pagination to control the result set.`, path: '/items/movie,show', method: 'GET', - pathParams: profileParamsSchema - .merge(listParamsSchema), - query: extendedMediaQuerySchema - .merge(sortQuerySchema) - .merge(mediaFilterParamsSchema) - .merge(ignoreQuerySchema) - .merge(pageQuerySchema) - .merge(limitlessQuerySchema), + pathParams: listPathParamsSchema, + query: listItemsQuerySchema, responses: { 200: listedMediaResponseSchema.array(), }, @@ -173,14 +166,8 @@ Returns movie and show items on a personal list. Use \`list_id\` to identify the Returns movie, show, season, and episode items on a personal list. Use \`list_id\` to identify the list and query sorting, filters, and pagination to control the result set.`, path: '/items/movie,show,season,episode', method: 'GET', - pathParams: profileParamsSchema - .merge(listParamsSchema), - query: extendedMediaQuerySchema - .merge(sortQuerySchema) - .merge(mediaFilterParamsSchema) - .merge(ignoreQuerySchema) - .merge(pageQuerySchema) - .merge(limitlessQuerySchema), + pathParams: listPathParamsSchema, + query: listItemsQuerySchema, responses: { 200: listedAllResponseSchema.array(), }, @@ -256,8 +243,7 @@ Remove one or more items from a personal list. Reorder items on a personal list. Send the ordered list item IDs in the request body; the response returns the updated item order.`, path: '/reorder', method: 'POST', - pathParams: profileParamsSchema - .merge(listParamsSchema), + pathParams: listPathParamsSchema, body: reorderRequestSchema, responses: { 200: reorderListResponseSchema, @@ -269,8 +255,7 @@ Reorder items on a personal list. Send the ordered list item IDs in the request Reorder items on a personal list. Send the ordered list item IDs in the request body; the response returns the updated item order.`, path: '/items/reorder', method: 'POST', - pathParams: profileParamsSchema - .merge(listParamsSchema), + pathParams: listPathParamsSchema, body: reorderRequestSchema, responses: { 200: reorderListResponseSchema, @@ -294,8 +279,7 @@ Reorder items on a personal list. Send the ordered list item IDs in the request Returns all users who liked a list.`, path: '/likes', method: 'GET', - pathParams: profileParamsSchema - .merge(listParamsSchema), + pathParams: listPathParamsSchema, query: pageQuerySchema, responses: { 200: likeResponseSchema.array(), @@ -307,8 +291,7 @@ Returns all users who liked a list.`, Votes help determine popular lists. Only one like is allowed per list per user.`, path: '/like', method: 'POST', - pathParams: profileParamsSchema - .merge(listParamsSchema), + pathParams: listPathParamsSchema, body: z.undefined(), responses: { 204: z.undefined(), @@ -320,8 +303,7 @@ Votes help determine popular lists. Only one like is allowed per list per user.` Remove a like on a list.`, path: '/like', method: 'DELETE', - pathParams: profileParamsSchema - .merge(listParamsSchema), + pathParams: listPathParamsSchema, responses: { 204: z.undefined(), }, @@ -352,8 +334,7 @@ Returns a ready-made smart list setup for a personal list that stopped updating \`filters\` use the same shape as smart list filters, so they can be sent as-is when creating a smart list. \`confidence\` is the share of the list's current titles the setup brings back, from 0 to 1. Lists without a good setup return \`404\`. \`converted\` is set once the owner has turned the list into a smart list.`, path: '/smart-list-draft', method: 'GET', - pathParams: profileParamsSchema - .merge(listParamsSchema), + pathParams: listPathParamsSchema, responses: { 200: smartListDraftResponseSchema, 401: z.undefined(), @@ -375,8 +356,7 @@ The list keeps its address: its items are then served from the smart list, and i | \`420\` | Smart list limit reached. |`, path: '/smart-list-draft/convert', method: 'POST', - pathParams: profileParamsSchema - .merge(listParamsSchema), + pathParams: listPathParamsSchema, body: smartListWriteSchema, responses: { 200: smartListCreateResponseSchema, @@ -393,8 +373,7 @@ The list keeps its address: its items are then served from the smart list, and i Report a list for moderator review. Send a \`reason\` and optional \`message\` with additional context. A user can only have one \`pending\` report per list.`, path: '/report', method: 'POST', - pathParams: profileParamsSchema - .merge(listParamsSchema), + pathParams: listPathParamsSchema, body: listReportRequestSchema, responses: { 201: z.undefined(), diff --git a/projects/api/src/contracts/users/subroutes/watchlist.ts b/projects/api/src/contracts/users/subroutes/watchlist.ts index 0b674a42..cf6215f1 100644 --- a/projects/api/src/contracts/users/subroutes/watchlist.ts +++ b/projects/api/src/contracts/users/subroutes/watchlist.ts @@ -14,6 +14,16 @@ import { listCommentsSortParamsSchema } from '../schema/request/listCommentsSort import { profileParamsSchema } from '../schema/request/profileParamsSchema.ts'; import { sortParamsSchema } from '../schema/request/sortParamsSchema.ts'; +const sortedWatchlistPathParamsSchema = profileParamsSchema + .merge(sortParamsSchema.partial()); + +const watchlistQuerySchema = extendedMediaQuerySchema + .merge(sortQuerySchema) + .merge(pageQuerySchema) + .merge(mediaFilterParamsSchema) + .merge(hideFilterParamsSchema) + .merge(ignoreQuerySchema); + const typedSortedWatchlistParamsSchema = profileParamsSchema.extend({ type: z.string().describe('Watchlist media type filter.'), sort_by: z.string().describe('Sort by a specific property.'), @@ -28,15 +38,9 @@ export const watchlist = builder.router({ `#### 🔓 OAuth Optional 📄 Pagination ✨ Extended Info 🎚 Filters Returns movies on a user watchlist. Use the \`sort\` path parameter plus query sorting, pagination, and filters to control the result order and contents.`, path: '/movies/:sort', - pathParams: profileParamsSchema - .merge(sortParamsSchema.partial()), + pathParams: sortedWatchlistPathParamsSchema, method: 'GET', - query: extendedMediaQuerySchema - .merge(sortQuerySchema) - .merge(pageQuerySchema) - .merge(mediaFilterParamsSchema) - .merge(hideFilterParamsSchema) - .merge(ignoreQuerySchema), + query: watchlistQuerySchema, responses: { 200: listedMovieResponseSchema.array(), }, @@ -47,15 +51,9 @@ Returns movies on a user watchlist. Use the \`sort\` path parameter plus query s `#### 🔓 OAuth Optional 📄 Pagination ✨ Extended Info 🎚 Filters Returns shows on a user watchlist. Use the \`sort\` path parameter plus query sorting, pagination, and filters to control the result order and contents.`, path: '/shows/:sort', - pathParams: profileParamsSchema - .merge(sortParamsSchema.partial()), + pathParams: sortedWatchlistPathParamsSchema, method: 'GET', - query: extendedMediaQuerySchema - .merge(sortQuerySchema) - .merge(pageQuerySchema) - .merge(mediaFilterParamsSchema) - .merge(hideFilterParamsSchema) - .merge(ignoreQuerySchema), + query: watchlistQuerySchema, responses: { 200: listedShowResponseSchema.array(), }, @@ -66,15 +64,9 @@ Returns shows on a user watchlist. Use the \`sort\` path parameter plus query so `#### 🔓 OAuth Optional 📄 Pagination ✨ Extended Info 🎚 Filters Returns movies and shows on a user watchlist. Use the \`sort\` path parameter plus query sorting, pagination, and filters to control the result order and contents.`, path: '/movie,show/:sort', - pathParams: profileParamsSchema - .merge(sortParamsSchema.partial()), + pathParams: sortedWatchlistPathParamsSchema, method: 'GET', - query: extendedMediaQuerySchema - .merge(sortQuerySchema) - .merge(pageQuerySchema) - .merge(mediaFilterParamsSchema) - .merge(hideFilterParamsSchema) - .merge(ignoreQuerySchema), + query: watchlistQuerySchema, responses: { 200: listedMediaResponseSchema.array(), }, @@ -87,12 +79,7 @@ Returns all items in a user's watchlist filtered by type.`, path: '/:type/:sort_by/:sort_how', pathParams: typedSortedWatchlistParamsSchema, method: 'GET', - query: extendedMediaQuerySchema - .merge(sortQuerySchema) - .merge(pageQuerySchema) - .merge(mediaFilterParamsSchema) - .merge(hideFilterParamsSchema) - .merge(ignoreQuerySchema), + query: watchlistQuerySchema, responses: { 200: listedMediaResponseSchema.array(), }, diff --git a/projects/developer/src/lib/api/DeveloperAccountState.ts b/projects/developer/src/lib/api/DeveloperAccountState.ts deleted file mode 100644 index 203fd2b3..00000000 --- a/projects/developer/src/lib/api/DeveloperAccountState.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { DeveloperAccount } from './DeveloperAccount.ts'; - -export type DeveloperAccountState = { - accounts: ReadonlyArray; -}; diff --git a/projects/developer/src/lib/api/fetchAccounts.ts b/projects/developer/src/lib/api/fetchAccounts.ts deleted file mode 100644 index 335cb82e..00000000 --- a/projects/developer/src/lib/api/fetchAccounts.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { listAccounts } from '$lib/auth/listAccounts.ts'; -import type { DeveloperAccountState } from './DeveloperAccountState.ts'; - -export async function fetchAccounts(): Promise { - return { accounts: await listAccounts().catch(() => []) }; -} diff --git a/projects/developer/src/lib/features/apps/Applications.svelte b/projects/developer/src/lib/features/apps/Applications.svelte index 22008b29..ace6be2c 100644 --- a/projects/developer/src/lib/features/apps/Applications.svelte +++ b/projects/developer/src/lib/features/apps/Applications.svelte @@ -327,6 +327,23 @@ > {/snippet} +{#snippet lockIcon()} + +{/snippet} +
@@ -412,36 +429,12 @@ class="create-tile locked" href="/premium" > - + {@render lockIcon()} App limit reachedPremium apps stay outside the community limit. See plans → {:else}
- + {@render lockIcon()} Create app{createLock}
{/if} {#each ownApps as app (app.id)}{@render appCard(app)}{/each} diff --git a/projects/developer/src/lib/features/apps/addAllowedUser.ts b/projects/developer/src/lib/features/apps/addAllowedUser.ts deleted file mode 100644 index 89701351..00000000 --- a/projects/developer/src/lib/features/apps/addAllowedUser.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { accountRequest } from '$lib/api/accountRequest.ts'; - -export async function addAllowedUser( - slot: number, - appId: number, - username: string, -): Promise { - await accountRequest({ - slot, - path: `/v3/users/me/applications/${appId}/allowed-users/${ - encodeURIComponent(username) - }`, - init: { method: 'POST' }, - }); -} diff --git a/projects/developer/src/lib/features/apps/addManager.ts b/projects/developer/src/lib/features/apps/addManager.ts deleted file mode 100644 index 48cec36c..00000000 --- a/projects/developer/src/lib/features/apps/addManager.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { accountRequest } from '$lib/api/accountRequest.ts'; - -export async function addManager( - slot: number, - appId: number, - username: string, -): Promise { - await accountRequest({ - slot, - path: `/v3/users/me/applications/${appId}/managers/${ - encodeURIComponent(username) - }`, - init: { method: 'POST' }, - }); -} diff --git a/projects/developer/src/lib/features/apps/addMember.ts b/projects/developer/src/lib/features/apps/addMember.ts index 3436fb83..1e195f44 100644 --- a/projects/developer/src/lib/features/apps/addMember.ts +++ b/projects/developer/src/lib/features/apps/addMember.ts @@ -1,14 +1,16 @@ -import { addAllowedUser } from './addAllowedUser.ts'; -import { addManager } from './addManager.ts'; +import { accountRequest } from '$lib/api/accountRequest.ts'; import type { AppMemberRole } from './AppMemberRole.ts'; +import { memberPath } from './memberPath.ts'; import type { MemberTarget } from './MemberTarget.ts'; -export function addMember( +export async function addMember( { slot, appId }: MemberTarget, username: string, role: AppMemberRole, ): Promise { - return role === 'manager' - ? addManager(slot, appId, username) - : addAllowedUser(slot, appId, username); + await accountRequest({ + slot, + path: memberPath({ appId, role, username }), + init: { method: 'POST' }, + }); } diff --git a/projects/developer/src/lib/features/apps/changeMemberRole.spec.ts b/projects/developer/src/lib/features/apps/changeMemberRole.spec.ts index cc67f7b4..27ebaa08 100644 --- a/projects/developer/src/lib/features/apps/changeMemberRole.spec.ts +++ b/projects/developer/src/lib/features/apps/changeMemberRole.spec.ts @@ -3,26 +3,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const calls: string[] = []; let failTesterAdd = false; -vi.mock('./addManager.ts', () => ({ - addManager: (_slot: number, _appId: number, slug: string) => { - calls.push(`addManager:${slug}`); - return Promise.resolve(); +vi.mock('./addMember.ts', () => ({ + addMember: (_target: unknown, slug: string, role: string) => { + calls.push(`add:${role}:${slug}`); + return failTesterAdd && role === 'tester' + ? Promise.reject(new Error('seats full')) + : Promise.resolve(); }, })); -vi.mock('./removeManager.ts', () => ({ - removeManager: (_slot: number, _appId: number, slug: string) => { - calls.push(`removeManager:${slug}`); +vi.mock('./removeMember.ts', () => ({ + removeMember: (_target: unknown, member: { slug: string; role: string }) => { + calls.push(`remove:${member.role}:${member.slug}`); return Promise.resolve(); }, })); -vi.mock('./addAllowedUser.ts', () => ({ - addAllowedUser: (_slot: number, _appId: number, slug: string) => { - calls.push(`addAllowedUser:${slug}`); - return failTesterAdd - ? Promise.reject(new Error('seats full')) - : Promise.resolve(); - }, -})); const { changeMemberRole } = await import('./changeMemberRole.ts'); @@ -42,12 +36,12 @@ describe('changeMemberRole', () => { it('promotes a tester in one call', async () => { await changeMemberRole(target, { ...manager, role: 'tester' }, 'manager'); - expect(calls).toEqual(['addManager:seftur']); + expect(calls).toEqual(['add:manager:seftur']); }); it('demotes a manager by removing then adding as a tester', async () => { await changeMemberRole(target, manager, 'tester'); - expect(calls).toEqual(['removeManager:seftur', 'addAllowedUser:seftur']); + expect(calls).toEqual(['remove:manager:seftur', 'add:tester:seftur']); }); it('restores the manager when the tester add fails', async () => { @@ -56,9 +50,9 @@ describe('changeMemberRole', () => { 'seats full', ); expect(calls).toEqual([ - 'removeManager:seftur', - 'addAllowedUser:seftur', - 'addManager:seftur', + 'remove:manager:seftur', + 'add:tester:seftur', + 'add:manager:seftur', ]); }); }); diff --git a/projects/developer/src/lib/features/apps/changeMemberRole.ts b/projects/developer/src/lib/features/apps/changeMemberRole.ts index 50748b65..9139b473 100644 --- a/projects/developer/src/lib/features/apps/changeMemberRole.ts +++ b/projects/developer/src/lib/features/apps/changeMemberRole.ts @@ -1,22 +1,21 @@ -import { addAllowedUser } from './addAllowedUser.ts'; -import { addManager } from './addManager.ts'; +import { addMember } from './addMember.ts'; import type { AppMember } from './AppMember.ts'; import type { AppMemberRole } from './AppMemberRole.ts'; import type { MemberTarget } from './MemberTarget.ts'; -import { removeManager } from './removeManager.ts'; +import { removeMember } from './removeMember.ts'; export async function changeMemberRole( - { slot, appId }: MemberTarget, + target: MemberTarget, member: AppMember, role: AppMemberRole, ): Promise { if (role === member.role) return; - if (role === 'manager') return addManager(slot, appId, member.slug); - await removeManager(slot, appId, member.slug); + if (role === 'manager') return addMember(target, member.slug, 'manager'); + await removeMember(target, member); try { - await addAllowedUser(slot, appId, member.slug); + await addMember(target, member.slug, 'tester'); } catch (cause) { - await addManager(slot, appId, member.slug); + await addMember(target, member.slug, 'manager'); throw cause; } } diff --git a/projects/developer/src/lib/features/apps/memberPath.spec.ts b/projects/developer/src/lib/features/apps/memberPath.spec.ts new file mode 100644 index 00000000..74903162 --- /dev/null +++ b/projects/developer/src/lib/features/apps/memberPath.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { memberPath } from './memberPath.ts'; + +describe('memberPath', () => { + it('points managers at the managers collection', () => { + expect(memberPath({ appId: 7, role: 'manager', username: 'seftur' })).toBe( + '/v3/users/me/applications/7/managers/seftur', + ); + }); + + it('points testers at the allowed users collection', () => { + expect(memberPath({ appId: 7, role: 'tester', username: 'seftur' })).toBe( + '/v3/users/me/applications/7/allowed-users/seftur', + ); + }); + + it('encodes the username', () => { + expect(memberPath({ appId: 7, role: 'tester', username: 'a b/c' })).toBe( + '/v3/users/me/applications/7/allowed-users/a%20b%2Fc', + ); + }); +}); diff --git a/projects/developer/src/lib/features/apps/memberPath.ts b/projects/developer/src/lib/features/apps/memberPath.ts new file mode 100644 index 00000000..d405d6f7 --- /dev/null +++ b/projects/developer/src/lib/features/apps/memberPath.ts @@ -0,0 +1,20 @@ +import type { AppMemberRole } from './AppMemberRole.ts'; + +const ROLE_SEGMENT: Record = { + manager: 'managers', + tester: 'allowed-users', +}; + +export function memberPath({ + appId, + role, + username, +}: { + appId: number; + role: AppMemberRole; + username: string; +}): string { + return `/v3/users/me/applications/${appId}/${ROLE_SEGMENT[role]}/${ + encodeURIComponent(username) + }`; +} diff --git a/projects/developer/src/lib/features/apps/removeAllowedUser.ts b/projects/developer/src/lib/features/apps/removeAllowedUser.ts deleted file mode 100644 index da09d673..00000000 --- a/projects/developer/src/lib/features/apps/removeAllowedUser.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { accountRequest } from '$lib/api/accountRequest.ts'; - -export async function removeAllowedUser( - slot: number, - appId: number, - slug: string, -): Promise { - await accountRequest({ - slot, - path: `/v3/users/me/applications/${appId}/allowed-users/${ - encodeURIComponent(slug) - }`, - init: { method: 'DELETE' }, - }); -} diff --git a/projects/developer/src/lib/features/apps/removeManager.ts b/projects/developer/src/lib/features/apps/removeManager.ts deleted file mode 100644 index 25466cbf..00000000 --- a/projects/developer/src/lib/features/apps/removeManager.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { accountRequest } from '$lib/api/accountRequest.ts'; - -export async function removeManager( - slot: number, - appId: number, - slug: string, -): Promise { - await accountRequest({ - slot, - path: `/v3/users/me/applications/${appId}/managers/${ - encodeURIComponent(slug) - }`, - init: { method: 'DELETE' }, - }); -} diff --git a/projects/developer/src/lib/features/apps/removeMember.ts b/projects/developer/src/lib/features/apps/removeMember.ts index 5fc0e968..318369b7 100644 --- a/projects/developer/src/lib/features/apps/removeMember.ts +++ b/projects/developer/src/lib/features/apps/removeMember.ts @@ -1,13 +1,15 @@ +import { accountRequest } from '$lib/api/accountRequest.ts'; import type { AppMember } from './AppMember.ts'; +import { memberPath } from './memberPath.ts'; import type { MemberTarget } from './MemberTarget.ts'; -import { removeAllowedUser } from './removeAllowedUser.ts'; -import { removeManager } from './removeManager.ts'; -export function removeMember( +export async function removeMember( { slot, appId }: MemberTarget, member: AppMember, ): Promise { - return member.role === 'manager' - ? removeManager(slot, appId, member.slug) - : removeAllowedUser(slot, appId, member.slug); + await accountRequest({ + slot, + path: memberPath({ appId, role: member.role, username: member.slug }), + init: { method: 'DELETE' }, + }); } diff --git a/projects/developer/src/lib/features/developer/DeveloperApp.svelte b/projects/developer/src/lib/features/developer/DeveloperApp.svelte index 540ab5c2..727ec8e5 100644 --- a/projects/developer/src/lib/features/developer/DeveloperApp.svelte +++ b/projects/developer/src/lib/features/developer/DeveloperApp.svelte @@ -16,7 +16,7 @@ import type { ApiHeader } from "$lib/api/ApiHeader.ts"; import { executeApiRequest } from "$lib/api/executeApiRequest.ts"; import { observeAccountChanges } from "$lib/auth/observeAccountChanges.ts"; - import { fetchAccounts } from "$lib/api/fetchAccounts.ts"; + import { listAccounts } from "$lib/auth/listAccounts.ts"; import type { DeveloperAccount } from "$lib/api/DeveloperAccount.ts"; import type { Endpoint } from "$lib/openapi/Endpoint.ts"; import { buildEndpointUrl } from "$lib/openapi/buildEndpointUrl.ts"; @@ -275,17 +275,17 @@ ]; requestBody = state.body; activeTab = state.activeTab ?? activeTab; + refreshRequestUrl(endpoint); + } + + function refreshRequestUrl(endpoint: Endpoint) { requestUrl = buildEndpointUrl({ endpoint, serverUrl, values }); } function setValue(id: string, value: string) { if (!selectedEndpoint) return; values = { ...values, [id]: value }; - requestUrl = buildEndpointUrl({ - endpoint: selectedEndpoint, - serverUrl, - values, - }); + refreshRequestUrl(selectedEndpoint); const headerId = parameterHeaderId(id); headers = headers.map((header) => @@ -313,11 +313,7 @@ if (!selectedEndpoint) return; if (!selectedEndpoint.serverUrls.includes(value)) return; serverUrl = value; - requestUrl = buildEndpointUrl({ - endpoint: selectedEndpoint, - serverUrl, - values, - }); + refreshRequestUrl(selectedEndpoint); } function setMainServer(value: string) { @@ -327,11 +323,7 @@ return; serverUrl = value; - requestUrl = buildEndpointUrl({ - endpoint: selectedEndpoint, - serverUrl, - values, - }); + refreshRequestUrl(selectedEndpoint); } function setSelectedAccount(slot: number | null) { @@ -381,9 +373,9 @@ async function refreshAccounts() { const generation = sessionGeneration; - const accountState = await fetchAccounts(); + const nextAccounts = await listAccounts().catch(() => []); if (generation !== sessionGeneration) return; - accounts = accountState.accounts; + accounts = nextAccounts; accountsLoading = false; profileRevision += 1; if (accounts.some((account) => account.slot === selectedAccountSlot)) diff --git a/projects/developer/src/lib/features/developer/GuideReader.svelte b/projects/developer/src/lib/features/developer/GuideReader.svelte index e116b7ed..d0e0e861 100644 --- a/projects/developer/src/lib/features/developer/GuideReader.svelte +++ b/projects/developer/src/lib/features/developer/GuideReader.svelte @@ -1,6 +1,5 @@