streaming!!, oh and file names, linting ig..
This commit is contained in:
parent
7b15834f17
commit
f2800f13c8
23 changed files with 1195 additions and 254 deletions
|
|
@ -6,7 +6,6 @@ import { pipeline } from "node:stream/promises";
|
|||
import { addToCache, isCached } from "../cache.js";
|
||||
|
||||
// TODO: simply add more fields. ha!
|
||||
// TODO: add album cover
|
||||
// TODO: add lyrics (what format??)
|
||||
export class FileMetadata {
|
||||
public readonly artist: string;
|
||||
|
|
@ -50,7 +49,7 @@ export class FileMetadata {
|
|||
this.composer = composer;
|
||||
}
|
||||
|
||||
public static fromSongResponse(trackMetadata: GetSongResponse<["extendedAssetUrls"], ["albums"]>): FileMetadata {
|
||||
public static fromSongResponse(trackMetadata: GetSongResponse<[], ["albums"]>): FileMetadata {
|
||||
const trackAttributes = trackMetadata.data[0].attributes;
|
||||
const albumAttributes = trackMetadata.data[0].relationships.albums.data[0].attributes;
|
||||
|
||||
|
|
@ -102,8 +101,9 @@ export class FileMetadata {
|
|||
"-i", imagePath,
|
||||
"-map", "0",
|
||||
"-map", "1",
|
||||
"-disposition:v", "attached_pic",
|
||||
"-c:a", "copy",
|
||||
"-c:v", "mjpeg"
|
||||
"-c:v", "copy"
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
28
src/downloader/format.ts
Normal file
28
src/downloader/format.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { SongAttributes } from "../appleMusicApi/types/attributes.js";
|
||||
|
||||
const illegalCharReplacements: Record<string, string> = {
|
||||
"?": "?",
|
||||
"!": "!",
|
||||
"*": "*",
|
||||
"/": "/",
|
||||
"\\": "\",
|
||||
":": ":",
|
||||
"\"": """,
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"|": "|"
|
||||
};
|
||||
|
||||
// TODO: make these configurable, too opinionated right now
|
||||
// eventually i'll make an account system? maybe you could do through there
|
||||
// or i'll just make it config on the server
|
||||
export function formatSong(trackAttributes: SongAttributes<[]>): string {
|
||||
const title = trackAttributes.name.replace(/[?!*\/\\:"<>|]/g, (match) => illegalCharReplacements[match] || match);
|
||||
const disc = trackAttributes.discNumber;
|
||||
const track = trackAttributes.trackNumber;
|
||||
|
||||
if (track === undefined) { throw new Error("track number is undefined in track attributes!"); }
|
||||
if (disc === undefined) { throw new Error("disc number is undefined in track attributes!"); }
|
||||
|
||||
return `${disc}-${track.toString().padStart(2, "0")} - ${title}`;
|
||||
}
|
||||
|
|
@ -5,14 +5,19 @@ import { addToCache, isCached } from "../cache.js";
|
|||
import type { RegularCodecType, WebplaybackCodecType } from "./codecType.js";
|
||||
import type { GetSongResponse } from "../appleMusicApi/types/responses.js";
|
||||
import { FileMetadata } from "./fileMetadata.js";
|
||||
import { createDecipheriv } from "node:crypto";
|
||||
import * as log from "../log.js";
|
||||
|
||||
export async function downloadSongFile(streamUrl: string, decryptionKey: string, songCodec: RegularCodecType | WebplaybackCodecType, songResponse: GetSongResponse<[], ["albums"]>): Promise<string> {
|
||||
log.debug("downloading song file and hopefully decrypting it");
|
||||
log.debug({ streamUrl: streamUrl, songCodec: songCodec });
|
||||
|
||||
export async function downloadSong(streamUrl: string, decryptionKey: string, songCodec: RegularCodecType | WebplaybackCodecType, songResponse: GetSongResponse<["extendedAssetUrls"], ["albums"]>): Promise<string> {
|
||||
let baseOutputName = streamUrl.match(/(?:.*\/)\s*(\S*?)[.?]/)?.[1];
|
||||
if (!baseOutputName) { throw new Error("could not get base output name from stream url!"); }
|
||||
baseOutputName += `_${songCodec}`;
|
||||
const encryptedName = baseOutputName + "_enc.mp4";
|
||||
const encryptedPath = path.join(config.downloader.cache.directory, encryptedName);
|
||||
const decryptedName = baseOutputName + ".mp4";
|
||||
const decryptedName = baseOutputName + ".m4a";
|
||||
const decryptedPath = path.join(config.downloader.cache.directory, decryptedName);
|
||||
|
||||
if ( // TODO: remove check for encrypted file/cache for encrypted?
|
||||
|
|
@ -58,3 +63,219 @@ export async function downloadSong(streamUrl: string, decryptionKey: string, son
|
|||
|
||||
return decryptedPath;
|
||||
}
|
||||
|
||||
// here's where shit gets real...
|
||||
// here's also where i regret using javascript
|
||||
// TODO: less mem alloc/access
|
||||
// TODO: use actual atom scanning. what if the magic bytes appear in a sample
|
||||
export async function fetchAndDecryptStreamSegment(segmentUrl: string, decryptionKey: string, fetchLength: number, offset: number): Promise<Uint8Array> {
|
||||
log.debug("downloading and hopefully decrypting stream segment");
|
||||
log.debug({ segmentUrl: segmentUrl, offset: offset, fetchLength: fetchLength });
|
||||
|
||||
const response = await fetch(segmentUrl, { headers: { "range": `bytes=${offset}-${offset + fetchLength - 1}` }});
|
||||
|
||||
const file = new Uint8Array(await response.arrayBuffer());
|
||||
|
||||
// this translates to "moof"
|
||||
const moof = new Uint8Array([0x6D, 0x6F, 0x6F, 0x66]);
|
||||
const moofIndex = file.findIndex((_v, i) => {
|
||||
return file.subarray(i, i + moof.length).every((byte, j) => { return byte === moof[j]; });
|
||||
});
|
||||
|
||||
const ivs = await extractIvFromFile(file);
|
||||
const sampleLocs = await extractSampleLocationsFromFile(file);
|
||||
|
||||
if (moofIndex !== -1) {
|
||||
sampleLocs.forEach((loc, i) => {
|
||||
const iv = ivs[i].value;
|
||||
const subsamples = ivs[i].subsamples;
|
||||
|
||||
const sample = file.subarray( // minus 4 because size
|
||||
moofIndex + loc.offset - 4,
|
||||
moofIndex + loc.offset + loc.size - 4
|
||||
);
|
||||
|
||||
if (subsamples.length > 0) {
|
||||
let pos = 0;
|
||||
|
||||
const decipher = createDecipheriv("aes-128-ctr", Buffer.from(decryptionKey, "hex"), Buffer.concat([iv, Buffer.alloc(8)]));
|
||||
|
||||
subsamples.forEach(({ clearBytes, encryptedBytes }) => {
|
||||
pos += clearBytes;
|
||||
|
||||
if (encryptedBytes > 0) {
|
||||
const chunk = sample.subarray(pos, pos + encryptedBytes);
|
||||
const decryptedChunk = Buffer.concat([decipher.update(chunk), decipher.final()]);
|
||||
decryptedChunk.copy(sample, pos);
|
||||
pos += encryptedBytes;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const decipher = createDecipheriv("aes-128-ctr", Buffer.from(decryptionKey, "hex"), Buffer.concat([iv, Buffer.alloc(8)]));
|
||||
const decrypted = Buffer.concat([decipher.update(sample), decipher.final()]);
|
||||
|
||||
file.set(decrypted, moofIndex + loc.offset - 4);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
interface IvValue {
|
||||
value: Buffer;
|
||||
subsamples: Subsample[];
|
||||
}
|
||||
|
||||
interface Subsample {
|
||||
clearBytes: number;
|
||||
encryptedBytes: number;
|
||||
}
|
||||
|
||||
async function extractIvFromFile(file: Uint8Array): Promise<IvValue[]> {
|
||||
const ivArray: IvValue[] = [];
|
||||
|
||||
let maxSampleCount: number | undefined;
|
||||
let subsampleEncryptionPresent = false;
|
||||
|
||||
for (let i = 0; i < file.length; i++) {
|
||||
// this translates to "senc"
|
||||
if (
|
||||
file[i] === 0x73 &&
|
||||
file[i+1] === 0x65 &&
|
||||
file[i+2] === 0x6E &&
|
||||
file[i+3] === 0x63
|
||||
) {
|
||||
// skip 4 bytes -- skip "senc" header
|
||||
i += 4;
|
||||
|
||||
// skip 1 byte -- skip version
|
||||
i += 1;
|
||||
|
||||
const flags = (file[i] << 16) | (file[i+1] << 8) | file[i+2];
|
||||
|
||||
subsampleEncryptionPresent = (flags & 0x000002) !== 0;
|
||||
|
||||
// skip 4 bytes -- skip flags
|
||||
i += 3;
|
||||
|
||||
// uint8x4 -> uint32x1
|
||||
maxSampleCount = (file[i] << 24) | (file[i+1] << 16) | (file[i+2] << 8) | file[i+3];
|
||||
|
||||
// skip 4 bytes -- skip sample count
|
||||
i += 4;
|
||||
|
||||
for (let sampleIndex = 0; sampleIndex < maxSampleCount; sampleIndex++) {
|
||||
const iv = file.subarray(i, i + 8);
|
||||
|
||||
// skip 8 bytes -- skip iv
|
||||
i += 8;
|
||||
|
||||
const subsamples: Subsample[] = [];
|
||||
if (subsampleEncryptionPresent) {
|
||||
const subsampleCount = (file[i] << 8) | file[i+1];
|
||||
|
||||
// skip 2 bytes -- skip subsample count
|
||||
i += 2;
|
||||
|
||||
for (let j = 0; j < subsampleCount; j++) {
|
||||
const clearBytes = (file[i] << 8) | file[i+1];
|
||||
|
||||
// skip 2 bytes -- skip clear bytes
|
||||
i += 2;
|
||||
|
||||
const encryptedBytes = (file[i] << 24) | (file[i+1] << 16) | (file[i+2] << 8) | file[i+3];
|
||||
|
||||
// skip 4 bytes -- skip encrypted bytes
|
||||
i += 4;
|
||||
|
||||
subsamples.push({
|
||||
clearBytes: clearBytes,
|
||||
encryptedBytes: encryptedBytes
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ivArray.push({
|
||||
value: Buffer.from(iv),
|
||||
subsamples: subsamples
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ivArray;
|
||||
}
|
||||
|
||||
interface SampleLocation {
|
||||
offset: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
async function extractSampleLocationsFromFile(file: Uint8Array): Promise<SampleLocation[]> {
|
||||
const sampleLocations: SampleLocation[] = [];
|
||||
|
||||
for (let i = 0; i < file.length; i++) {
|
||||
// this translates to "trun"
|
||||
if (
|
||||
file[i] === 0x74 &&
|
||||
file[i+1] === 0x72 &&
|
||||
file[i+2] === 0x75 &&
|
||||
file[i+3] === 0x6E
|
||||
) {
|
||||
// skip 4 bytes -- skip "trun" header
|
||||
i += 4;
|
||||
|
||||
// skip 1 byte -- skip version
|
||||
i += 1;
|
||||
|
||||
const flags = (file[i] << 16) | (file[i+1] << 8) | file[i+2];
|
||||
|
||||
const dataOffsetPresent = (flags & 0x000001) !== 0;
|
||||
const firstSampleFlagsPresent = (flags & 0x000004) !== 0;
|
||||
const sampleDurationPresent = (flags & 0x000100) !== 0;
|
||||
const sampleSizePresent = (flags & 0x000200) !== 0;
|
||||
const sampleFlagsPresent = (flags & 0x000400) !== 0;
|
||||
const sampleCompositionTimeOffsetsPresent = (flags & 0x000800) !== 0;
|
||||
|
||||
// skip 3 bytes -- skip flags
|
||||
i += 3;
|
||||
|
||||
if (!dataOffsetPresent) { throw new Error("data offset not present in trun atom!"); }
|
||||
if (!sampleSizePresent) { throw new Error("sample size not present in trun atom!"); }
|
||||
|
||||
// TODO: add these flags
|
||||
if (firstSampleFlagsPresent) { throw new Error("first sample flags not supported yet!"); }
|
||||
if (sampleFlagsPresent) { throw new Error("sample flags not supported yet!"); }
|
||||
if (sampleCompositionTimeOffsetsPresent) { throw new Error("sample composition time offsets not supported yet!"); }
|
||||
|
||||
const sampleCount = (file[i] << 24) | (file[i+1] << 16) | (file[i+2] << 8) | file[i+3];
|
||||
|
||||
// skip 4 bytes -- skip sample count
|
||||
i += 4;
|
||||
|
||||
let sampleDataOffset = (file[i] << 24) | (file[i+1] << 16) | (file[i+2] << 8) | file[i+3];
|
||||
|
||||
// skip 4 bytes -- skip data offset
|
||||
i += 4;
|
||||
|
||||
for (let j = 0; j < sampleCount; j++) {
|
||||
// honestly? i'm scared of what apple is doing to where this could be true
|
||||
// for context, only ones that use subsample encryption have this... on the last segment?????
|
||||
// truly something that you gotta ponder about for a second
|
||||
// skip 4 bytes -- skip sample duration
|
||||
if (sampleDurationPresent) { i += 4; }
|
||||
|
||||
const sampleSize = (file[i] << 24) | (file[i+1] << 16) | (file[i+2] << 8) | file[i+3];
|
||||
|
||||
// skip 4 bytes -- skip sample size
|
||||
i += 4;
|
||||
|
||||
sampleLocations.push({ offset: sampleDataOffset, size: sampleSize });
|
||||
sampleDataOffset += sampleSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sampleLocations;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export async function getWidevineDecryptionKey(psshDataUri: string, trackId: str
|
|||
let challenge: Buffer;
|
||||
try {
|
||||
challenge = session.createLicenseRequest();
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
// 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({
|
||||
|
|
@ -24,6 +24,9 @@ export async function getWidevineDecryptionKey(psshDataUri: string, trackId: str
|
|||
keyIds: [Buffer.from(dataUriToBuffer(psshDataUri).buffer).toString("hex")]
|
||||
});
|
||||
|
||||
// i'd love to log the error but it feels weird doing that and spammy
|
||||
// also its the most useless error ever. "the pssh is not an actuall pssh"
|
||||
// that typo is intentional, the library is like that
|
||||
log.warn("pssh was invalid, treating it as raw data (this is expected in the webplayback manifest)");
|
||||
log.warn("this should not throw an error, unless the pssh data is actually invalid");
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ type M3u8 = ReturnType<typeof hls.default.parse>;
|
|||
export default class StreamInfo {
|
||||
public readonly trackId: string;
|
||||
public readonly streamUrl: string;
|
||||
public readonly streamParsed: M3u8;
|
||||
public readonly primaryFileUrl: string;
|
||||
public readonly widevinePssh: string | undefined;
|
||||
public readonly playreadyPssh: string | undefined;
|
||||
public readonly fairplayKey: string | undefined;
|
||||
|
|
@ -21,20 +23,25 @@ export default class StreamInfo {
|
|||
private constructor(
|
||||
trackId: string,
|
||||
streamUrl: string,
|
||||
streamParsed: M3u8,
|
||||
primaryFileUrl: string,
|
||||
widevinePssh: string | undefined,
|
||||
playreadyPssh: string | undefined,
|
||||
fairplayKey: string | undefined
|
||||
) {
|
||||
this.trackId = trackId;
|
||||
this.streamUrl = streamUrl;
|
||||
this.streamParsed = streamParsed;
|
||||
this.primaryFileUrl = primaryFileUrl;
|
||||
this.widevinePssh = widevinePssh;
|
||||
this.playreadyPssh = playreadyPssh;
|
||||
this.fairplayKey = fairplayKey;
|
||||
}
|
||||
|
||||
// TODO: why can't we decrypt widevine ones with this?
|
||||
// why can't we decrypt widevine ones with this?
|
||||
// we get a valid key.. but it doesn't work :-(
|
||||
// upd: it seems thats just how the cookie crumbles. oh well
|
||||
// upd: removed todo. as i said its just how it is
|
||||
public static async fromTrackMetadata(trackMetadata: SongAttributes<["extendedAssetUrls"]>, codec: RegularCodecType): 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");
|
||||
|
|
@ -52,6 +59,10 @@ export default class StreamInfo {
|
|||
const drmIds = assetInfos[variantId]["AUDIO-SESSION-KEY-IDS"];
|
||||
|
||||
const correctM3u8Url = m3u8Url.substring(0, m3u8Url.lastIndexOf("/")) + "/" + playlist.uri;
|
||||
const correctM3u8 = await axios.get(correctM3u8Url, { responseType: "text" });
|
||||
const correctM3u8Parsed = hls.default.parse(correctM3u8.data);
|
||||
|
||||
const primaryFileUrl = getPrimaryFileUrl(m3u8Parsed);
|
||||
|
||||
const widevinePssh = getWidevinePssh(drmInfos, drmIds);
|
||||
const playreadyPssh = getPlayreadyPssh(drmInfos, drmIds);
|
||||
|
|
@ -63,6 +74,8 @@ export default class StreamInfo {
|
|||
return new StreamInfo(
|
||||
trackId,
|
||||
correctM3u8Url,
|
||||
correctM3u8Parsed,
|
||||
primaryFileUrl,
|
||||
widevinePssh,
|
||||
playreadyPssh,
|
||||
fairplayKey
|
||||
|
|
@ -87,6 +100,8 @@ export default class StreamInfo {
|
|||
const m3u8 = await axios.get(m3u8Url, { responseType: "text" });
|
||||
const m3u8Parsed = hls.default.parse(m3u8.data);
|
||||
|
||||
const primaryFileUrl = getPrimaryFileUrl(m3u8Parsed);
|
||||
|
||||
const widevinePssh = m3u8Parsed.lines.find((line) => { return line.name === "key"; })?.attributes?.uri;
|
||||
if (widevinePssh === undefined) { throw new Error("widevine uri is missing!"); }
|
||||
if (typeof widevinePssh !== "string") { throw new Error("widevine uri is not a string!"); }
|
||||
|
|
@ -95,6 +110,8 @@ export default class StreamInfo {
|
|||
return new StreamInfo(
|
||||
trackId,
|
||||
m3u8Url,
|
||||
m3u8Parsed,
|
||||
primaryFileUrl,
|
||||
widevinePssh,
|
||||
undefined,
|
||||
undefined
|
||||
|
|
@ -102,7 +119,11 @@ export default class StreamInfo {
|
|||
}
|
||||
}
|
||||
|
||||
type DrmInfos = { [key: string]: { [key: string]: { "URI": string } }; };
|
||||
function getPrimaryFileUrl(m3u8Data: M3u8): string {
|
||||
return m3u8Data.segments[0].uri;
|
||||
}
|
||||
|
||||
type DrmInfos = Record<string, Record<string, { "URI": string }>>;;
|
||||
function getDrmInfos(m3u8Data: M3u8): DrmInfos {
|
||||
// see `getAssetInfos` for the reason why this is so bad
|
||||
for (const line of m3u8Data.lines) {
|
||||
|
|
@ -121,7 +142,7 @@ function getDrmInfos(m3u8Data: M3u8): DrmInfos {
|
|||
throw new Error("m3u8 missing audio session key info!");
|
||||
}
|
||||
|
||||
type AssetInfos = { [key: string]: { "AUDIO-SESSION-KEY-IDS": string[]; }; }
|
||||
type AssetInfos = Record<string, { "AUDIO-SESSION-KEY-IDS": string[] }>;
|
||||
function getAssetInfos(m3u8Data: M3u8): AssetInfos {
|
||||
// LOL??? THIS LIBRARY IS SO BAD
|
||||
// YOU CAN'T MAKE THIS SHIT UP
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue