closes #1; openapi! yay
This commit is contained in:
parent
b560060b45
commit
7b15834f17
16 changed files with 519 additions and 341 deletions
6
flake.lock
generated
6
flake.lock
generated
|
@ -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": {
|
||||
|
|
|
@ -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
682
package-lock.json
generated
File diff suppressed because it is too large
Load diff
25
package.json
25
package.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")));
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
20
src/web/endpoints/front/documentation.ts
Normal file
20
src/web/endpoints/front/documentation.ts
Normal 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;
|
|
@ -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
|
||||
];
|
||||
|
|
|
@ -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
35
src/web/openApi.ts
Normal 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
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"baseUrl": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
<footer>
|
||||
<a href="https://git.reidlab.pink/reidlab/amdl" target="_blank">source</a>
|
||||
·
|
||||
<a href="/documentation">documentation</a>
|
||||
·
|
||||
<a href="https://reidlab.pink/socials" target="_blank">need to contact me?</a>
|
||||
</footer>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue