downloaders.js 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. import {spawn} from 'node:child_process'
  2. import {createReadStream, createWriteStream} from 'node:fs'
  3. import {readdir, rename, stat, symlink, writeFile} from 'node:fs/promises'
  4. import os from 'node:os'
  5. import path from 'node:path'
  6. import url from 'node:url'
  7. import {mkdirp} from 'mkdirp'
  8. import fetch from 'node-fetch'
  9. import sanitize from 'sanitize-filename'
  10. import tempy from 'tempy'
  11. import {promisifyProcess} from './general-util.js'
  12. const copyFile = (source, target) => {
  13. // Stolen from https://stackoverflow.com/a/30405105/4633828
  14. const rd = createReadStream(source)
  15. const wr = createWriteStream(target)
  16. return new Promise((resolve, reject) => {
  17. rd.on('error', reject)
  18. wr.on('error', reject)
  19. wr.on('finish', resolve)
  20. rd.pipe(wr)
  21. }).catch(function(error) {
  22. rd.destroy()
  23. wr.end()
  24. throw error
  25. })
  26. }
  27. export const rootCacheDir = path.join(os.homedir(), '.mtui', 'downloads')
  28. const cachify = (identifier, keyFunction, baseFunction) => {
  29. return async arg => {
  30. // If there was no argument passed (or it aws empty), nothing will work..
  31. if (!arg) {
  32. throw new TypeError('Expected a downloader argument')
  33. }
  34. // Determine where the final file will end up. This is just a directory -
  35. // the file's own name is determined by the downloader.
  36. const cacheDir = rootCacheDir + '/' + identifier
  37. const finalDirectory = cacheDir + '/' + sanitize(keyFunction(arg))
  38. // Check if that directory only exists. If it does, return the file in it,
  39. // because it being there means we've already downloaded it at some point
  40. // in the past.
  41. let exists
  42. try {
  43. await stat(finalDirectory)
  44. exists = true
  45. } catch (error) {
  46. // ENOENT means the folder doesn't exist, which is one of the potential
  47. // expected outputs, so do nothing and let the download continue.
  48. if (error.code === 'ENOENT') {
  49. exists = false
  50. }
  51. // Otherwise, there was some unexpected error, so throw it:
  52. else {
  53. throw error
  54. }
  55. }
  56. // If the directory exists, return the file in it. Downloaders always
  57. // return only one file, so it's expected that the directory will only
  58. // contain a single file. We ignore any other files. Note we also allow
  59. // the download to continue if there aren't any files in the directory -
  60. // that would mean that the file (but not the directory) was unexpectedly
  61. // deleted.
  62. if (exists) {
  63. const files = await readdir(finalDirectory)
  64. if (files.length >= 1) {
  65. return finalDirectory + '/' + files[0]
  66. }
  67. }
  68. // The "temporary" output, aka the download location. Generally in a
  69. // temporary location as returned by tempy.
  70. const tempFile = await baseFunction(arg)
  71. // Then move the download to the final location. First we need to make the
  72. // folder exist, then we move the file.
  73. const finalFile = finalDirectory + '/' + path.basename(tempFile)
  74. await mkdirp(finalDirectory)
  75. await rename(tempFile, finalFile)
  76. // And return.
  77. return finalFile
  78. }
  79. }
  80. const removeFileProtocol = arg => {
  81. const fileProto = 'file://'
  82. if (arg.startsWith(fileProto)) {
  83. return decodeURIComponent(arg.slice(fileProto.length))
  84. } else {
  85. return arg
  86. }
  87. }
  88. // Generally target file extension, used by youtube-dl
  89. export const extension = 'mp3'
  90. const downloaders = {}
  91. downloaders.http =
  92. cachify('http',
  93. arg => {
  94. const {hostname, pathname} = new url.URL(arg)
  95. return hostname + pathname
  96. },
  97. arg => {
  98. const out = (
  99. tempy.directory() + '/' +
  100. sanitize(decodeURIComponent(path.basename(arg))))
  101. return fetch(arg)
  102. .then(response => response.buffer())
  103. .then(buffer => writeFile(out, buffer))
  104. .then(() => out)
  105. })
  106. downloaders.youtubedl =
  107. cachify('youtubedl',
  108. arg => (arg.match(/watch\?v=(.*)/) || ['', arg])[1],
  109. arg => {
  110. const outDir = tempy.directory()
  111. const outFile = outDir + '/%(id)s-%(uploader)s-%(title)s.%(ext)s'
  112. const opts = [
  113. '--quiet',
  114. '--no-warnings',
  115. '--extract-audio',
  116. '--audio-format', extension,
  117. '--output', outFile,
  118. arg
  119. ]
  120. return promisifyProcess(spawn('youtube-dl', opts))
  121. .then(() => readdir(outDir))
  122. .then(files => outDir + '/' + files[0])
  123. })
  124. downloaders.local =
  125. cachify('local',
  126. arg => arg,
  127. arg => {
  128. // Usually we'd just return the given argument in a local
  129. // downloader, which is efficient, since there's no need to
  130. // copy a file from one place on the hard drive to another.
  131. // But reading from a separate drive (e.g. a USB stick or a
  132. // CD) can take a lot longer than reading directly from the
  133. // computer's own drive, so this downloader copies the file
  134. // to a temporary file on the computer's drive.
  135. // Ideally, we'd be able to check whether a file is on the
  136. // computer's main drive mount or not before going through
  137. // the steps to copy, but I'm not sure if there's a way to
  138. // do that (and it's even less likely there'd be a cross-
  139. // platform way).
  140. // It's possible the downloader argument starts with the "file://"
  141. // protocol string; in that case we'll want to snip it off and URL-
  142. // decode the string.
  143. arg = removeFileProtocol(arg)
  144. // TODO: Is it necessary to sanitize here?
  145. // Haha, the answer to "should I sanitize" is probably always YES..
  146. const base = path.basename(arg, path.extname(arg))
  147. const out = tempy.directory() + '/' + sanitize(base) + path.extname(arg)
  148. return copyFile(arg, out)
  149. .then(() => out)
  150. })
  151. downloaders.locallink =
  152. cachify('locallink',
  153. arg => arg,
  154. arg => {
  155. // Like the local downloader, but creates a symbolic link to the argument.
  156. arg = removeFileProtocol(arg)
  157. const base = path.basename(arg, path.extname(arg))
  158. const out = tempy.directory() + '/' + sanitize(base) + path.extname(arg)
  159. return symlink(path.resolve(arg), out)
  160. .then(() => out)
  161. })
  162. downloaders.echo =
  163. arg => arg
  164. export default downloaders
  165. export function getDownloaderFor(arg) {
  166. if (arg.startsWith('http://') || arg.startsWith('https://')) {
  167. if (arg.includes('youtube.com')) {
  168. return downloaders.youtubedl
  169. } else {
  170. return downloaders.http
  171. }
  172. } else {
  173. // return downloaders.local
  174. return downloaders.locallink
  175. }
  176. }