downlederigoೃ
This commit is contained in:
parent
4954f84a48
commit
b8da59086b
10 changed files with 170 additions and 19 deletions
87
src/cache.ts
87
src/cache.ts
|
@ -1,23 +1,98 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import timeago from "timeago.js";
|
||||
import { config } from "./config.js";
|
||||
import * as log from "./log.js";
|
||||
|
||||
// DO NOT READ FURTHER INTO THIS FILE
|
||||
// COGNITIVE DISSONANCE WARNING
|
||||
|
||||
// TODO: hourly cache reports
|
||||
// TODO: swap to sqlite
|
||||
// TODO: make async fs calls
|
||||
// TODO: rework EVERYTHING
|
||||
|
||||
interface CacheEntry {
|
||||
fileName: string;
|
||||
expiry: number;
|
||||
expiry: number; // milliseconds, not seconds
|
||||
}
|
||||
|
||||
const ttl = config.downloader.cache.ttl * 1000;
|
||||
const file = path.join(config.downloader.cache.directory, "cache.json");
|
||||
const cacheTtl = config.downloader.cache.ttl * 1000;
|
||||
const cacheFile = path.join(config.downloader.cache.directory, "cache.json");
|
||||
|
||||
if (!fs.existsSync(config.downloader.cache.directory)) {
|
||||
log.debug("cache directory not found, creating it");
|
||||
fs.mkdirSync(config.downloader.cache.directory, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(file)) {
|
||||
if (!fs.existsSync(cacheFile)) {
|
||||
log.debug("cache file not found, creating it");
|
||||
fs.writeFileSync(file, JSON.stringify([]), { encoding: "utf-8" });
|
||||
fs.writeFileSync(cacheFile, JSON.stringify([]), { encoding: "utf-8" });
|
||||
}
|
||||
|
||||
// SUPER TODO: implement this
|
||||
let cache = JSON.parse(fs.readFileSync(cacheFile, { encoding: "utf-8" })) as CacheEntry[];
|
||||
|
||||
// TODO: change how this works
|
||||
// this is so uncomfy
|
||||
cache.push = function(...items: CacheEntry[]): number {
|
||||
for (const entry of items) {
|
||||
log.debug(`cache entry ${entry.fileName} added, expires ${timeago.format(entry.expiry)}`);
|
||||
setTimeout(() => {
|
||||
log.debug(`cache entry ${entry.fileName} expired, cleaning`);
|
||||
removeCacheEntry(entry.fileName);
|
||||
rewriteCache();
|
||||
}, entry.expiry - Date.now());
|
||||
}
|
||||
|
||||
return Array.prototype.push.apply(this, items);
|
||||
};
|
||||
|
||||
function rewriteCache(): void {
|
||||
// cache is in fact []. i checked
|
||||
fs.writeFileSync(cacheFile, JSON.stringify(cache), { encoding: "utf-8" });
|
||||
}
|
||||
|
||||
function removeCacheEntry(fileName: string): void {
|
||||
cache = cache.filter((entry) => { return entry.fileName !== fileName; });
|
||||
try {
|
||||
fs.unlinkSync(path.join(config.downloader.cache.directory, fileName));
|
||||
} catch (err) {
|
||||
log.error(`could not remove cache entry ${fileName}`);
|
||||
log.error("this could result in 2 effects:");
|
||||
log.error("1. the cache entry will be removed, and the file never existed, operation is perfect, ignore this");
|
||||
log.error("2. the cache entry will be removed, but the file exists, so it will remain in the filesystem");
|
||||
log.error("if you experience the latter, the manual deletion of the file is required to fix this.");
|
||||
}
|
||||
}
|
||||
|
||||
// clear cache entries that are expired
|
||||
// this is for when the program is killed when cache entries are present
|
||||
// those could expire while the program is not running, therefore not being cleaned
|
||||
let expiryLogPrinted = false;
|
||||
for (const entry of cache) {
|
||||
if (entry.expiry < Date.now()) {
|
||||
if (!expiryLogPrinted) { log.info("old expired cache entries are present, cleaning them"); }
|
||||
expiryLogPrinted = true;
|
||||
log.debug(`cache entry ${entry.fileName} expired ${timeago.format(entry.expiry)}; cleaning`);
|
||||
removeCacheEntry(entry.fileName);
|
||||
rewriteCache();
|
||||
}
|
||||
}
|
||||
|
||||
export function isCached(fileName: string): boolean {
|
||||
const entry = cache.find((e) => { return e.fileName === fileName; });
|
||||
const cached = entry !== undefined && entry.expiry > Date.now();
|
||||
if (cached) { log.debug(`cache HIT for ${fileName}`); }
|
||||
else { log.debug(`cache MISS for ${fileName}`); }
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function addToCache(fileName: string): void {
|
||||
cache.push({
|
||||
fileName: fileName,
|
||||
expiry: Date.now() + cacheTtl
|
||||
});
|
||||
rewriteCache();
|
||||
}
|
||||
|
||||
// setTimeout(() => addToCache("jorry.tx"), 1000);
|
||||
|
||||
|
|
|
@ -12,6 +12,8 @@ const configSchema = z.object({
|
|||
port: z.number().int().min(0).max(65535).or(z.string())
|
||||
}),
|
||||
downloader: z.object({
|
||||
ffmpeg_path: z.string(),
|
||||
ytdlp_path: z.string(),
|
||||
cache: z.object({
|
||||
directory: z.string(),
|
||||
ttl: z.number().int().min(0)
|
||||
|
|
50
src/downloader/index.ts
Normal file
50
src/downloader/index.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { config } from "../config.js";
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { addToCache, isCached } from "../cache.js";
|
||||
|
||||
// TODO: make this have a return type
|
||||
export async function downloadSong(streamUrl: string, decryptionKey: string): Promise<void> {
|
||||
const 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"; }
|
||||
const encryptedName = baseOutputName + "_enc.mp4";
|
||||
const encryptedPath = path.join(config.downloader.cache.directory, encryptedName);
|
||||
const decryptedName = baseOutputName + ".mp4";
|
||||
const decryptedPath = path.join(config.downloader.cache.directory, decryptedName);
|
||||
|
||||
if ( // TODO: remove check for encrypted file/cache for encrypted?
|
||||
isCached(encryptedName) &&
|
||||
isCached(decryptedName)
|
||||
) { return; }
|
||||
|
||||
await new Promise<void>((res, rej) => {
|
||||
const child = spawn(config.downloader.ytdlp_path, [
|
||||
"--quiet",
|
||||
"--no-warnings",
|
||||
"--allow-unplayable-formats",
|
||||
"--fixup", "never",
|
||||
"--paths", config.downloader.cache.directory,
|
||||
"--output", encryptedName,
|
||||
streamUrl
|
||||
]).on("error", (err) => { rej(err); });
|
||||
child.stderr.on("data", (chunk) => { rej(chunk); });
|
||||
child.on("exit", () => { res(); });
|
||||
});
|
||||
|
||||
await new Promise<void>((res, rej) => {
|
||||
const child = spawn(config.downloader.ffmpeg_path, [
|
||||
"-loglevel", "error",
|
||||
"-y",
|
||||
"-decryption_key", decryptionKey,
|
||||
"-i", encryptedPath,
|
||||
"-c", "copy",
|
||||
"-movflags", "+faststart",
|
||||
decryptedPath
|
||||
]).on("error", (err) => { rej(err); });
|
||||
child.stderr.on("data", (chunk) => { rej(chunk); });
|
||||
child.on("exit", () => { res(); } );
|
||||
});
|
||||
|
||||
addToCache(encryptedName);
|
||||
addToCache(decryptedName);
|
||||
}
|
|
@ -19,7 +19,7 @@ export async function getWidevineDecryptionKey(psshDataUri: string, trackId: str
|
|||
// for some reason, if gotten from a webplayback manifest, the pssh is in a completely different format
|
||||
// well, somewhat. it's just the raw data, we have to rebuild the pssh
|
||||
const rebuiltPssh = psshTools.widevine.encodePssh({
|
||||
contentId: "Hiiii", // lol?? i don't know what this is, random slop go!!!!
|
||||
contentId: "meow", // this actually isn't even needed, but this library is stubborn
|
||||
dataOnly: false,
|
||||
keyIds: [Buffer.from(dataUriToBuffer(psshDataUri).buffer).toString("hex")]
|
||||
});
|
||||
|
|
|
@ -7,6 +7,7 @@ import { getWidevineDecryptionKey } from "./keygen.js";
|
|||
import { widevine, playready, fairplay } from "../constants/keyFormats.js";
|
||||
import { select } from "@inquirer/prompts";
|
||||
import type { WebplaybackResponse } from "api/appleMusicApi.js";
|
||||
import { downloadSong } from "./index.js";
|
||||
|
||||
// ugliest type ever
|
||||
// this library is so bad
|
||||
|
@ -27,24 +28,29 @@ type M3u8 = ReturnType<typeof hls.default.parse>;
|
|||
// OH. it doesn't seem to give the keys you want anyway LOLLLLLLL????
|
||||
// i'm sure its used for *SOMETHING* so i'll keep it
|
||||
|
||||
export class StreamInfo {
|
||||
export default class StreamInfo {
|
||||
public readonly trackId: string;
|
||||
public readonly streamUrl: string;
|
||||
public readonly widevinePssh: string | undefined;
|
||||
public readonly playreadyPssh: string | undefined;
|
||||
public readonly fairplayKey: string | undefined;
|
||||
|
||||
private constructor(
|
||||
trackId: string,
|
||||
streamUrl: string,
|
||||
widevinePssh: string | undefined,
|
||||
playreadyPssh: string | undefined,
|
||||
fairplayKey: string | undefined
|
||||
) {
|
||||
this.trackId = trackId;
|
||||
this.streamUrl = streamUrl;
|
||||
this.widevinePssh = widevinePssh;
|
||||
this.playreadyPssh = playreadyPssh;
|
||||
this.fairplayKey = fairplayKey;
|
||||
}
|
||||
|
||||
// TODO: why can't we decrypt widevine ones with this?
|
||||
// we get a valid key.. but it doesn't work :-(
|
||||
public static async fromTrackMetadata(trackMetadata: SongAttributes<["extendedAssetUrls"]>): Promise<StreamInfo> {
|
||||
log.warn("the track metadata method is experimental, and may not work or give correct values!");
|
||||
log.warn("if there is a failure--use a codec that uses the webplayback method");
|
||||
|
@ -70,6 +76,7 @@ export class StreamInfo {
|
|||
|
||||
return new StreamInfo(
|
||||
trackId,
|
||||
m3u8Url, // TODO: make this keep in mind the CODEC, yt-dlp will shit itself if not supplied i think
|
||||
widevinePssh,
|
||||
playreadyPssh,
|
||||
fairplayKey
|
||||
|
@ -95,6 +102,7 @@ export class StreamInfo {
|
|||
// afaik this ONLY has widevine
|
||||
return new StreamInfo(
|
||||
trackId,
|
||||
m3u8Url,
|
||||
widevinePssh,
|
||||
undefined,
|
||||
undefined
|
||||
|
@ -121,7 +129,6 @@ function getDrmInfos(m3u8Data: M3u8): DrmInfos {
|
|||
throw "m3u8 missing audio session key info!";
|
||||
}
|
||||
|
||||
|
||||
type AssetInfos = { [key: string]: { "AUDIO-SESSION-KEY-IDS": string[]; }; }
|
||||
function getAssetInfos(m3u8Data: M3u8): AssetInfos {
|
||||
// LOL??? THIS LIBRARY IS SO BAD
|
||||
|
@ -176,7 +183,9 @@ const getPlayreadyPssh = (drmInfos: DrmInfos, drmIds: string[]): string | undefi
|
|||
const getFairplayKey = (drmInfos: DrmInfos, drmIds: string[]): string | undefined => getDrmData(drmInfos, drmIds, fairplay);
|
||||
|
||||
// TODO: remove later, this is just for testing
|
||||
const streamInfo1 = await StreamInfo.fromWebplayback(await appleMusicApi.getWebplayback("1615276490"), "32:ctrp64");
|
||||
const streamInfo2 = await StreamInfo.fromTrackMetadata((await appleMusicApi.getSong("1615276490")).data[0].attributes);
|
||||
if (streamInfo1.widevinePssh !== undefined) { log.debug(await getWidevineDecryptionKey(streamInfo1.widevinePssh, streamInfo1.trackId)); }
|
||||
if (streamInfo2.widevinePssh !== undefined) { log.debug(await getWidevineDecryptionKey(streamInfo2.widevinePssh, streamInfo2.trackId)); }
|
||||
const streamInfo1 = await StreamInfo.fromWebplayback(await appleMusicApi.getWebplayback("1744965708"), "32:ctrp64");
|
||||
// const streamInfo2 = await StreamInfo.fromTrackMetadata((await appleMusicApi.getSong("1615276490")).data[0].attributes);
|
||||
// if (streamInfo1.widevinePssh !== undefined) { log.debug(await getWidevineDecryptionKey(streamInfo1.widevinePssh, streamInfo1.trackId)); }
|
||||
// if (streamInfo2.widevinePssh !== undefined) { log.debug(await getWidevineDecryptionKey(streamInfo2.widevinePssh, streamInfo2.trackId)); }
|
||||
|
||||
if (streamInfo1.widevinePssh !== undefined) { await downloadSong(streamInfo1.streamUrl, await getWidevineDecryptionKey(streamInfo1.widevinePssh, streamInfo1.trackId)); }
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue