streaming!!, oh and file names, linting ig..

This commit is contained in:
Reid 2025-08-15 01:40:21 -07:00
parent 7b15834f17
commit f2800f13c8
Signed by: reidlab
GPG key ID: DAF5EAF6665839FD
23 changed files with 1195 additions and 254 deletions

View file

@ -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
View 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}`;
}

View file

@ -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;
}

View file

@ -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");

View file

@ -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