Migrated read APIs to Nitro

This commit is contained in:
lucast committed 2026-09-29 00:34:50 +02:00
1 parent eb4e9f0700
commit ee5809a489
26 files changed
+558

No files matched your search

+16
View File
@@ -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()
})
+24
View File
@@ -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()
})
+16
View File
@@ -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()
})
+8
View File
@@ -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")
})
+8
View File
@@ -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({})
})
+8
View File
@@ -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({})
})
+14
View File
@@ -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")
})
+13
View File
@@ -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()
})
+24
View File
@@ -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()
})
+24
View File
@@ -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()
})
+13
View File
@@ -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()
})
+31
View File
@@ -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)
})
+14
View File
@@ -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
})
+40
View File
@@ -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)
})
+95
View File
@@ -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
})
+20
View File
@@ -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()
})
+20
View File
@@ -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()
})
+16
View File
@@ -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
})
+5
View File
@@ -0,0 +1,5 @@
import { requireAuthenticatedUser } from "../../utils/auth"
export default defineEventHandler(async (event) => {
return await requireAuthenticatedUser(event)
})
+15
View File
@@ -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
})
+26
View File
@@ -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 {}
})
+15
View File
@@ -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<any> | undefined) ??
(mongoose.model<any>("Album", albumSchema) as Model<any>)
+10
View File
@@ -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<any> | undefined) ??
(mongoose.model<any>("Artist", artistSchema) as Model<any>)
+9
View File
@@ -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<any> | undefined) ??
(mongoose.model<any>("Genre", genreSchema) as Model<any>)
+22
View File
@@ -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<any> | undefined) ??
(mongoose.model<any>("Track", trackSchema) as Model<any>)
+52
View File
@@ -0,0 +1,52 @@
import mongoose from "mongoose"
import { setResponseStatus, type H3Event } from "h3"
export type ParseResult<T> = { value: T } | { error: string }
export function parseMongoId(value: unknown): ParseResult<string> {
if (typeof value !== "string" || !mongoose.isValidObjectId(value)) {
return { error: "Invalid id" }
}
return { value }
}
export function parseMongoIds(value: unknown): ParseResult<string[]> {
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<number> {
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<string> {
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 }
}