album covers and metadata

This commit is contained in:
Reid 2025-07-20 03:02:48 -07:00
parent 8113c36a47
commit b560060b45
Signed by: reidlab
GPG key ID: DAF5EAF6665839FD
4 changed files with 150 additions and 7 deletions

View file

@ -1,6 +1,5 @@
import type { RegularCodecType } from "downloader/codecType.js";
export const songCodecRegex: { [key in RegularCodecType]: RegExp } = {
"aac": /audio-stereo-\d+/,
"aac_he": /audio-HE-stereo-\d+/,

View file

@ -0,0 +1,137 @@
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";
// TODO: simply add more fields. ha!
// TODO: add album cover
// TODO: add lyrics (what format??)
export class FileMetadata {
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;
public readonly copyright?: string;
public readonly isrc?: string;
public readonly composer?: string;
constructor(
artist: string,
title: string,
album: string,
albumArtist: string,
isPartOfCompilation: boolean,
artwork: string,
track?: number,
disc?: number,
date?: string,
copyright?: string,
isrc?: string,
composer?: string
) {
this.artist = artist;
this.title = title;
this.album = album.replace(/- (EP|Single)$/, "").trim();
this.albumArtist = albumArtist;
this.isPartOfCompilation = isPartOfCompilation;
this.artwork = artwork;
this.track = track;
this.disc = disc;
this.date = date;
this.copyright = copyright;
this.isrc = isrc;
this.composer = composer;
}
public static fromSongResponse(trackMetadata: GetSongResponse<["extendedAssetUrls"], ["albums"]>): 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.artistName,
trackAttributes.name,
albumAttributes.name,
albumAttributes.artistName,
albumAttributes.isCompilation,
artworkUrl,
trackAttributes.trackNumber,
trackAttributes.discNumber,
trackAttributes.releaseDate,
albumAttributes.copyright,
trackAttributes.isrc,
trackAttributes.composerName
);
}
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);
}
return [
"-i", encryptedPath,
"-i", imagePath,
"-map", "0",
"-map", "1",
"-c:a", "copy",
"-c:v", "mjpeg"
];
}
public async toFfmpegArgs(): Promise<string[]> {
return [
// standard album cover metadata
"-metadata:s:v","comment='Cover (front)'",
// bog standard metadata
"-metadata", "artist=" + this.artist,
"-metadata", "title=" + this.title,
"-metadata", "album=" + this.album,
"-metadata", "album_artist=" + this.albumArtist,
// oh how i'd love to do <track>/<totaltracks> but...
// it feels weird only doing it on tracks, since MZ doesn't have total disks
// so i'm just doing non-full numbers because it feels weird only doing it for one
...(this.track !== undefined ? ["-metadata", "track=" + this.track] : []),
...(this.disc !== undefined ? ["-metadata", "disc=" + this.disc] : []),
...(this.date !== undefined ? ["-metadata", "date=" + this.date] : []),
...(this.copyright !== undefined ? ["-metadata", "copyright=" + this.copyright] : []),
...(this.isrc !== undefined ? ["-metadata", "isrc=" + this.isrc] : []),
...(this.composer !== undefined ? ["-metadata", "composer=" + this.composer] : []),
// from https://id3.org/Developer%20Information:
// > TCMP: iTunes Compilation flag
// > TSO2: iTunes uses this for Album Artist sort order
// > TSOC: iTunes uses this for Composer sort order
"-metadata", "TCMP=" + (this.isPartOfCompilation ? "1" : "0"),
"-metadata", "TSO2=" + this.albumArtist,
"-metadata", "TSOC=" + this.composer
];
}
}

View file

@ -3,8 +3,10 @@ import { spawn } from "node:child_process";
import path from "node:path";
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";
export async function downloadSong(streamUrl: string, decryptionKey: string, songCodec: RegularCodecType | WebplaybackCodecType): Promise<string> {
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}`;
@ -35,13 +37,15 @@ export async function downloadSong(streamUrl: string, decryptionKey: string, son
addToCache(encryptedName);
await new Promise<void>((res, rej) => {
const fileMetadata = FileMetadata.fromSongResponse(songResponse);
await new Promise<void>(async (res, rej) => {
const child = spawn(config.downloader.ffmpeg_path, [
"-loglevel", "error",
"-y",
"-decryption_key", decryptionKey,
"-i", encryptedPath,
"-c", "copy",
...await fileMetadata.setupFfmpegInputs(encryptedPath),
...await fileMetadata.toFfmpegArgs(),
"-movflags", "+faststart",
decryptedPath
]);

View file

@ -29,9 +29,10 @@ router.get("/download", async (req, res, next) => {
const trackMetadata = await appleMusicApi.getSong(id);
const trackAttributes = trackMetadata.data[0].attributes;
const streamInfo = await StreamInfo.fromTrackMetadata(trackAttributes, regularCodec);
if (streamInfo.widevinePssh !== undefined) {
const decryptionKey = await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId);
const filePath = await downloadSong(streamInfo.streamUrl, decryptionKey, regularCodec);
const filePath = await downloadSong(streamInfo.streamUrl, decryptionKey, regularCodec, trackMetadata);
res.download(filePath);
} else {
throw new Error("no decryption key found for regular codec! this is typical. don't fret!");
@ -39,10 +40,12 @@ router.get("/download", async (req, res, next) => {
} else if (codecType.regularOrWebplayback === "webplayback") {
const webplaybackCodec = codecType.codecType as WebplaybackCodecType; // safe cast, zod
const webplaybackResponse = await appleMusicApi.getWebplayback(id);
const trackMetadata = await appleMusicApi.getSong(id);
const streamInfo = await StreamInfo.fromWebplayback(webplaybackResponse, webplaybackCodec);
if (streamInfo.widevinePssh !== undefined) {
const decryptionKey = await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId);
const filePath = await downloadSong(streamInfo.streamUrl, decryptionKey, webplaybackCodec);
const filePath = await downloadSong(streamInfo.streamUrl, decryptionKey, webplaybackCodec, trackMetadata);
res.download(filePath);
} else {
throw new Error("no decryption key found for web playback! this should not happen..");