diff --git a/readme.md b/readme.md index 117c5d0..9c2870d 100644 --- a/readme.md +++ b/readme.md @@ -45,6 +45,7 @@ Interactive docs are served at `/` via [Scalar](https://scalar.com). The raw Ope | GET | `/` | | Scalar API reference UI | | GET | `/openapi.json` | | OpenAPI 3.0.3 spec | | GET | `/album` | `slug` or (`artist`, `name`), `minimal` | Full album detail | +| GET | `/similar` | `slug` or (`artist`, `name`) | Similar albums | | GET | `/releases` | `page` | New album releases | | GET | `/releases/singles` | `page` | New single releases | | GET | `/upcoming` | `page` | Upcoming releases | diff --git a/src/index.ts b/src/index.ts index e76ea6d..fb6ee4d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -407,6 +407,25 @@ async function route(path: string, q: URLSearchParams, opts: FetchOpts): Promise return detail; } + if (path === "/similar") { + const slug = q.get("slug"); + const artist = q.get("artist"); + const name = q.get("name"); + + let albumPath: string; + if (slug) { + albumPath = `/album/${slug}/`; + } else if (artist && name) { + const url = await findAlbumUrl(artist, name, opts); + if (!url) throw new ApiError("Album not found", 404); + albumPath = new URL(url).pathname; + } else { + throw new ApiError("Provide either slug (ID or full slug) or both artist and name", 400); + } + + return { albums: await fetchAlbumBlocks(`${albumPath}similar/`, opts) }; + } + if (path === "/releases") { const page = getPage(q); return { page, albums: await fetchAlbumBlocks(`/releases/${page}/`, opts) }; diff --git a/src/openapi.ts b/src/openapi.ts index dc664fb..d3d6e68 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -61,6 +61,57 @@ export const openApiSpec = { }, }, }, + "/similar": { + get: { + summary: "Similar albums", + description: + "Return the albums AOTY lists as similar to a given album (the 'You May Also Like' section). Provide either slug (ID or full slug for direct lookup) or both artist and name (search-based lookup).", + operationId: "getSimilar", + parameters: [ + { $ref: "#/components/parameters/CacheControl" }, + { + name: "slug", + in: "query", + required: false, + schema: { type: "string" }, + description: "AOTY album ID or full slug (e.g. '2915' or '2915-outkast-aquemini'). Use this or artist+name.", + example: "2915", + }, + { + name: "artist", + in: "query", + required: false, + schema: { type: "string" }, + example: "OutKast", + }, + { + name: "name", + in: "query", + required: false, + schema: { type: "string" }, + example: "Aquemini", + }, + ], + responses: { + "200": { + description: "List of similar albums", + content: { + "application/json": { + schema: { + type: "object", + properties: { + albums: { type: "array", items: { $ref: "#/components/schemas/AlbumBlock" } }, + }, + }, + }, + }, + }, + "400": { $ref: "#/components/responses/BadRequest" }, + "404": { $ref: "#/components/responses/NotFound" }, + "500": { $ref: "#/components/responses/ServerError" }, + }, + }, + }, "/releases": { get: { summary: "New album releases", diff --git a/tests/index.test.ts b/tests/index.test.ts index 8ecc8f6..32ba5b8 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -116,6 +116,24 @@ describe("GET /album parameter validation", () => { }); }); +describe("GET /similar parameter validation", () => { + it("returns 400 when both params missing", async () => { + const res = await fetch("/similar"); + expect(res.status).toBe(400); + }); + + it("returns 400 when name missing", async () => { + const res = await fetch("/similar?artist=OutKast"); + expect(res.status).toBe(400); + }); + + it("400 body has detail field (RFC 9457)", async () => { + const body = await (await fetch("/similar")).json() as { detail: string; status: number }; + expect(body.detail).toContain("artist"); + expect(body.status).toBe(400); + }); +}); + describe("GET /search parameter validation", () => { const searchPaths = ["/search", "/search/albums", "/search/artists", "/search/labels"];