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": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1739866667, "lastModified": 1753939845,
"narHash": "sha256-EO1ygNKZlsAC9avfcwHkKGMsmipUk1Uc0TbrEZpkn64=", "narHash": "sha256-K2ViRJfdVGE8tpJejs8Qpvvejks1+A4GQej/lBk5y7I=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "73cf49b8ad837ade2de76f87eb53fc85ed5d4680", "rev": "94def634a20494ee057c76998843c015909d6311",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

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

686
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,11 +1,11 @@
import fs from "node:fs"; import fs from "node:fs";
import * as log from "./log.js"; import * as log from "./log.js";
import toml from "toml"; import toml from "toml";
import { z, ZodError, ZodSchema } from "zod"; import { z, ZodError, ZodObject } from "zod";
import * as dotenv from "dotenv"; import * as dotenv from "dotenv";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
dotenv.config(); dotenv.config({ quiet: true });
const configSchema = z.object({ const configSchema = z.object({
server: 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) * @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 * @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 { try {
if (typeof something === "string") { if (typeof something === "string") {
return schema.parse(toml.parse(fs.readFileSync(something, "utf-8"))); 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 { z } from "zod";
import { validate } from "../../validate.js"; import { validate } from "../../validate.js";
import { CodecType, regularCodecTypeSchema, webplaybackCodecTypeSchema, type RegularCodecType, type WebplaybackCodecType } from "../../../downloader/codecType.js"; import { CodecType, regularCodecTypeSchema, webplaybackCodecTypeSchema, type RegularCodecType, type WebplaybackCodecType } from "../../../downloader/codecType.js";
import { paths } from "../../openApi.js";
const router = express.Router(); const router = express.Router();
const path = "/download";
const schema = z.object({ const schema = z.object({
query: z.object({ query: z.object({
id: z.string(), 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: support more encryption schemes
// TODO: some type of agnostic-ness for the encryption schemes on regular codec // 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 { try {
const { id, codec } = (await validate(req, schema)).query; 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 express from "express";
import { validate } from "../../validate.js"; import { validate } from "../../validate.js";
import { z } from "zod"; import { z } from "zod";
import { paths } from "../../openApi.js";
const router = express.Router(); const router = express.Router();
const path = "/getAlbumMetadata";
const schema = z.object({ const schema = z.object({
query: z.object({ query: z.object({
id: z.string() 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` // see comments in `getTrackMetadata.ts`
// awawawawawa // awawawawawa
router.get("/getAlbumMetadata", async (req, res, next) => { router.get(path, async (req, res, next) => {
try { try {
const { id } = (await validate(req, schema)).query; const { id } = (await validate(req, schema)).query;

View file

@ -2,18 +2,31 @@ import { appleMusicApi } from "../../../appleMusicApi/index.js";
import express from "express"; import express from "express";
import { validate } from "../../validate.js"; import { validate } from "../../validate.js";
import { z } from "zod"; import { z } from "zod";
import { paths } from "../../openApi.js";
const router = express.Router(); const router = express.Router();
const path = "/getPlaylistMetadata";
const schema = z.object({ const schema = z.object({
query: z.object({ query: z.object({
id: z.string() 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` // see comments in `getTrackMetadata.ts`
// awawawawawa // awawawawawa
router.get("/getPlaylistMetadata", async (req, res, next) => { router.get(path, async (req, res, next) => {
try { try {
const { id } = (await validate(req, schema)).query; const { id } = (await validate(req, schema)).query;

View file

@ -2,19 +2,32 @@ import { appleMusicApi } from "../../../appleMusicApi/index.js";
import express from "express"; import express from "express";
import { validate } from "../../validate.js"; import { validate } from "../../validate.js";
import { z } from "zod"; import { z } from "zod";
import { paths } from "../../openApi.js";
const router = express.Router(); const router = express.Router();
const path = "/getTrackMetadata";
const schema = z.object({ const schema = z.object({
query: z.object({ query: z.object({
id: z.string() 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 // 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'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 // 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 { try {
const { id } = (await validate(req, schema)).query; 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 frontDownload from "./front/download.js";
import search from "./front/search.js"; import search from "./front/search.js";
export const front = [ export const front = [
documentation,
frontDownload, frontDownload,
search search
]; ];

View file

@ -7,6 +7,7 @@ import { ZodError } from "zod";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { env } from "../config.js"; import { env } from "../config.js";
import { createOpenApiDocument } from "./openApi.js";
export class HttpException extends Error { export class HttpException extends Error {
public readonly status?: number; public readonly status?: number;
@ -38,9 +39,15 @@ app.set("views", env.VIEWS_DIR);
app.use("/", express.static(env.PUBLIC_DIR)); app.use("/", express.static(env.PUBLIC_DIR));
app.get("/favicon.ico", (_req, res) => { res.status(301).location("/favicon.png").send(); }); 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); }); back.forEach((route) => { app.use("/api", route); });
front.forEach((route) => { app.use(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) => { app.use((req, _res, next) => {
next(new HttpException(404, `${req.path} not found`)); 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 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); const result = await schema.parseAsync(req);
return result; return result;
} }

View file

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

View file

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