websӘFacebook ite

This commit is contained in:
Reid 2025-04-29 17:56:59 -07:00
parent f233d9e64f
commit 76543fd220
Signed by: reidlab
GPG key ID: DAF5EAF6665839FD
20 changed files with 1051 additions and 96 deletions

View file

@ -3,10 +3,9 @@ import { spawn } from "node:child_process";
import path from "node:path";
import { addToCache, isCached } from "../cache.js";
// TODO: make this have a return type (file path)
// TODO: refresh cache timer on download
// TODO: remux to m4a?
export async function downloadSong(streamUrl: string, decryptionKey: string, songCodec: RegularCodec | WebplaybackCodec): Promise<void> {
export async function downloadSong(streamUrl: string, decryptionKey: string, songCodec: RegularCodec | WebplaybackCodec): Promise<string> {
let baseOutputName = streamUrl.split("/").at(-1)?.split("?").at(0)?.split(".").splice(0, 1).join(".")?.trim();
if (!baseOutputName) { throw "could not get base output name from stream url"; }
baseOutputName += `_${songCodec}`;
@ -18,7 +17,7 @@ export async function downloadSong(streamUrl: string, decryptionKey: string, son
if ( // TODO: remove check for encrypted file/cache for encrypted?
isCached(encryptedName) &&
isCached(decryptedName)
) { return; }
) { return decryptedPath; }
await new Promise<void>((res, rej) => {
const child = spawn(config.downloader.ytdlp_path, [
@ -50,6 +49,8 @@ export async function downloadSong(streamUrl: string, decryptionKey: string, son
addToCache(encryptedName);
addToCache(decryptedName);
return decryptedPath;
}
// TODO: find a better spot for this

View file

@ -1,13 +1,11 @@
import { appleMusicApi } from "../api/index.js";
import * as log from "../log.js";
import type { SongAttributes } from "../api/types/appleMusic/attributes.js";
import hls, { Item } from "parse-hls";
import axios from "axios";
import { getWidevineDecryptionKey } from "./keygen.js";
import { widevine, playready, fairplay } from "../constants/keyFormats.js";
import { songCodecRegex } from "../constants/codecs.js";
import type { WebplaybackResponse } from "api/appleMusicApi.js";
import { downloadSong, RegularCodec, WebplaybackCodec } from "./index.js";
import { RegularCodec, WebplaybackCodec } from "./index.js";
// why is this private
// i wish pain on the person who wrote this /j :smile:
@ -188,31 +186,3 @@ function getDrmData(drmInfos: DrmInfos, drmIds: string[], drmKey: string): strin
const getWidevinePssh = (drmInfos: DrmInfos, drmIds: string[]): string | undefined => getDrmData(drmInfos, drmIds, widevine);
const getPlayreadyPssh = (drmInfos: DrmInfos, drmIds: string[]): string | undefined => getDrmData(drmInfos, drmIds, playready);
const getFairplayKey = (drmInfos: DrmInfos, drmIds: string[]): string | undefined => getDrmData(drmInfos, drmIds, fairplay);
// TODO: remove later, this is just for testing
// const streamInfo2 = await StreamInfo.fromTrackMetadata((await appleMusicApi.getSong("1615276490")).data[0].attributes, RegularCodec.Aac);
const streamCodec1 = WebplaybackCodec.AacLegacy;
const streamInfo1 = await StreamInfo.fromWebplayback(await appleMusicApi.getWebplayback("1705366148"), streamCodec1);
if (streamInfo1.widevinePssh !== undefined) {
await downloadSong(
streamInfo1.streamUrl,
await getWidevineDecryptionKey(streamInfo1.widevinePssh, streamInfo1.trackId),
streamCodec1
);
}
// try {
// const streamCodec2 = RegularCodec.AacHe;
// const streamInfo2 = await StreamInfo.fromTrackMetadata((await appleMusicApi.getSong("1705366148")).data[0].attributes, streamCodec2);
// if (streamInfo2.widevinePssh !== undefined) {
// await downloadSong(
// streamInfo2.streamUrl,
// await getWidevineDecryptionKey(streamInfo2.widevinePssh, streamInfo2.trackId),
// streamCodec2
// );
// }
// } catch (err) {
// log.error("failed to download song");
// log.error(err);
// }

View file

@ -1,43 +1,8 @@
import { config } from "./config.js";
import express, { type NextFunction, type Request, type Response } from "express";
import process from "node:process";
import * as log from "./log.js";
import { appleMusicApi } from "./api/index.js";
export class HttpException extends Error {
public readonly status?: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.message = message;
}
}
const app = express();
app.disable("x-powered-by");
app.set("trust proxy", ["loopback", "uniquelocal"]);
app.use("/", express.static("public"));
app.use("/data", express.static(config.downloader.cache.directory, { extensions: ["mp4"] }));
app.use((req, _res, next) => {
next(new HttpException(404, `${req.path} not found`));
});
app.use((err: HttpException, _req: Request, res: Response, _next: NextFunction) => {
if (!err.status || err.status % 500 < 100) {
log.error(err);
}
const status = err.status ?? 500;
const message = err.message;
res.status(status).send(message);
});
import { app } from "./web/index.js";
await appleMusicApi.login().catch((err) => {
log.error("failed to login to apple music api");

View file

@ -0,0 +1,35 @@
import { getWidevineDecryptionKey } from "../../../downloader/keygen.js";
import { downloadSong, RegularCodec } from "../../../downloader/index.js";
import express from "express";
import StreamInfo from "../../../downloader/streamInfo.js";
import { appleMusicApi } from "../../../api/index.js";
const router = express.Router();
// TODO: support more encryption schemes
// TODO: some type of agnostic-ness for the encryption keys
router.get("/dlTrackMetadata", async (req, res, next) => {
try {
const { trackId, codec } = req.query;
if (typeof trackId !== "string") { res.status(400).send("trackId is required and must be a string!"); return; }
if (typeof codec !== "string") { res.status(400).send("codec is required and must be a string!"); return; }
const c = Object.values(RegularCodec).find((c) => { return c === codec; });
if (c === undefined) { res.status(400).send("codec is invalid!"); return; }
const trackMetadata = await appleMusicApi.getSong(trackId);
const trackAttributes = trackMetadata.data[0].attributes;
const streamInfo = await StreamInfo.fromTrackMetadata(trackAttributes, c);
if (streamInfo.widevinePssh !== undefined) {
const decryptionKey = await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId);
const filePath = await downloadSong(streamInfo.streamUrl, decryptionKey, c);
res.download(filePath);
} else {
res.status(400).send("no decryption key found!");
}
} catch (err) {
next(err);
}
});
export default router;

View file

@ -0,0 +1,34 @@
import { getWidevineDecryptionKey } from "../../../downloader/keygen.js";
import { downloadSong, WebplaybackCodec } from "../../../downloader/index.js";
import express from "express";
import StreamInfo from "../../../downloader/streamInfo.js";
import { appleMusicApi } from "../../../api/index.js";
const router = express.Router();
router.get("/dlWebplayback", async (req, res, next) => {
try {
const { trackId, codec } = req.query;
if (typeof trackId !== "string") { res.status(400).send("trackId is required and must be a string!"); return; }
if (typeof codec !== "string") { res.status(400).send("codec is required and must be a string!"); return; }
const c = Object.values(WebplaybackCodec).find((c) => { return c === codec; });
if (c === undefined) { res.status(400).send("codec is invalid!"); return; }
// TODO: check if this returns an error
const webplaybackResponse = await appleMusicApi.getWebplayback(trackId);
console.log(webplaybackResponse);
const streamInfo = await StreamInfo.fromWebplayback(webplaybackResponse, c);
if (streamInfo.widevinePssh !== undefined) {
const decryptionKey = await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId);
const filePath = await downloadSong(streamInfo.streamUrl, decryptionKey, c);
res.download(filePath);
} else {
res.status(400).send("no decryption key found!");
}
} catch (err) {
next(err);
}
});
export default router;

View file

@ -0,0 +1,23 @@
import { appleMusicApi } from "../../../api/index.js";
import express from "express";
const router = express.Router();
// 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
// it makes it a bit easier to get the metadata for a track knowing the trackId
router.get("/getTrackMetadata", async (req, res, next) => {
try {
const { trackId } = req.query;
if (typeof trackId !== "string") { res.status(400).send("trackId is required and must be a string!"); return; }
const trackMetadata = await appleMusicApi.getSong(trackId);
const trackAttributes = trackMetadata.data[0].attributes;
res.json(trackAttributes);
} catch (err) {
next(err);
}
});
export default router;

View file

@ -0,0 +1,45 @@
import express from "express";
import gitRepoInfo from "git-rev-sync";
// TODO: move this into a helper or whatever?
// i don't wanna do this for every single page lol
const hash = gitRepoInfo.short("./");
const dirty = gitRepoInfo.isDirty();
const router = express.Router();
// TODO: implement this
// TODO: show tracks
// TODO: add a download button
router.get("/", (req, res) => {
res.render("search", {
title: "search",
hash: hash,
dirty: dirty,
query: req.query.q,
results: [
{
name: "Revengeseekerz",
artists: ["Jane Remover"],
cover: "https://is1-ssl.mzstatic.com/image/thumb/Music221/v4/18/cf/f6/18cff6df-c7b6-0ca1-8067-83743f6c1f8a/193436418720_coverGriffinMcMahon.jpg/592x592bb.webp"
}
// {
// name: "Carousel (An Examination of the Shadow, Creekflow, And its Life as an Afterthought) ",
// artists: ["Vylet Pony"],
// tracks: [
// {
// artists: ["Vylet Pony"],
// name: "Carousel"
// },
// {
// artists: ["Vylet Pony", "Namii"],
// name: "The Shadow"
// }
// ],
// cover: "https://is1-ssl.mzstatic.com/image/thumb/Music116/v4/7c/f0/94/7cf09429-4942-a9cb-1287-b8bbc53a4d61/artwork.jpg/592x592bb.webp"
// }
]
});
});
export default router;

50
src/web/index.ts Normal file
View file

@ -0,0 +1,50 @@
import * as log from "../log.js";
import express, { type NextFunction, type Request, type Response } from "express";
import { engine } from "express-handlebars";
import dlTrackMetadata from "./endpoints/back/dlTrackMetadata.js";
import dlWebplayback from "./endpoints/back/dlWebplayback.js";
import getTrackMetadata from "./endpoints/back/getTrackMetadata.js";
import search from "./endpoints/front/search.js";
export class HttpException extends Error {
public readonly status?: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.message = message;
}
}
const app = express();
app.set("trust proxy", ["loopback", "uniquelocal"]);
app.engine("handlebars", engine());
app.set("view engine", "handlebars");
app.set("views", "./views");
app.use("/", express.static("public"));
app.use(dlTrackMetadata);
app.use(dlWebplayback);
app.use(getTrackMetadata);
app.use(search);
app.use((req, _res, next) => {
next(new HttpException(404, `${req.path} not found`));
});
app.use((err: HttpException, _req: Request, res: Response, _next: NextFunction) => {
if (!err.status || err.status % 500 < 100) {
log.error(err);
}
const status = err.status ?? 500;
const message = err.message;
res.status(status).send(message);
});
export { app };