add album downloading and rewrite cache

This commit is contained in:
Reid 2025-08-28 16:03:46 -07:00
parent f2800f13c8
commit a3cefee49a
Signed by: reidlab
GPG key ID: DAF5EAF6665839FD
33 changed files with 2573 additions and 277 deletions

View file

@ -1,19 +1,19 @@
import { createWriteStream } from "node:fs";
import type { GetSongResponse } from "appleMusicApi/types/responses.js";
import path from "node:path";
import { config } from "../config.js";
import { pipeline } from "node:stream/promises";
import { addToCache, isCached } from "../cache.js";
import type { GetSongResponse } from "../appleMusicApi/types/responses.js";
import { stripAlbumGarbage } from "./format.js";
import { downloadAlbumCover } from "./index.js";
import type { AlbumAttributes, SongAttributes } from "../appleMusicApi/types/attributes.js";
// TODO: simply add more fields. ha!
// TODO: add lyrics (what format??)
// TODO: where it does file name formatting to hit caches, i think we should normalize this throughout files in a function
export class FileMetadata {
private readonly trackAttributes: SongAttributes<[]>;
private readonly albumAttributes: AlbumAttributes<[]>;
public readonly artist: string;
public readonly title: string;
public readonly album: string;
public readonly albumArtist: string;
public readonly isPartOfCompilation: boolean;
public readonly artwork: string;
public readonly track?: number;
public readonly disc?: number;
public readonly date?: string;
@ -21,13 +21,14 @@ export class FileMetadata {
public readonly isrc?: string;
public readonly composer?: string;
constructor(
private constructor(
trackAttributes: SongAttributes<[]>,
albumAttributes: AlbumAttributes<[]>,
artist: string,
title: string,
album: string,
albumArtist: string,
isPartOfCompilation: boolean,
artwork: string,
track?: number,
disc?: number,
date?: string,
@ -35,12 +36,13 @@ export class FileMetadata {
isrc?: string,
composer?: string
) {
this.trackAttributes = trackAttributes;
this.albumAttributes = albumAttributes;
this.artist = artist;
this.title = title;
this.album = album.replace(/- (EP|Single)$/, "").trim();
this.album = stripAlbumGarbage(album);
this.albumArtist = albumArtist;
this.isPartOfCompilation = isPartOfCompilation;
this.artwork = artwork;
this.track = track;
this.disc = disc;
this.date = date;
@ -53,17 +55,14 @@ export class FileMetadata {
const trackAttributes = trackMetadata.data[0].attributes;
const albumAttributes = trackMetadata.data[0].relationships.albums.data[0].attributes;
const artworkUrl = trackAttributes.artwork.url
.replace("{w}", trackAttributes.artwork.width.toString())
.replace("{h}", trackAttributes.artwork.height.toString());
return new FileMetadata(
trackAttributes,
albumAttributes,
trackAttributes.artistName,
trackAttributes.name,
albumAttributes.name,
albumAttributes.artistName,
albumAttributes.isCompilation,
artworkUrl,
trackAttributes.trackNumber,
trackAttributes.discNumber,
trackAttributes.releaseDate,
@ -73,32 +72,12 @@ export class FileMetadata {
);
}
public async setupFfmpegInputs(encryptedPath: string): Promise<string[]> {
// url is in a weird format
// only things we care about is the uuid and file extension i think?
// i dont wanna use the original file name because what if. what if theres a collision
const extension = this.artwork.slice(this.artwork.lastIndexOf(".") + 1);
const uuid = this.artwork.split("/").at(-3);
if (uuid === undefined) { throw new Error("could not get uuid from artwork url!"); }
const imageFileName = `${uuid}.${extension}`;
const imagePath = path.join(config.downloader.cache.directory, imageFileName);
if (!isCached(imageFileName)) {
const response = await fetch(this.artwork);
if (!response.ok) { throw new Error(`failed to fetch artwork: ${response.status}`); }
if (!response.body) { throw new Error("no response body for artwork!"); }
await pipeline(response.body as ReadableStream, createWriteStream(imagePath));
addToCache(imageFileName);
}
public async setupFfmpegInputs(audioInput: string): Promise<string[]> {
const albumCover = await downloadAlbumCover(this.albumAttributes);
return [
"-i", encryptedPath,
"-i", imagePath,
"-i", audioInput,
"-i", albumCover,
"-map", "0",
"-map", "1",
"-disposition:v", "attached_pic",

View file

@ -1,4 +1,8 @@
import type { SongAttributes } from "../appleMusicApi/types/attributes.js";
import type { AlbumAttributes, SongAttributes } from "../appleMusicApi/types/attributes.js";
// 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
const illegalCharReplacements: Record<string, string> = {
"?": "",
@ -13,10 +17,11 @@ 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 {
export function stripAlbumGarbage(input: string): string {
return input.replace(/- (EP|Single)$/, "").trim();
}
export function formatSongForFs(trackAttributes: SongAttributes<[]>): string {
const title = trackAttributes.name.replace(/[?!*\/\\:"<>|]/g, (match) => illegalCharReplacements[match] || match);
const disc = trackAttributes.discNumber;
const track = trackAttributes.trackNumber;
@ -26,3 +31,10 @@ export function formatSong(trackAttributes: SongAttributes<[]>): string {
return `${disc}-${track.toString().padStart(2, "0")} - ${title}`;
}
export function formatAlbumForFs(albumAttributes: AlbumAttributes<[]>): string {
const artist = albumAttributes.artistName.replace(/[?!*\/\\:"<>|]/g, (match) => illegalCharReplacements[match] || match);
const album = stripAlbumGarbage(albumAttributes.name).replace(/[?!*\/\\:"<>|]/g, (match) => illegalCharReplacements[match] || match);
return `${artist} - ${album}`;
}

View file

@ -1,46 +1,35 @@
import { config } from "../config.js";
import { spawn } from "node:child_process";
import path from "node:path";
import { addToCache, isCached } from "../cache.js";
import { addFileToCache, isFileCached } 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";
import type { AlbumAttributes } from "../appleMusicApi/types/attributes.js";
import { pipeline } from "node:stream/promises";
import { createWriteStream } from "node:fs";
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 });
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 + ".m4a";
const decryptedPath = path.join(config.downloader.cache.directory, decryptedName);
if ( // TODO: remove check for encrypted file/cache for encrypted?
isCached(encryptedName) &&
isCached(decryptedName)
) { return decryptedPath; }
if (await isFileCached(decryptedName)) { return decryptedPath; }
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
]);
child.on("error", (err) => { rej(err); });
child.stderr.on("data", (data) => { rej(new Error(data.toString().trim())); });
child.on("exit", () => { res(); });
});
addToCache(encryptedName);
const ytdlp = spawn(config.downloader.ytdlp_path, [
"--quiet",
"--no-warnings",
"--allow-unplayable-formats",
"--fixup", "never",
"--paths", config.downloader.cache.directory,
"--output", "-",
streamUrl
], { stdio: ["ignore", "pipe", "pipe"] });
ytdlp.on("error", (err) => { throw err; });
ytdlp.stderr.on("data", (data) => { throw new Error(data.toString().trim()); });
const fileMetadata = FileMetadata.fromSongResponse(songResponse);
@ -49,17 +38,18 @@ export async function downloadSongFile(streamUrl: string, decryptionKey: string,
"-loglevel", "error",
"-y",
"-decryption_key", decryptionKey,
...await fileMetadata.setupFfmpegInputs(encryptedPath),
...await fileMetadata.setupFfmpegInputs("pipe:0"),
...await fileMetadata.toFfmpegArgs(),
"-movflags", "+faststart",
decryptedPath
]);
], { stdio: ["pipe", "pipe", "pipe"] });
ytdlp.stdout.pipe(child.stdin);
child.on("error", (err) => { rej(err); });
child.stderr.on("data", (data) => { rej(new Error(data.toString().trim())); });
child.on("exit", () => { res(); } );
});
addToCache(decryptedName);
await addFileToCache(decryptedName);
return decryptedPath;
}
@ -69,11 +59,7 @@ export async function downloadSongFile(streamUrl: string, decryptionKey: string,
// 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"
@ -122,6 +108,31 @@ export async function fetchAndDecryptStreamSegment(segmentUrl: string, decryptio
return file;
}
export async function downloadAlbumCover(albumAttributes: AlbumAttributes<[]>): Promise<string> {
const url = albumAttributes.artwork.url
.replace("{w}", albumAttributes.artwork.width.toString())
.replace("{h}", albumAttributes.artwork.height.toString());
const name = albumAttributes.playParams?.id;
const extension = url.slice(url.lastIndexOf(".") + 1);
if (!name) { throw new Error("no artwork name found! this may indicate the album isnt acessable w/ your subscription!"); }
const imageFileName = `${name}.${extension}`;
const imagePath = path.join(config.downloader.cache.directory, imageFileName);
if (await isFileCached(imageFileName) === false) {
const response = await fetch(url);
if (!response.ok) { throw new Error(`failed to fetch artwork: ${response.status}`); }
if (!response.body) { throw new Error("no response body for artwork!"); }
await pipeline(response.body as ReadableStream, createWriteStream(imagePath));
await addFileToCache(imageFileName);
}
return imagePath;
}
interface IvValue {
value: Buffer;
subsamples: Subsample[];