diff --git a/server/api/albums/[id].get.ts b/server/api/albums/[id].get.ts new file mode 100644 index 0000000..f6e3374 --- /dev/null +++ b/server/api/albums/[id].get.ts @@ -0,0 +1,16 @@ +import { getRouterParam } from "h3" +import { Album } from "../../models/album" +import { requireAuthenticatedUser } from "../../utils/auth" +import { parseMongoId, validationResponse } from "../../utils/api-validation" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event) + + const parsedId = parseMongoId(getRouterParam(event, "id")) + if ("error" in parsedId) return validationResponse(event, parsedId.error) + + return Album.findOne({ _id: parsedId.value }) + .populate("artists") + .populate("genre") + .exec() +}) diff --git a/server/api/albums/[id]/tracks.get.ts b/server/api/albums/[id]/tracks.get.ts new file mode 100644 index 0000000..fe5c0b0 --- /dev/null +++ b/server/api/albums/[id]/tracks.get.ts @@ -0,0 +1,24 @@ +import { getQuery, getRouterParam } from "h3" +import { Track } from "../../../models/track" +import { requireAuthenticatedUser } from "../../../utils/auth" +import { parseMongoId, parseNumber, validationResponse } from "../../../utils/api-validation" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event) + + const parsedId = parseMongoId(getRouterParam(event, "id")) + if ("error" in parsedId) return validationResponse(event, parsedId.error) + + const query = getQuery(event) + const limit = parseNumber(query.limit, 9, 1) + if ("error" in limit) return validationResponse(event, limit.error) + const skip = parseNumber(query.skip, 0, 0) + if ("error" in skip) return validationResponse(event, skip.error) + + return Track.find({ album: parsedId.value }) + .limit(limit.value || 9) + .skip(skip.value || 0) + .populate("artists") + .populate({ path: "album", select: "-artists -genre" }) + .exec() +}) diff --git a/server/api/albums/index.get.ts b/server/api/albums/index.get.ts new file mode 100644 index 0000000..89f36b3 --- /dev/null +++ b/server/api/albums/index.get.ts @@ -0,0 +1,16 @@ +import { getQuery } from "h3" +import { Album } from "../../models/album" +import { requireAuthenticatedUser } from "../../utils/auth" +import { parseMongoIds, validationResponse } from "../../utils/api-validation" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event) + + const parsedIds = parseMongoIds(getQuery(event).ids) + if ("error" in parsedIds) return validationResponse(event, parsedIds.error) + + return Album.find({ _id: { $in: parsedIds.value } }) + .populate("artists") + .populate("genre") + .exec() +}) diff --git a/server/api/all/albums.get.ts b/server/api/all/albums.get.ts new file mode 100644 index 0000000..442be87 --- /dev/null +++ b/server/api/all/albums.get.ts @@ -0,0 +1,8 @@ +import { Album } from "../../models/album" +import { requireAuthenticatedUser } from "../../utils/auth" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event) + + return Album.find({}).populate("artists").populate("genre") +}) diff --git a/server/api/all/artists.get.ts b/server/api/all/artists.get.ts new file mode 100644 index 0000000..c3c8bf4 --- /dev/null +++ b/server/api/all/artists.get.ts @@ -0,0 +1,8 @@ +import { Artist } from "../../models/artist" +import { requireAuthenticatedUser } from "../../utils/auth" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event) + + return Artist.find({}) +}) diff --git a/server/api/all/genres.get.ts b/server/api/all/genres.get.ts new file mode 100644 index 0000000..329bfb6 --- /dev/null +++ b/server/api/all/genres.get.ts @@ -0,0 +1,8 @@ +import { Genre } from "../../models/genre" +import { requireAuthenticatedUser } from "../../utils/auth" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event) + + return Genre.find({}) +}) diff --git a/server/api/all/tracks.get.ts b/server/api/all/tracks.get.ts new file mode 100644 index 0000000..8aeb1bf --- /dev/null +++ b/server/api/all/tracks.get.ts @@ -0,0 +1,14 @@ +import { Track } from "../../models/track" +import { requireAuthenticatedUser } from "../../utils/auth" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event) + + return Track.find({}) + .populate({ + path: "album", + select: "-artists", + populate: { path: "genre" }, + }) + .populate("artists") +}) diff --git a/server/api/artists/[id].get.ts b/server/api/artists/[id].get.ts new file mode 100644 index 0000000..3710a08 --- /dev/null +++ b/server/api/artists/[id].get.ts @@ -0,0 +1,13 @@ +import { getRouterParam } from "h3" +import { Artist } from "../../models/artist" +import { requireAuthenticatedUser } from "../../utils/auth" +import { parseMongoId, validationResponse } from "../../utils/api-validation" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event) + + const parsedId = parseMongoId(getRouterParam(event, "id")) + if ("error" in parsedId) return validationResponse(event, parsedId.error) + + return Artist.findOne({ _id: parsedId.value }).exec() +}) diff --git a/server/api/artists/[id]/albums.get.ts b/server/api/artists/[id]/albums.get.ts new file mode 100644 index 0000000..465bc32 --- /dev/null +++ b/server/api/artists/[id]/albums.get.ts @@ -0,0 +1,24 @@ +import { getQuery, getRouterParam } from "h3" +import { Album } from "../../../models/album" +import { requireAuthenticatedUser } from "../../../utils/auth" +import { parseMongoId, parseNumber, validationResponse } from "../../../utils/api-validation" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event) + + const parsedId = parseMongoId(getRouterParam(event, "id")) + if ("error" in parsedId) return validationResponse(event, parsedId.error) + + const query = getQuery(event) + const limit = parseNumber(query.limit, 9, 1) + if ("error" in limit) return validationResponse(event, limit.error) + const skip = parseNumber(query.skip, 0, 0) + if ("error" in skip) return validationResponse(event, skip.error) + + return Album.find({ artists: parsedId.value }) + .limit(limit.value || 9) + .skip(skip.value || 0) + .populate("genre") + .populate("artists") + .exec() +}) diff --git a/server/api/artists/[id]/tracks.get.ts b/server/api/artists/[id]/tracks.get.ts new file mode 100644 index 0000000..9907547 --- /dev/null +++ b/server/api/artists/[id]/tracks.get.ts @@ -0,0 +1,24 @@ +import { getQuery, getRouterParam } from "h3" +import { Track } from "../../../models/track" +import { requireAuthenticatedUser } from "../../../utils/auth" +import { parseMongoId, parseNumber, validationResponse } from "../../../utils/api-validation" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event) + + const parsedId = parseMongoId(getRouterParam(event, "id")) + if ("error" in parsedId) return validationResponse(event, parsedId.error) + + const query = getQuery(event) + const limit = parseNumber(query.limit, 9, 1) + if ("error" in limit) return validationResponse(event, limit.error) + const skip = parseNumber(query.skip, 0, 0) + if ("error" in skip) return validationResponse(event, skip.error) + + return Track.find({ artists: parsedId.value }) + .limit(limit.value || 9) + .skip(skip.value || 0) + .populate({ path: "album", select: "-artists" }) + .populate("artists") + .exec() +}) diff --git a/server/api/artists/index.get.ts b/server/api/artists/index.get.ts new file mode 100644 index 0000000..3671af3 --- /dev/null +++ b/server/api/artists/index.get.ts @@ -0,0 +1,13 @@ +import { getQuery } from "h3" +import { Artist } from "../../models/artist" +import { requireAuthenticatedUser } from "../../utils/auth" +import { parseMongoIds, validationResponse } from "../../utils/api-validation" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event) + + const parsedIds = parseMongoIds(getQuery(event).ids) + if ("error" in parsedIds) return validationResponse(event, parsedIds.error) + + return Artist.find({ _id: { $in: parsedIds.value } }).exec() +}) diff --git a/server/api/genres/albums/[id].get.ts b/server/api/genres/albums/[id].get.ts new file mode 100644 index 0000000..b8da70d --- /dev/null +++ b/server/api/genres/albums/[id].get.ts @@ -0,0 +1,31 @@ +import mongoose, { type PipelineStage } from "mongoose" +import { Album } from "../../../models/album" +import { parseMongoId, parseNumber, validationResponse } from "../../../utils/api-validation" +import { requireAuthenticatedUser } from "../../../utils/auth" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event) + + const id = parseMongoId(getRouterParam(event, "id")) + if ("error" in id) return validationResponse(event, id.error) + + const { limit: rawLimit } = getQuery(event) + const limit = parseNumber(rawLimit, 10, 1) + if ("error" in limit) return validationResponse(event, limit.error) + + const pipeline: PipelineStage[] = [ + { $match: { genre: new mongoose.Types.ObjectId(id.value) } }, + { $sample: { size: limit.value || 10 } }, + { + $lookup: { + from: "artists", + localField: "artists", + foreignField: "_id", + as: "artists", + }, + }, + { $unset: "genre" }, + ] + + return Album.aggregate(pipeline) +}) diff --git a/server/api/genres/random.get.ts b/server/api/genres/random.get.ts new file mode 100644 index 0000000..3482b53 --- /dev/null +++ b/server/api/genres/random.get.ts @@ -0,0 +1,14 @@ +import { Genre } from "../../models/genre" +import { parseNumber, validationResponse } from "../../utils/api-validation" +import { requireAuthenticatedUser } from "../../utils/auth" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event) + + const { limit: rawLimit } = getQuery(event) + const limit = parseNumber(rawLimit, 10, 1) + if ("error" in limit) return validationResponse(event, limit.error) + + const genres = await Genre.aggregate([{ $sample: { size: limit.value || 10 } }]) + return genres +}) diff --git a/server/api/genres/tracks/[id].get.ts b/server/api/genres/tracks/[id].get.ts new file mode 100644 index 0000000..cb8b68e --- /dev/null +++ b/server/api/genres/tracks/[id].get.ts @@ -0,0 +1,40 @@ +import mongoose, { type PipelineStage } from "mongoose" +import { Track } from "../../../models/track" +import { parseMongoId, parseNumber, validationResponse } from "../../../utils/api-validation" +import { requireAuthenticatedUser } from "../../../utils/auth" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event) + + const id = parseMongoId(getRouterParam(event, "id")) + if ("error" in id) return validationResponse(event, id.error) + + const { limit: rawLimit } = getQuery(event) + const limit = parseNumber(rawLimit, 10, 1) + if ("error" in limit) return validationResponse(event, limit.error) + + const pipeline: PipelineStage[] = [ + { + $lookup: { + from: "albums", + localField: "album", + foreignField: "_id", + as: "album", + }, + }, + { $unwind: "$album" }, + { + $lookup: { + from: "artists", + localField: "artists", + foreignField: "_id", + as: "artists", + }, + }, + { $match: { "album.genre": new mongoose.Types.ObjectId(id.value) } }, + { $sample: { size: limit.value || 10 } }, + { $unset: "album.artists" }, + ] + + return Track.aggregate(pipeline) +}) diff --git a/server/api/search.get.ts b/server/api/search.get.ts new file mode 100644 index 0000000..19f2414 --- /dev/null +++ b/server/api/search.get.ts @@ -0,0 +1,95 @@ +import Fuse from "fuse.js" +import { Album } from "../models/album" +import { Artist } from "../models/artist" +import { Track } from "../models/track" +import { + parseNumber, + parseQueryString, + validationResponse, +} from "../utils/api-validation" +import { requireAuthenticatedUser } from "../utils/auth" + +const searchTypes = ["track", "album", "artist", "default"] as const + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event) + + const query = getQuery(event) + const q = parseQueryString(query.q) + if ("error" in q) return validationResponse(event, q.error) + + let type = "default" + if (query.type !== undefined) { + const parsedType = parseQueryString(query.type) + if ("error" in parsedType) return validationResponse(event, parsedType.error) + if (!searchTypes.includes(parsedType.value as (typeof searchTypes)[number])) { + return validationResponse( + event, + `Invalid enum value. Expected 'track' | 'album' | 'artist' | 'default', received '${parsedType.value}'`, + ) + } + type = parsedType.value + } + + const parsedLimit = parseNumber(query.limit, 9, 1) + if ("error" in parsedLimit) return validationResponse(event, parsedLimit.error) + const limit = parsedLimit.value + + let results: { tracks: any[]; albums: any[]; artists: any[] } = { + tracks: [], + albums: [], + artists: [], + } + + if (type === "track" || type === "default") { + const rawTracks = await Track.find({}) + .populate(["album", "artists"]) + .lean() + + const fuse = new Fuse(rawTracks, { + keys: ["name", "lyrics.text"], + threshold: 0.3, + distance: 100, + ignoreLocation: true, + }) + + results.tracks = fuse.search(q.value).slice(0, limit).map((result) => result.item) + } + + if (type === "album" || type === "default") { + const rawAlbums = await Album.find({}) + .populate(["artists", "genre"]) + .lean() + + const fuse = new Fuse(rawAlbums, { + keys: ["name"], + threshold: 0.3, + distance: 100, + ignoreLocation: true, + }) + + results.albums = fuse.search(q.value).slice(0, limit).map((result) => result.item) + } + + if (type === "artist" || type === "default") { + const rawArtists = await Artist.find({}).lean() + + const fuse = new Fuse(rawArtists, { + keys: ["name"], + threshold: 0.3, + distance: 100, + ignoreLocation: true, + }) + + results.artists = fuse.search(q.value).slice(0, limit).map((result) => result.item) + } + + // Keep the original API's split allocation, including when type selects one category. + results = { + tracks: results.tracks.slice(0, Math.ceil(limit / 3)), + albums: results.albums.slice(0, Math.floor(limit / 3)), + artists: results.artists.slice(0, Math.floor(limit / 3)), + } + + return results +}) diff --git a/server/api/tracks.get.ts b/server/api/tracks.get.ts new file mode 100644 index 0000000..ba04b8e --- /dev/null +++ b/server/api/tracks.get.ts @@ -0,0 +1,20 @@ +import { getQuery } from "h3" +import { Track } from "../models/track" +import { requireAuthenticatedUser } from "../utils/auth" +import { parseMongoIds, validationResponse } from "../utils/api-validation" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event) + + const parsedIds = parseMongoIds(getQuery(event).ids) + if ("error" in parsedIds) return validationResponse(event, parsedIds.error) + + return Track.find({ _id: { $in: parsedIds.value } }) + .populate({ + path: "album", + select: "-artists", + populate: { path: "genre" }, + }) + .populate("artists") + .exec() +}) diff --git a/server/api/tracks/[id].get.ts b/server/api/tracks/[id].get.ts new file mode 100644 index 0000000..7a15d53 --- /dev/null +++ b/server/api/tracks/[id].get.ts @@ -0,0 +1,20 @@ +import { getRouterParam } from "h3" +import { Track } from "../../models/track" +import { requireAuthenticatedUser } from "../../utils/auth" +import { parseMongoId, validationResponse } from "../../utils/api-validation" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event) + + const parsedId = parseMongoId(getRouterParam(event, "id")) + if ("error" in parsedId) return validationResponse(event, parsedId.error) + + return Track.findOne({ _id: parsedId.value }) + .populate({ + path: "album", + select: "-artists", + populate: { path: "genre" }, + }) + .populate("artists") + .exec() +}) diff --git a/server/api/tracks/[id]/lyrics.get.ts b/server/api/tracks/[id]/lyrics.get.ts new file mode 100644 index 0000000..dcf58bd --- /dev/null +++ b/server/api/tracks/[id]/lyrics.get.ts @@ -0,0 +1,16 @@ +import { getRouterParam } from "h3" +import { Track } from "../../../models/track" +import { requireAuthenticatedUser } from "../../../utils/auth" +import { parseMongoId, validationResponse } from "../../../utils/api-validation" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event) + + const parsedId = parseMongoId(getRouterParam(event, "id")) + if ("error" in parsedId) return validationResponse(event, parsedId.error) + + const track = await Track.findOne({ _id: parsedId.value }).select("lyrics").exec() + if (!track) return {} + + return track.lyrics +}) diff --git a/server/api/user/index.get.ts b/server/api/user/index.get.ts new file mode 100644 index 0000000..8b818b9 --- /dev/null +++ b/server/api/user/index.get.ts @@ -0,0 +1,5 @@ +import { requireAuthenticatedUser } from "../../utils/auth" + +export default defineEventHandler(async (event) => { + return await requireAuthenticatedUser(event) +}) diff --git a/server/api/user/tracks.get.ts b/server/api/user/tracks.get.ts new file mode 100644 index 0000000..634a1c6 --- /dev/null +++ b/server/api/user/tracks.get.ts @@ -0,0 +1,15 @@ +import { User } from "../../models/user" +import { requireAuthenticatedUser } from "../../utils/auth" +import { setResponseStatus } from "h3" + +export default defineEventHandler(async (event) => { + const authenticatedUser = await requireAuthenticatedUser(event) + const user = await User.findById(authenticatedUser._id).populate("savedTracks") + + if (!user) { + setResponseStatus(event, 404) + return { message: "User not found" } + } + + return user.savedTracks +}) diff --git a/server/api/user/tracks.patch.ts b/server/api/user/tracks.patch.ts new file mode 100644 index 0000000..c4a2325 --- /dev/null +++ b/server/api/user/tracks.patch.ts @@ -0,0 +1,26 @@ +import { getQuery, setResponseStatus } from "h3" +import mongoose, { type ObjectId } from "mongoose" +import { User } from "../../models/user" +import { requireAuthenticatedUser } from "../../utils/auth" +import { parseMongoId, validationResponse } from "../../utils/api-validation" + +export default defineEventHandler(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) + if (!user) { + setResponseStatus(event, 404) + return { message: "User not found" } + } + + // Preserve the legacy comparison behavior and empty-object response. + if ((user.savedTracks as ObjectId[]).includes(parsedId.value as unknown as ObjectId)) { + return {} + } + + user.savedTracks.push(new mongoose.Types.ObjectId(parsedId.value)) + await user.save() + return {} +}) diff --git a/server/models/album.ts b/server/models/album.ts new file mode 100644 index 0000000..ce39832 --- /dev/null +++ b/server/models/album.ts @@ -0,0 +1,15 @@ +import mongoose, { Schema, type Model } from "mongoose" + +const albumSchema = new Schema({ + name: { type: String, required: true }, + artists: { + type: [{ type: Schema.Types.ObjectId, ref: "Artist" }], + required: true, + }, + file: { type: String, required: true }, + genre: { type: Schema.Types.ObjectId, ref: "Genre", required: true }, +}) + +export const Album = + (mongoose.models.Album as Model | undefined) ?? + (mongoose.model("Album", albumSchema) as Model) diff --git a/server/models/artist.ts b/server/models/artist.ts new file mode 100644 index 0000000..204c578 --- /dev/null +++ b/server/models/artist.ts @@ -0,0 +1,10 @@ +import mongoose, { Schema, type Model } from "mongoose" + +const artistSchema = new Schema({ + name: { type: String, required: true }, + file: { type: String, required: true }, +}) + +export const Artist = + (mongoose.models.Artist as Model | undefined) ?? + (mongoose.model("Artist", artistSchema) as Model) diff --git a/server/models/genre.ts b/server/models/genre.ts new file mode 100644 index 0000000..a622e0d --- /dev/null +++ b/server/models/genre.ts @@ -0,0 +1,9 @@ +import mongoose, { Schema, type Model } from "mongoose" + +const genreSchema = new Schema({ + name: { type: String, required: true, unique: true }, +}) + +export const Genre = + (mongoose.models.Genre as Model | undefined) ?? + (mongoose.model("Genre", genreSchema) as Model) diff --git a/server/models/track.ts b/server/models/track.ts new file mode 100644 index 0000000..894a229 --- /dev/null +++ b/server/models/track.ts @@ -0,0 +1,22 @@ +import mongoose, { Schema, type Model } from "mongoose" + +const trackSchema = new Schema({ + name: { type: String, required: true }, + album: { type: Schema.Types.ObjectId, ref: "Album", required: true }, + artists: { + type: [{ type: Schema.Types.ObjectId, ref: "Artist" }], + required: true, + }, + fileDir: { type: String, required: true }, + durationInSeconds: { type: Number, required: true }, + lyrics: { + type: { + synced: { type: Boolean, required: true }, + text: { type: String, required: true }, + }, + }, +}) + +export const Track = + (mongoose.models.Track as Model | undefined) ?? + (mongoose.model("Track", trackSchema) as Model) diff --git a/server/utils/api-validation.ts b/server/utils/api-validation.ts new file mode 100644 index 0000000..e3fb216 --- /dev/null +++ b/server/utils/api-validation.ts @@ -0,0 +1,52 @@ +import mongoose from "mongoose" +import { setResponseStatus, type H3Event } from "h3" + +export type ParseResult = { value: T } | { error: string } + +export function parseMongoId(value: unknown): ParseResult { + if (typeof value !== "string" || !mongoose.isValidObjectId(value)) { + return { error: "Invalid id" } + } + return { value } +} + +export function parseMongoIds(value: unknown): ParseResult { + if (typeof value !== "string" && !Array.isArray(value)) { + return { error: "Required" } + } + + const values = Array.isArray(value) ? value : [value] + const ids: string[] = [] + for (const item of values) { + const parsed = parseMongoId(item) + if ("error" in parsed) return parsed + ids.push(parsed.value) + } + return { value: ids } +} + +export function parseNumber( + value: unknown, + defaultValue: number, + minimum: number, +): ParseResult { + if (value === undefined) return { value: defaultValue } + + const parsed = Number(value) + if (Number.isNaN(parsed)) return { error: "Expected number, received nan" } + if (parsed < minimum) { + return { error: `Number must be greater than or equal to ${minimum}` } + } + return { value: parsed } +} + +export function parseQueryString(value: unknown): ParseResult { + if (value === undefined) return { error: "Required" } + if (typeof value !== "string") return { error: "Expected string, received array" } + return { value } +} + +export function validationResponse(event: H3Event, message: string) { + setResponseStatus(event, 400) + return { message } +}