add album downloading and rewrite cache
This commit is contained in:
parent
f2800f13c8
commit
a3cefee49a
33 changed files with 2573 additions and 277 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue