From b560060b45310916a831fb3513e6be806244ebf6 Mon Sep 17 00:00:00 2001 From: reidlab Date: Sun, 20 Jul 2025 03:02:48 -0700 Subject: [PATCH] album covers and metadata --- src/constants/codecs.ts | 1 - src/downloader/fileMetadata.ts | 137 +++++++++++++++++++++++++++++ src/downloader/index.ts | 12 ++- src/web/endpoints/back/download.ts | 7 +- 4 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 src/downloader/fileMetadata.ts diff --git a/src/constants/codecs.ts b/src/constants/codecs.ts index 991f66e..a44a2b9 100644 --- a/src/constants/codecs.ts +++ b/src/constants/codecs.ts @@ -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+/, diff --git a/src/downloader/fileMetadata.ts b/src/downloader/fileMetadata.ts new file mode 100644 index 0000000..c72d84a --- /dev/null +++ b/src/downloader/fileMetadata.ts @@ -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 { + // 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 { + 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 / 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 + ]; + } +} diff --git a/src/downloader/index.ts b/src/downloader/index.ts index a189804..3dc357c 100644 --- a/src/downloader/index.ts +++ b/src/downloader/index.ts @@ -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 { +export async function downloadSong(streamUrl: string, decryptionKey: string, songCodec: RegularCodecType | WebplaybackCodecType, songResponse: GetSongResponse<["extendedAssetUrls"], ["albums"]>): Promise { 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((res, rej) => { + const fileMetadata = FileMetadata.fromSongResponse(songResponse); + + await new Promise(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 ]); diff --git a/src/web/endpoints/back/download.ts b/src/web/endpoints/back/download.ts index 983adf6..dd36d3a 100644 --- a/src/web/endpoints/back/download.ts +++ b/src/web/endpoints/back/download.ts @@ -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..");