From 0526e83a9b15277b2957debfdeff8bc6591d9b82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20T=C3=A4kker?= Date: Mon, 5 Oct 2026 17:54:29 +0200 Subject: [PATCH] Added saved library API --- server/api/user/albums.get.ts | 33 ++++++++++++++++++++++++++ server/api/user/index.get.ts | 2 +- server/api/user/tracks.delete.ts | 26 ++++++++++++++++++++ server/api/user/tracks.get.ts | 14 +++++++---- server/api/user/tracks.patch.ts | 8 +++---- server/api/user/tracks/contains.get.ts | 22 +++++++++++++++++ server/utils/openapi.ts | 13 ++++++++-- 7 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 server/api/user/albums.get.ts create mode 100644 server/api/user/tracks.delete.ts create mode 100644 server/api/user/tracks/contains.get.ts diff --git a/server/api/user/albums.get.ts b/server/api/user/albums.get.ts new file mode 100644 index 0000000..a1e0449 --- /dev/null +++ b/server/api/user/albums.get.ts @@ -0,0 +1,33 @@ +import { setResponseStatus } from "h3" +import { User } from "#server/models/user" +import { Album } from "#server/models/album" +import { Track } from "#server/models/track" +import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "#server/utils/auth" + +export default defineAuthenticatedEventHandler(async (event) => { + const authenticatedUser = await requireAuthenticatedUser(event) + const user = await User.findById(authenticatedUser._id).select("savedTracks") + if (!user) { + setResponseStatus(event, 404) + return { message: "User not found" } + } + + if (!user.savedTracks.length) return [] + + const tracks = await Track.find({ _id: { $in: user.savedTracks } }).select("_id album").exec() + const albumByTrack = new Map(tracks.map((track: any) => [track._id.toString(), track.album?.toString()])) + const albumIds: string[] = [] + const seen = new Set() + for (const savedTrack of user.savedTracks) { + const albumId = albumByTrack.get(savedTrack.toString()) + if (albumId && !seen.has(albumId)) { + seen.add(albumId) + albumIds.push(albumId) + } + } + if (!albumIds.length) return [] + + const albums = await Album.find({ _id: { $in: albumIds } }).populate("artists").populate("genre").exec() + const albumsById = new Map(albums.map((album: any) => [album._id.toString(), album])) + return albumIds.map((id) => albumsById.get(id)).filter((album) => album != null) +}) diff --git a/server/api/user/index.get.ts b/server/api/user/index.get.ts index b616d2b..51149c3 100644 --- a/server/api/user/index.get.ts +++ b/server/api/user/index.get.ts @@ -1,4 +1,4 @@ -import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth" +import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "#server/utils/auth" export default defineAuthenticatedEventHandler(async (event) => { return await requireAuthenticatedUser(event) diff --git a/server/api/user/tracks.delete.ts b/server/api/user/tracks.delete.ts new file mode 100644 index 0000000..b524230 --- /dev/null +++ b/server/api/user/tracks.delete.ts @@ -0,0 +1,26 @@ +import { getQuery, setResponseStatus } from "h3" +import { User } from "#server/models/user" +import { + defineAuthenticatedEventHandler, + requireAuthenticatedUser, +} from "#server/utils/auth" +import { parseMongoIds, validationResponse } from "#server/utils/api-validation" + +export default defineAuthenticatedEventHandler(async (event) => { + const authenticatedUser = await requireAuthenticatedUser(event) + const parsedIds = parseMongoIds(getQuery(event).ids ?? getQuery(event).id) + if ("error" in parsedIds) return validationResponse(event, parsedIds.error) + + const user = await User.findById(authenticatedUser._id) + if (!user) { + setResponseStatus(event, 404) + return { message: "User not found" } + } + + user.savedTracks = user.savedTracks.filter( + (savedTrack: { toString(): string }) => + !parsedIds.value.includes(savedTrack.toString()), + ) + await user.save() + return {} +}) diff --git a/server/api/user/tracks.get.ts b/server/api/user/tracks.get.ts index 801dffd..462e820 100644 --- a/server/api/user/tracks.get.ts +++ b/server/api/user/tracks.get.ts @@ -1,15 +1,21 @@ -import { User } from "../../models/user" -import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth" +import { User } from "#server/models/user" +import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "#server/utils/auth" import { setResponseStatus } from "h3" export default defineAuthenticatedEventHandler(async (event) => { const authenticatedUser = await requireAuthenticatedUser(event) - const user = await User.findById(authenticatedUser._id).populate("savedTracks") + const user = await User.findById(authenticatedUser._id).populate({ + path: "savedTracks", + populate: [ + { path: "artists" }, + { path: "album", populate: { path: "artists" } }, + ], + }) if (!user) { setResponseStatus(event, 404) return { message: "User not found" } } - return user.savedTracks + return user.savedTracks.filter((track: unknown) => track != null) }) diff --git a/server/api/user/tracks.patch.ts b/server/api/user/tracks.patch.ts index 4c46d93..64e3ad3 100644 --- a/server/api/user/tracks.patch.ts +++ b/server/api/user/tracks.patch.ts @@ -1,8 +1,8 @@ import { getQuery, setResponseStatus } from "h3" import mongoose, { type ObjectId } from "mongoose" -import { User } from "../../models/user" -import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth" -import { parseMongoId, validationResponse } from "../../utils/api-validation" +import { User } from "#server/models/user" +import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "#server/utils/auth" +import { parseMongoId, validationResponse } from "#server/utils/api-validation" export default defineAuthenticatedEventHandler(async (event) => { const authenticatedUser = await requireAuthenticatedUser(event) @@ -16,7 +16,7 @@ export default defineAuthenticatedEventHandler(async (event) => { } // Preserve the legacy comparison behavior and empty-object response. - if ((user.savedTracks as ObjectId[]).includes(parsedId.value as unknown as ObjectId)) { + if ((user.savedTracks as ObjectId[]).some((savedTrack) => savedTrack.toString() === parsedId.value)) { return {} } diff --git a/server/api/user/tracks/contains.get.ts b/server/api/user/tracks/contains.get.ts new file mode 100644 index 0000000..4deb046 --- /dev/null +++ b/server/api/user/tracks/contains.get.ts @@ -0,0 +1,22 @@ +import { getQuery, setResponseStatus } from "h3" +import { User } from "#server/models/user" +import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "#server/utils/auth" +import { parseMongoId, validationResponse } from "#server/utils/api-validation" + +export default defineAuthenticatedEventHandler(async (event) => { + const authenticatedUser = await requireAuthenticatedUser(event) + const parsedId = parseMongoId(getQuery(event).id) + if ("error" in parsedId) return validationResponse(event, parsedId.error) + + const user = await User.findById(authenticatedUser._id).select("savedTracks") + if (!user) { + setResponseStatus(event, 404) + return { message: "User not found" } + } + + return { + contains: user.savedTracks.some( + (savedTrack: { toString(): string }) => savedTrack.toString() === parsedId.value, + ), + } +}) diff --git a/server/utils/openapi.ts b/server/utils/openapi.ts index 0127882..6a9febe 100644 --- a/server/utils/openapi.ts +++ b/server/utils/openapi.ts @@ -41,6 +41,9 @@ const operations: ApiOperation[] = [ { method: "get", path: "/user", tag: "User", summary: "Get the current user", auth: true, response: "User" }, { method: "get", path: "/user/tracks", tag: "User", summary: "Get saved tracks", auth: true, response: "TrackList" }, { method: "patch", path: "/user/tracks", tag: "User", summary: "Save a track", auth: true, query: ["id"], response: "EmptyObject" }, + { method: "delete", path: "/user/tracks", tag: "User", summary: "Remove saved tracks", auth: true, query: ["id", "ids"], response: "EmptyObject" }, + { method: "get", path: "/user/tracks/contains", tag: "User", summary: "Check whether a track is saved", auth: true, query: ["id"], response: "TrackMembership" }, + { method: "get", path: "/user/albums", tag: "User", summary: "Get albums represented by saved tracks", auth: true, response: "AlbumList" }, { method: "post", path: "/admin/artist", tag: "Admin", summary: "Create an artist", admin: true, body: "multipart", response: "Artist", status: 201 }, { method: "patch", path: "/admin/artist", tag: "Admin", summary: "Update an artist", admin: true, query: ["id"], body: "multipart", response: "Artist" }, { method: "delete", path: "/admin/artist", tag: "Admin", summary: "Delete an artist", admin: true, query: ["id", "force"], response: "EmptyObject" }, @@ -86,9 +89,14 @@ function operationSpec(operation: ApiOperation) { ...(operation.query ?? []).map((name) => ({ name, in: "query", - required: name === "q" || name === "ids" || name === "id" || (name === "force" && operation.path === "/admin/genre"), + required: operation.method === "delete" && operation.path === "/user/tracks" + ? false + : name === "q" || name === "ids" || name === "id" || (name === "force" && operation.path === "/admin/genre"), schema: fieldTypes[name] ?? { type: "string" }, ...(name === "ids" ? { style: "form", explode: true } : {}), + ...(operation.method === "delete" && operation.path === "/user/tracks" + ? { description: "Provide either id for one track or repeated ids for multiple tracks." } + : {}), })), ] const bodySchema = operation.body === "json" @@ -127,7 +135,7 @@ function operationSpec(operation: ApiOperation) { ...((operation.method === "post" && operation.path.startsWith("/auth/")) || ((operation.auth || operation.admin) && operation.method !== "get" && !operation.path.startsWith("/admin/")) ? { 403: jsonResponse("Invalid request origin", ref("Error")) } : {}), - ...(operation.path === "/user/tracks" || (operation.path.startsWith("/admin/") && (operation.method === "patch" || operation.method === "delete")) + ...(operation.path.startsWith("/user/") || (operation.path.startsWith("/admin/") && (operation.method === "patch" || operation.method === "delete")) ? { 404: jsonResponse("Resource not found", ref("NotFound")) } : {}), ...(operation.path === "/admin/track" && operation.method === "post" @@ -202,6 +210,7 @@ export const openApiDocument = { AlbumOrNull: { anyOf: [ref("Album"), { type: "null" }] }, TrackOrNull: { anyOf: [ref("Track"), { type: "null" }] }, EmptyObject: { type: "object", properties: {}, additionalProperties: false }, + TrackMembership: { type: "object", required: ["contains"], properties: { contains: { type: "boolean" } } }, NotFound: { oneOf: [ref("Error"), ref("EmptyObject")] }, DependentError: { type: "object",