279 lines
No EOL
8.4 KiB
TypeScript
279 lines
No EOL
8.4 KiB
TypeScript
import Auth from "./src/helpers/auth";
|
|
import Utils from "./src/helpers/utils";
|
|
import { utimes } from "fs/promises";
|
|
|
|
const auth = await Auth()
|
|
|
|
const utils = new Utils(auth);
|
|
|
|
await utils.init()
|
|
|
|
Bun.serve({
|
|
routes: {
|
|
"/api/track/:id": async req => {
|
|
const trackId = parseInt(req.params.id)
|
|
|
|
const flac = Bun.file(`downloaded/${trackId}.flac`)
|
|
|
|
if (await flac.exists()) {
|
|
return new Response(flac, {
|
|
headers: {
|
|
"Content-Type": "audio/flac",
|
|
//"Content-Disposition": `attachment; filename="${trackId}.flac"`,
|
|
"Cache-Control": "public, max-age=31536000",
|
|
"ETag": trackId.toString(),
|
|
}
|
|
})
|
|
}
|
|
|
|
const m4a = Bun.file(`downloaded/${trackId}.m4a`)
|
|
|
|
if (await m4a.exists()) {
|
|
return new Response(m4a, {
|
|
headers: {
|
|
"Content-Type": "audio/m4a",
|
|
//"Content-Disposition": `attachment; filename="${trackId}.m4a"`,
|
|
"Cache-Control": "public, max-age=31536000",
|
|
"ETag": trackId.toString(),
|
|
}
|
|
})
|
|
}
|
|
|
|
const { manifestMimeType, manifest } = await utils.fetchTrack(trackId)
|
|
|
|
const audio = await utils.downloadFlac(manifestMimeType, manifest);
|
|
if (audio.mimeType === "audio/flac") {
|
|
audio.buffer = Buffer.from(await utils.tagFlac(trackId, audio.buffer))
|
|
await Bun.write(`downloaded/${trackId}.flac`, audio.buffer)
|
|
} else if (audio.mimeType === "audio/m4a") {
|
|
await Bun.write(`downloaded/${trackId}.m4a`, audio.buffer)
|
|
}
|
|
|
|
return new Response(audio.buffer, {
|
|
headers: {
|
|
"Content-Type": audio.mimeType,
|
|
"Cache-Control": "public, max-age=31536000",
|
|
"ETag": trackId.toString(),
|
|
}
|
|
})
|
|
},
|
|
|
|
"/api/@me/tracks": async () => {
|
|
const tracks = await utils.fetchTracks();
|
|
|
|
return new Response(Bun.gzipSync(JSON.stringify(tracks)), {
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Content-Encoding": "gzip",
|
|
}
|
|
})
|
|
}
|
|
},
|
|
development: true,
|
|
idleTimeout: 255
|
|
})
|
|
|
|
const tracks = await utils.fetchTracks() as {
|
|
limit: number,
|
|
offset: number,
|
|
totalNumberOfItems: number,
|
|
items: {
|
|
created: string,
|
|
item: {
|
|
id: string
|
|
title: string
|
|
duration: number, // in seconds
|
|
replayGain: number,
|
|
peak: number,
|
|
allowStreaming: boolean,
|
|
streamReady: boolean,
|
|
payToStream: boolean,
|
|
adSupportedStreamReady: boolean,
|
|
djReady: boolean,
|
|
stemReady: boolean,
|
|
streamStartDate: string,
|
|
premiumStreamingOnly: boolean,
|
|
trackNumber: number,
|
|
volumeNumber: number, // discNumber
|
|
version: null, // ?
|
|
popularity: number,
|
|
copyright: string,
|
|
bpm: null, // think this is only provided to DJ users
|
|
url: string,
|
|
isrc: string,
|
|
editable: boolean,
|
|
explicit: boolean,
|
|
audioQuality: string,
|
|
audioModes: "STEREO"[], // todo find other modes
|
|
mediaMetadata: {
|
|
tags: "LOSSLESS"[], // todo find other tags
|
|
},
|
|
upload: boolean, // was uploaded by a user
|
|
artist: {
|
|
id: number,
|
|
name: string,
|
|
handle: null, // ?
|
|
type: "MAIN", // todo find other types
|
|
picture: string,
|
|
},
|
|
artists: [
|
|
{
|
|
id: number,
|
|
name: string,
|
|
handle: null,
|
|
type: "MAIN",
|
|
picture: string,
|
|
}
|
|
],
|
|
album: {
|
|
id: number,
|
|
title: string,
|
|
cover: string,
|
|
vibrantColor: string,
|
|
videoCover: null,
|
|
},
|
|
mixes: {
|
|
TRACK_MIX: string,
|
|
},
|
|
}
|
|
}[]
|
|
};
|
|
|
|
/* Remove all tracks that are unavailable
|
|
for (const track of tracks.items) {
|
|
if (track.item.streamReady) {
|
|
continue
|
|
}
|
|
|
|
await utils.fetch("/users/199235629/favorites/tracks/" + track.item.id, {
|
|
method: "DELETE",
|
|
})
|
|
console.log("Removed from favorites", track.item.id)
|
|
}
|
|
*/
|
|
/*
|
|
function findDuplicateTracksByName(tracks: typeof tracks.items) {
|
|
const seen = new Map<string, { count: number, originals: typeof tracks }>();
|
|
|
|
for (const trackObj of tracks) {
|
|
const title = trackObj.item.title;
|
|
const normalized = title.trim().toLowerCase();
|
|
|
|
if (!seen.has(normalized)) {
|
|
seen.set(normalized, { count: 1, originals: [trackObj] });
|
|
} else {
|
|
const entry = seen.get(normalized)!;
|
|
entry.count++;
|
|
entry.originals.push(trackObj);
|
|
}
|
|
}
|
|
|
|
// Filter to only include titles with duplicates
|
|
return Array.from(seen.entries())
|
|
.filter(([_, v]) => v.count > 1)
|
|
.map(([normalizedTitle, data]) => ({
|
|
normalizedTitle,
|
|
count: data.count,
|
|
duplicates: data.originals,
|
|
}));
|
|
}
|
|
|
|
function findDuplicateTracksByISRC(tracks: typeof tracks.items) {
|
|
const seen = new Map<string, { count: number, originals: typeof tracks }>();
|
|
|
|
for (const trackObj of tracks) {
|
|
const isrc = trackObj.item.isrc;
|
|
|
|
// Skip if ISRC is missing or empty
|
|
if (!isrc) continue;
|
|
|
|
if (!seen.has(isrc)) {
|
|
seen.set(isrc, { count: 1, originals: [trackObj] });
|
|
} else {
|
|
const entry = seen.get(isrc)!;
|
|
entry.count++;
|
|
entry.originals.push(trackObj);
|
|
}
|
|
}
|
|
|
|
// Only return those with duplicates
|
|
return Array.from(seen.entries())
|
|
.filter(([_, v]) => v.count > 1)
|
|
.map(([isrc, data]) => ({
|
|
name: data.originals[0].item.title,
|
|
isrc,
|
|
count: data.count,
|
|
duplicates: data.originals,
|
|
}));
|
|
}
|
|
|
|
*/
|
|
/*
|
|
// Usage:
|
|
const duplicatesByISRC = findDuplicateTracksByISRC(tracks.items);
|
|
duplicatesByISRC.forEach(({ name, isrc, count, duplicates }) => {
|
|
console.log(`Name: ${name} ISRC: "${isrc}" - Count: ${count}`);
|
|
duplicates.forEach((trackObj) => {
|
|
//console.log(` - Track ID: ${trackObj.item.id}, Created: ${trackObj.created}`);
|
|
});
|
|
});
|
|
*/
|
|
/*
|
|
// Usage:
|
|
const duplicates = findDuplicateTracksByName(tracks.items);
|
|
|
|
console.log("Duplicate tracks:");
|
|
duplicates.forEach(({ normalizedTitle, count, duplicates }) => {
|
|
console.log(`Title: "${normalizedTitle}" - Count: ${count}`);
|
|
duplicates.forEach((trackObj) => {
|
|
//console.log(` - Track ID: ${trackObj.item.id}, Created: ${trackObj.created}`);
|
|
});
|
|
});
|
|
*/
|
|
|
|
|
|
|
|
let i = 1;
|
|
for await (const track of tracks.items) {
|
|
const { id } = track.item
|
|
const createdAt = new Date(track.created)
|
|
|
|
const trackId = parseInt(id)
|
|
|
|
if (await Bun.file(`downloaded/${trackId}.flac`).exists() || await Bun.file(`downloaded/${trackId}.m4a`).exists() || await Bun.file(`downloaded/${trackId}.unknown`).exists()) {
|
|
//console.log(`Already downloaded ${trackId}.flac`)
|
|
i++;
|
|
continue
|
|
}
|
|
|
|
const trackData = await utils.fetchTrack(trackId)
|
|
if (trackData?.status === 404 || trackData === null) {
|
|
console.error(`track ${trackId} not available`)
|
|
i++;
|
|
continue
|
|
}
|
|
try {
|
|
const audio = await utils.downloadFlac(trackData.manifestMimeType, trackData.manifest)
|
|
|
|
const format = audio.mimeType.split("/")[1];
|
|
|
|
const name = `downloaded/${trackId}.${format}`
|
|
if (audio.mimeType === "audio/flac") {
|
|
const taggedAudio = await utils.tagFlac(trackId, audio.buffer)
|
|
|
|
await Bun.write(name, taggedAudio)
|
|
} else {
|
|
await Bun.write(name, audio.buffer)
|
|
}
|
|
|
|
await utimes(name, createdAt, createdAt)
|
|
//console.log(`Downloaded ${trackId}.flac`)
|
|
} catch (e) {
|
|
console.error(`Failed to download ${trackId}.flac`, e)
|
|
}
|
|
|
|
console.log(`Downloaded track ${i} of ${tracks.items.length}`)
|
|
i++;
|
|
}
|
|
|
|
console.log("Done") |