Last active
November 17, 2024 14:30
-
-
Save Demonstrandum/07f76ac87326beb415e81e4865da134a to your computer and use it in GitHub Desktop.
door.link.ts: download mixtapes
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env -S deno run --allow-net --allow-env --allow-read --allow-write | |
import MP3Tag from 'https://cdn.jsdelivr.net/gh/eidoriantan/mp3tag.js@latest/src/mp3tag.mjs'; | |
Object.defineProperty(Deno, "execName", { | |
get() { | |
const PATHS = Deno.env.get('PATH')?.split(':') || []; | |
// Deno doesn't seem to have something akin to $0 in shell. | |
let progName = Deno.mainModule.replace("file://", "") | |
for (const path of PATHS) { | |
if (progName.startsWith(path)) { | |
progName = progName.replace(path + '/', ""); | |
break; | |
} | |
} | |
if (progName.startsWith(Deno.cwd())) { | |
progName = progName.replace(Deno.cwd() + '/', ""); | |
if (!progName.includes('/')) | |
progName = "./" + progName | |
} | |
return progName; | |
} | |
}); | |
const cleanFilename = (filename: string) => { | |
const disallowed = `/?<>\\:*|"`.split(''); | |
const alternative = { | |
'/': '\u2215', // division slash | |
'?': '\uFF1F', // fullwidth question mark | |
'<': '\uFF1C', // fullwidth less-than sign | |
'>': '\uFF1E', // fullwidth greater-than sign | |
'\\': '\u29F5', // reverse solidus operator | |
':': '\uA789', // modifier letter colon | |
'*': '\u204E', // low asterisk | |
'|': '\u23D0', // vertical line extension | |
'"': '\uFF02', // fullwidth quotation mark | |
}; | |
return disallowed.reduce((cleaned, pattern) => | |
cleaned.replaceAll(pattern, alternative[pattern] || ''), | |
filename); | |
} | |
const usage = (exit: any = null) => { | |
console.warn(`usage: ${Deno.execName} [download directory]`); | |
if (exit || exit === 0) Deno.exit(Number(exit) || 0) | |
} | |
const PAGE_LIMIT = 999; // Arbitrary limit of how many entries to consider. | |
const API_URL = `https://lit-castle-14233.herokuapp.com`; | |
const PLAYLIST = `playlists?_sort=created_at:desc&_limit=${PAGE_LIMIT}`; | |
const [SAVE_PATH, ..._] = Deno.args; | |
if (!SAVE_PATH) usage(1); | |
else await Deno.mkdir(SAVE_PATH, { recursive: true }); | |
const { columns } = await Deno.consoleSize(Deno.stdout.rid); | |
const print = (out: string) => | |
Deno.stdout.write(new TextEncoder().encode(out)); | |
const clearLine = () => | |
print('\r' + ' '.repeat(columns)); | |
let finishedCount = 0; | |
const writeAll = async (writer: Deno.Writer, data: Uint8Array) => { | |
const totalBytes = data.length; | |
let bytesWritten: number = await writer.write(data); | |
while (bytesWritten < totalBytes) | |
bytesWritten += writer.write(data.subarray(bytesWritten)); | |
}; | |
const savePlaylist = async (): Promise<number> => { | |
// Get list of all media on door.link. | |
console.log('fetching', `${API_URL}/${PLAYLIST}`); | |
const res = await fetch(`${API_URL}/${PLAYLIST}`, { | |
method: "GET", | |
headers: { | |
"Content-Type": "application/json", | |
} | |
}); | |
const entries = await res.json(); | |
(async () => { for (const entry of entries) { | |
const url = entry.audio.url; | |
const filename = cleanFilename(`${entry.number} - ${entry.Title.trim()}${entry.audio.ext}`); | |
const dest = `${SAVE_PATH}/${filename}`; | |
const exists = await Deno.stat(dest) | |
.then(_ => true).catch(_ => false); | |
if (exists) { | |
print('\r\x1b[F' | |
+ `* Skipping: '${filename}', already exists.\n\n`); | |
finishedCount += 1; | |
continue; | |
} else { | |
print('\r\x1b[F' + `* Fetching: '${filename}'.\n\n`); | |
} | |
const isMp3 = entry.audio.ext === '.mp3'; | |
// Download media. | |
const mp3 = await fetch(url); | |
if (mp3.body === null) { | |
console.error('\r\x1b[F' + `[*] Failed to fetch: ${filename}.\n`); | |
finishedCount += 1; | |
return; | |
} | |
await Deno.create(dest); | |
const save = await Deno.open(dest, { create: true, write: true }); | |
// Only add id3v{1,2} info to MP3 files. | |
if (isMp3) { | |
// Tag media file with id3v{1,2} details (eg title, track, etc.). | |
let buffer = new ArrayBuffer(10e3); | |
let id3: any = new MP3Tag(buffer, false); | |
const tags = id3.read(); | |
if (id3.error !== '') throw new Error(id3.error); | |
// If original file had no tags, we must init ourselves. | |
tags.v1 ||= {}; | |
tags.v2 ||= {}; | |
tags.v1Details ||= {}; | |
tags.v2Details ||= { version: [3] }; | |
tags.title = entry.Title.trim(); | |
tags.artist = 'door.link'; | |
tags.album = 'door.link'; | |
tags.year = '0000'; | |
tags.comment = 'N/A'; | |
tags.track = `${parseInt(entry.number)}`; | |
tags.genre = ''; | |
// Download and set artwork. | |
if (entry.artwork) { | |
const art = await fetch(entry.artwork.url); | |
if (art.body !== null) { | |
tags.v2.APIC = [{ | |
format: entry.artwork.mime, | |
type: 3, // Picture type $03: Cover (front) | |
description: entry.artwork.alternativeText || 'Cover art', | |
data: new Uint8Array(await art.arrayBuffer()) | |
}]; | |
} | |
} | |
buffer = id3.save({ | |
strict: true, | |
id3v1: { include: false }, | |
id3v2: { include: true } | |
}); | |
if (id3.error !== '') throw new Error(id3.error); | |
const view = new Uint8Array(buffer); | |
// Write id3v{1,2} data to the file. | |
await writeAll(save, view); | |
} | |
// Write rest of chunks of mp3. | |
for await (const chunk of mp3.body) | |
await writeAll(save, chunk); | |
save.close(); | |
finishedCount += 1; | |
}})(); | |
return entries.length; | |
}; | |
print('\n') | |
const pendingCount = await savePlaylist(); | |
const spinners = "|/-\\".split(''); | |
let tick = 0; | |
const interval = setInterval(() => { | |
print('\x1b[F'); clearLine(); print('\n') | |
clearLine(); | |
if (finishedCount >= pendingCount) { | |
print("\rDone.\n"); | |
clearInterval(interval); | |
return; | |
} | |
const spinner = spinners[tick]; | |
print(`\r${spinner} Downloading (${finishedCount + 1}/${pendingCount})` | |
+ '.'.repeat(tick)); | |
tick = (1 + tick) % 4; | |
}, 200); | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment