closes #1; openapi! yay

This commit is contained in:
Reid 2025-08-03 20:42:31 -07:00
parent b560060b45
commit 7b15834f17
Signed by: reidlab
GPG key ID: DAF5EAF6665839FD
16 changed files with 519 additions and 341 deletions

6
flake.lock generated
View file

@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1739866667,
"narHash": "sha256-EO1ygNKZlsAC9avfcwHkKGMsmipUk1Uc0TbrEZpkn64=",
"lastModified": 1753939845,
"narHash": "sha256-K2ViRJfdVGE8tpJejs8Qpvvejks1+A4GQej/lBk5y7I=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "73cf49b8ad837ade2de76f87eb53fc85ed5d4680",
"rev": "94def634a20494ee057c76998843c015909d6311",
"type": "github"
},
"original": {

View file

@ -25,7 +25,7 @@
# uncomment this and let the build fail, then get the current hash
# very scuffed but endorsed!
# npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
npmDepsHash = "sha256-hMI010P3lJIMCMaj9HYUZopMAWaNQMCG1QXk/OdV1u4=";
npmDepsHash = "sha256-1p/QMZQoFwXxXxlTYTtdHQ3O7p+RSzD3RpoKRB40CHg=";
nativeBuildInputs = with pkgs; [ makeWrapper ];
@ -38,7 +38,7 @@
mv node_modules dist views public $out/
makeWrapper ${pkgs.nodejs-slim}/bin/node $out/bin/amdl \
--prefix PATH : ${makeBinPath buildInputs} \
--add-flags "$out/dist/index.js" \
--add-flags "$out/dist/src/index.js" \
--set VIEWS_DIR $out/views \
--set PUBLIC_DIR $out/public

682
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,41 +1,44 @@
{
"name": "amdl",
"version": "1.0.0",
"description": "amdl",
"description": "an apple music downloader",
"author": "reidlab",
"license": "MIT",
"type": "module",
"scripts": {
"dev": "concurrently --prefix none 'node --watch dist/index.js' 'tsc --watch'",
"dev": "concurrently 'node --watch dist/src/index.js' 'tsc --watch'",
"build": "npm run lint && tsc",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"axios": "^1.7.9",
"axios": "^1.11.0",
"callsites": "^4.2.0",
"chalk": "^5.4.1",
"data-uri-to-buffer": "^6.0.2",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-handlebars": "^8.0.1",
"dotenv": "^17.2.1",
"express": "^5.1.0",
"express-handlebars": "^8.0.3",
"format-duration": "^3.0.2",
"node-widevine": "^0.1.3",
"parse-hls": "^1.0.7",
"pssh-tools": "^1.2.0",
"source-map-support": "^0.5.21",
"swagger-ui-express": "^5.0.1",
"timeago.js": "^4.0.2",
"toml": "^3.0.0",
"zod": "^3.24.2",
"zod-validation-error": "^3.4.0"
"zod": "^4.0.14",
"zod-openapi": "^5.3.0",
"zod-validation-error": "^4.0.1"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/express": "^5.0.3",
"@types/source-map-support": "^0.5.10",
"@types/swagger-ui-express": "^4.1.8",
"@typescript-eslint/parser": "^7.12.0",
"concurrently": "^9.1.2",
"concurrently": "^9.2.0",
"eslint": "^8.57.0",
"typescript": "^5.4.5",
"typescript": "^5.9.2",
"typescript-eslint": "^7.13.0"
}
}

View file

@ -1,11 +1,11 @@
import fs from "node:fs";
import * as log from "./log.js";
import toml from "toml";
import { z, ZodError, ZodSchema } from "zod";
import { z, ZodError, ZodObject } from "zod";
import * as dotenv from "dotenv";
import { fromZodError } from "zod-validation-error";
dotenv.config();
dotenv.config({ quiet: true });
const configSchema = z.object({
server: z.object({
@ -57,7 +57,7 @@ if (!fs.existsSync("config.toml")) {
* @param something the thing to load the schema from--either a **file path to a toml file** or **an object** (e.g. process.env)
* @returns the inferred type of the schema
*/
function loadSchemaSomething<T extends ZodSchema>(schema: T, something: string | unknown): z.infer<T> {
function loadSchemaSomething<T extends ZodObject>(schema: T, something: string | unknown): z.infer<T> {
try {
if (typeof something === "string") {
return schema.parse(toml.parse(fs.readFileSync(something, "utf-8")));

View file

@ -6,19 +6,32 @@ import { appleMusicApi } from "../../../appleMusicApi/index.js";
import { z } from "zod";
import { validate } from "../../validate.js";
import { CodecType, regularCodecTypeSchema, webplaybackCodecTypeSchema, type RegularCodecType, type WebplaybackCodecType } from "../../../downloader/codecType.js";
import { paths } from "../../openApi.js";
const router = express.Router();
const path = "/download";
const schema = z.object({
query: z.object({
id: z.string(),
codec: regularCodecTypeSchema.or(webplaybackCodecTypeSchema)
codec: z.enum([...regularCodecTypeSchema.options, ...webplaybackCodecTypeSchema.options])
})
});
paths[path] = {
get: {
requestParams: { query: schema.shape.query },
responses: {
200: { description: "returns a song in an mp4 container" },
400: { description: "bad request, invalid query parameters. sent as a zod error with details" },
default: { description: "upstream api error, or some other error" }
}
}
};
// TODO: support more encryption schemes
// TODO: some type of agnostic-ness for the encryption schemes on regular codec
router.get("/download", async (req, res, next) => {
router.get(path, async (req, res, next) => {
try {
const { id, codec } = (await validate(req, schema)).query;

View file

@ -2,18 +2,31 @@ import { appleMusicApi } from "../../../appleMusicApi/index.js";
import express from "express";
import { validate } from "../../validate.js";
import { z } from "zod";
import { paths } from "../../openApi.js";
const router = express.Router();
const path = "/getAlbumMetadata";
const schema = z.object({
query: z.object({
id: z.string()
})
});
paths[path] = {
get: {
requestParams: { query: schema.shape.query },
responses: {
200: { description: "returns from the apple music api, album metadata with `tracks` relationship https://developer.apple.com/documentation/applemusicapi/get-a-catalog-album" },
400: { description: "bad request, invalid query parameters. sent as a zod error with details" },
default: { description: "upstream api error, or some other error" }
}
}
};
// see comments in `getTrackMetadata.ts`
// awawawawawa
router.get("/getAlbumMetadata", async (req, res, next) => {
router.get(path, async (req, res, next) => {
try {
const { id } = (await validate(req, schema)).query;

View file

@ -2,18 +2,31 @@ import { appleMusicApi } from "../../../appleMusicApi/index.js";
import express from "express";
import { validate } from "../../validate.js";
import { z } from "zod";
import { paths } from "../../openApi.js";
const router = express.Router();
const path = "/getPlaylistMetadata";
const schema = z.object({
query: z.object({
id: z.string()
})
});
paths[path] = {
get: {
requestParams: { query: schema.shape.query },
responses: {
200: { description: "returns from the apple music api, playlist metadata with `tracks` relationship https://developer.apple.com/documentation/applemusicapi/get-a-catalog-playlist" },
400: { description: "bad request, invalid query parameters. sent as a zod error with details" },
default: { description: "upstream api error, or some other error" }
}
}
};
// see comments in `getTrackMetadata.ts`
// awawawawawa
router.get("/getPlaylistMetadata", async (req, res, next) => {
router.get(path, async (req, res, next) => {
try {
const { id } = (await validate(req, schema)).query;

View file

@ -2,19 +2,32 @@ import { appleMusicApi } from "../../../appleMusicApi/index.js";
import express from "express";
import { validate } from "../../validate.js";
import { z } from "zod";
import { paths } from "../../openApi.js";
const router = express.Router();
const path = "/getTrackMetadata";
const schema = z.object({
query: z.object({
id: z.string()
})
});
paths[path] = {
get: {
requestParams: { query: schema.shape.query },
responses: {
200: { description: "returns from the apple music api, track metadata with `extendedAssetUrls` extension and `albums` relationship https://developer.apple.com/documentation/applemusicapi/get-a-catalog-song" },
400: { description: "bad request, invalid query parameters. sent as a zod error with details" },
default: { description: "upstream api error, or some other error" }
}
}
};
// this endpoint isn't actually used for anything by us
// it's for people who want to implement apple music downloading into their own apps (ex. discord music bot)
// it makes it a bit easier to get the metadata for a track knowing the trackId
router.get("/getTrackMetadata", async (req, res, next) => {
router.get(path, async (req, res, next) => {
try {
const { id } = (await validate(req, schema)).query;

View file

@ -0,0 +1,20 @@
import express from "express";
import swaggerUi from "swagger-ui-express";
import { doc } from "../../openApi.js";
const router = express.Router();
router.use("/documentation", swaggerUi.serve);
router.get("/documentation", async (_req, res, next) => {
try {
res.send(swaggerUi.generateHTML(doc, {
customCss: ".swagger-ui .topbar { display: none }",
customSiteTitle: "amdl - documentation",
customfavIcon: "/favicon.png"
}));
} catch (err) {
next(err);
}
});
export default router;

View file

@ -1,6 +1,8 @@
import documentation from "./front/documentation.js";
import frontDownload from "./front/download.js";
import search from "./front/search.js";
export const front = [
documentation,
frontDownload,
search
];

View file

@ -7,6 +7,7 @@ import { ZodError } from "zod";
import { fromZodError } from "zod-validation-error";
import { AxiosError } from "axios";
import { env } from "../config.js";
import { createOpenApiDocument } from "./openApi.js";
export class HttpException extends Error {
public readonly status?: number;
@ -38,9 +39,15 @@ app.set("views", env.VIEWS_DIR);
app.use("/", express.static(env.PUBLIC_DIR));
app.get("/favicon.ico", (_req, res) => { res.status(301).location("/favicon.png").send(); });
// TODO: customize the "/api" prefix
// currently hardcoded in places like the frontend and openapi document
back.forEach((route) => { app.use("/api", route); });
front.forEach((route) => { app.use(route); });
// this is a bit of a hack, but it works
// we need to create the openapi document after all routes are registered, but before serving starts
createOpenApiDocument();
app.use((req, _res, next) => {
next(new HttpException(404, `${req.path} not found`));
});

35
src/web/openApi.ts Normal file
View file

@ -0,0 +1,35 @@
import { createDocument } from "zod-openapi";
import packageJson from "../../package.json" with { type: "json" };
// i hate doing this
// classic maneuver tho!
type OpenAPIObject = ReturnType<typeof createDocument>;
type ZodOpenApiPathsObject = NonNullable<Parameters<typeof createDocument>[0]["paths"]>;
export const paths: ZodOpenApiPathsObject = {};
// this seems a little race-conditiony
// but, it's actually pretty safe! in the web index file, we call createOpenApiDocument
// after all routes are registered, so this will be populated by then (then being serving)
export let doc: OpenAPIObject;
export function createOpenApiDocument(): void {
doc = createDocument({
openapi: "3.1.1", // most recent at the time of writing
info: {
title: packageJson.name,
version: packageJson.version,
description: packageJson.description,
contact: {
name: "reidlab",
url: "https://reidlab.pink/socials"
}
},
servers: [{
url: "/api"
}],
paths: {
...paths
}
});
}

View file

@ -1,7 +1,7 @@
import express from "express";
import { z, ZodSchema } from "zod";
import { z, ZodObject } from "zod";
export async function validate<T extends ZodSchema>(req: express.Request, schema: T): Promise<z.infer<T>> {
export async function validate<T extends ZodObject>(req: express.Request, schema: T): Promise<z.infer<T>> {
const result = await schema.parseAsync(req);
return result;
}

View file

@ -10,6 +10,7 @@
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true,
"resolveJsonModule": true,
"baseUrl": "src",
"outDir": "dist"
},

View file

@ -1,5 +1,7 @@
<footer>
<a href="https://git.reidlab.pink/reidlab/amdl" target="_blank">source</a>
&middot;
<a href="/documentation">documentation</a>
&middot;
<a href="https://reidlab.pink/socials" target="_blank">need to contact me?</a>
</footer>