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";
|
import type { RegularCodecType } from "downloader/codecType.js";
|
||||||
|
|
||||||
|
|
||||||
export const songCodecRegex: { [key in RegularCodecType]: RegExp } = {
|
export const songCodecRegex: { [key in RegularCodecType]: RegExp } = {
|
||||||
"aac": /audio-stereo-\d+/,
|
"aac": /audio-stereo-\d+/,
|
||||||
"aac_he": /audio-HE-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 path from "node:path";
|
||||||
import { addToCache, isCached } from "../cache.js";
|
import { addToCache, isCached } from "../cache.js";
|
||||||
import type { RegularCodecType, WebplaybackCodecType } from "./codecType.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];
|
let baseOutputName = streamUrl.match(/(?:.*\/)\s*(\S*?)[.?]/)?.[1];
|
||||||
if (!baseOutputName) { throw new Error("could not get base output name from stream url!"); }
|
if (!baseOutputName) { throw new Error("could not get base output name from stream url!"); }
|
||||||
baseOutputName += `_${songCodec}`;
|
baseOutputName += `_${songCodec}`;
|
||||||
|
@ -35,13 +37,15 @@ export async function downloadSong(streamUrl: string, decryptionKey: string, son
|
||||||
|
|
||||||
addToCache(encryptedName);
|
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, [
|
const child = spawn(config.downloader.ffmpeg_path, [
|
||||||
"-loglevel", "error",
|
"-loglevel", "error",
|
||||||
"-y",
|
"-y",
|
||||||
"-decryption_key", decryptionKey,
|
"-decryption_key", decryptionKey,
|
||||||
"-i", encryptedPath,
|
...await fileMetadata.setupFfmpegInputs(encryptedPath),
|
||||||
"-c", "copy",
|
...await fileMetadata.toFfmpegArgs(),
|
||||||
"-movflags", "+faststart",
|
"-movflags", "+faststart",
|
||||||
decryptedPath
|
decryptedPath
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -29,9 +29,10 @@ router.get("/download", async (req, res, next) => {
|
||||||
const trackMetadata = await appleMusicApi.getSong(id);
|
const trackMetadata = await appleMusicApi.getSong(id);
|
||||||
const trackAttributes = trackMetadata.data[0].attributes;
|
const trackAttributes = trackMetadata.data[0].attributes;
|
||||||
const streamInfo = await StreamInfo.fromTrackMetadata(trackAttributes, regularCodec);
|
const streamInfo = await StreamInfo.fromTrackMetadata(trackAttributes, regularCodec);
|
||||||
|
|
||||||
if (streamInfo.widevinePssh !== undefined) {
|
if (streamInfo.widevinePssh !== undefined) {
|
||||||
const decryptionKey = await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId);
|
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);
|
res.download(filePath);
|
||||||
} else {
|
} else {
|
||||||
throw new Error("no decryption key found for regular codec! this is typical. don't fret!");
|
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") {
|
} else if (codecType.regularOrWebplayback === "webplayback") {
|
||||||
const webplaybackCodec = codecType.codecType as WebplaybackCodecType; // safe cast, zod
|
const webplaybackCodec = codecType.codecType as WebplaybackCodecType; // safe cast, zod
|
||||||
const webplaybackResponse = await appleMusicApi.getWebplayback(id);
|
const webplaybackResponse = await appleMusicApi.getWebplayback(id);
|
||||||
|
const trackMetadata = await appleMusicApi.getSong(id);
|
||||||
const streamInfo = await StreamInfo.fromWebplayback(webplaybackResponse, webplaybackCodec);
|
const streamInfo = await StreamInfo.fromWebplayback(webplaybackResponse, webplaybackCodec);
|
||||||
|
|
||||||
if (streamInfo.widevinePssh !== undefined) {
|
if (streamInfo.widevinePssh !== undefined) {
|
||||||
const decryptionKey = await getWidevineDecryptionKey(streamInfo.widevinePssh, streamInfo.trackId);
|
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);
|
res.download(filePath);
|
||||||
} else {
|
} else {
|
||||||
throw new Error("no decryption key found for web playback! this should not happen..");
|
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