123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206 |
- import {spawn} from 'node:child_process'
- import {createReadStream, createWriteStream} from 'node:fs'
- import {readdir, rename, stat, symlink, writeFile} from 'node:fs/promises'
- import os from 'node:os'
- import path from 'node:path'
- import url from 'node:url'
- import {mkdirp} from 'mkdirp'
- import fetch from 'node-fetch'
- import sanitize from 'sanitize-filename'
- import tempy from 'tempy'
- import {promisifyProcess} from './general-util.js'
- const copyFile = (source, target) => {
- // Stolen from https://stackoverflow.com/a/30405105/4633828
- const rd = createReadStream(source)
- const wr = createWriteStream(target)
- return new Promise((resolve, reject) => {
- rd.on('error', reject)
- wr.on('error', reject)
- wr.on('finish', resolve)
- rd.pipe(wr)
- }).catch(function(error) {
- rd.destroy()
- wr.end()
- throw error
- })
- }
- export const rootCacheDir = path.join(os.homedir(), '.mtui', 'downloads')
- const cachify = (identifier, keyFunction, baseFunction) => {
- return async arg => {
- // If there was no argument passed (or it aws empty), nothing will work..
- if (!arg) {
- throw new TypeError('Expected a downloader argument')
- }
- // Determine where the final file will end up. This is just a directory -
- // the file's own name is determined by the downloader.
- const cacheDir = rootCacheDir + '/' + identifier
- const finalDirectory = cacheDir + '/' + sanitize(keyFunction(arg))
- // Check if that directory only exists. If it does, return the file in it,
- // because it being there means we've already downloaded it at some point
- // in the past.
- let exists
- try {
- await stat(finalDirectory)
- exists = true
- } catch (error) {
- // ENOENT means the folder doesn't exist, which is one of the potential
- // expected outputs, so do nothing and let the download continue.
- if (error.code === 'ENOENT') {
- exists = false
- }
- // Otherwise, there was some unexpected error, so throw it:
- else {
- throw error
- }
- }
- // If the directory exists, return the file in it. Downloaders always
- // return only one file, so it's expected that the directory will only
- // contain a single file. We ignore any other files. Note we also allow
- // the download to continue if there aren't any files in the directory -
- // that would mean that the file (but not the directory) was unexpectedly
- // deleted.
- if (exists) {
- const files = await readdir(finalDirectory)
- if (files.length >= 1) {
- return finalDirectory + '/' + files[0]
- }
- }
- // The "temporary" output, aka the download location. Generally in a
- // temporary location as returned by tempy.
- const tempFile = await baseFunction(arg)
- // Then move the download to the final location. First we need to make the
- // folder exist, then we move the file.
- const finalFile = finalDirectory + '/' + path.basename(tempFile)
- await mkdirp(finalDirectory)
- await rename(tempFile, finalFile)
- // And return.
- return finalFile
- }
- }
- const removeFileProtocol = arg => {
- const fileProto = 'file://'
- if (arg.startsWith(fileProto)) {
- return decodeURIComponent(arg.slice(fileProto.length))
- } else {
- return arg
- }
- }
- // Generally target file extension, used by youtube-dl
- export const extension = 'mp3'
- const downloaders = {}
- downloaders.http =
- cachify('http',
- arg => {
- const {hostname, pathname} = new url.URL(arg)
- return hostname + pathname
- },
- arg => {
- const out = (
- tempy.directory() + '/' +
- sanitize(decodeURIComponent(path.basename(arg))))
- return fetch(arg)
- .then(response => response.buffer())
- .then(buffer => writeFile(out, buffer))
- .then(() => out)
- })
- downloaders.youtubedl =
- cachify('youtubedl',
- arg => (arg.match(/watch\?v=(.*)/) || ['', arg])[1],
- arg => {
- const outDir = tempy.directory()
- const outFile = outDir + '/%(id)s-%(uploader)s-%(title)s.%(ext)s'
- const opts = [
- '--quiet',
- '--no-warnings',
- '--extract-audio',
- '--audio-format', extension,
- '--output', outFile,
- arg
- ]
- return promisifyProcess(spawn('youtube-dl', opts))
- .then(() => readdir(outDir))
- .then(files => outDir + '/' + files[0])
- })
- downloaders.local =
- cachify('local',
- arg => arg,
- arg => {
- // Usually we'd just return the given argument in a local
- // downloader, which is efficient, since there's no need to
- // copy a file from one place on the hard drive to another.
- // But reading from a separate drive (e.g. a USB stick or a
- // CD) can take a lot longer than reading directly from the
- // computer's own drive, so this downloader copies the file
- // to a temporary file on the computer's drive.
- // Ideally, we'd be able to check whether a file is on the
- // computer's main drive mount or not before going through
- // the steps to copy, but I'm not sure if there's a way to
- // do that (and it's even less likely there'd be a cross-
- // platform way).
- // It's possible the downloader argument starts with the "file://"
- // protocol string; in that case we'll want to snip it off and URL-
- // decode the string.
- arg = removeFileProtocol(arg)
- // TODO: Is it necessary to sanitize here?
- // Haha, the answer to "should I sanitize" is probably always YES..
- const base = path.basename(arg, path.extname(arg))
- const out = tempy.directory() + '/' + sanitize(base) + path.extname(arg)
- return copyFile(arg, out)
- .then(() => out)
- })
- downloaders.locallink =
- cachify('locallink',
- arg => arg,
- arg => {
- // Like the local downloader, but creates a symbolic link to the argument.
- arg = removeFileProtocol(arg)
- const base = path.basename(arg, path.extname(arg))
- const out = tempy.directory() + '/' + sanitize(base) + path.extname(arg)
- return symlink(path.resolve(arg), out)
- .then(() => out)
- })
- downloaders.echo =
- arg => arg
- export default downloaders
- export function getDownloaderFor(arg) {
- if (arg.startsWith('http://') || arg.startsWith('https://')) {
- if (arg.includes('youtube.com')) {
- return downloaders.youtubedl
- } else {
- return downloaders.http
- }
- } else {
- // return downloaders.local
- return downloaders.locallink
- }
- }
|