album covers and metadata
This commit is contained in:
parent
8113c36a47
commit
b560060b45
4 changed files with 150 additions and 7 deletions
|
@ -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+/,
|
||||
|
|
137
src/downloader/fileMetadata.ts
Normal file
137
src/downloader/fileMetadata.ts
Normal 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
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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
|
||||
]);
|
||||
|
|
|
@ -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..");
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue