websӘFacebook ite
This commit is contained in:
parent
f233d9e64f
commit
76543fd220
20 changed files with 1051 additions and 96 deletions
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
// }
|
||||
|
|
37
src/index.ts
37
src/index.ts
|
@ -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");
|
||||
|
|
35
src/web/endpoints/back/dlTrackMetadata.ts
Normal file
35
src/web/endpoints/back/dlTrackMetadata.ts
Normal 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;
|
34
src/web/endpoints/back/dlWebplayback.ts
Normal file
34
src/web/endpoints/back/dlWebplayback.ts
Normal 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;
|
23
src/web/endpoints/back/getTrackMetadata.ts
Normal file
23
src/web/endpoints/back/getTrackMetadata.ts
Normal 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;
|
45
src/web/endpoints/front/search.ts
Normal file
45
src/web/endpoints/front/search.ts
Normal 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
50
src/web/index.ts
Normal 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 };
|
Loading…
Add table
Add a link
Reference in a new issue