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": {
|
"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": {
|
||||||
|
|
|
@ -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
686
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",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")));
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
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 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
|
||||||
];
|
];
|
||||||
|
|
|
@ -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
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 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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
@ -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>
|
||||||
·
|
·
|
||||||
|
<a href="/documentation">documentation</a>
|
||||||
|
·
|
||||||
<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>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue