Clip Web videos.
Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.

143 wiersze
3.5 KiB

  1. import { EventEmitter } from 'events';
  2. import { spawnSync } from 'child_process';
  3. import { readFileSync, unlinkSync } from 'fs';
  4. import { CreateBaseClipperParams, FILE_TYPES, VideoClipEventEmitter } from '../common';
  5. import { convertDurationToSeconds, convertSecondsToDuration } from '../duration';
  6. const normalizeStartSeconds = (start?: number | string) => {
  7. if (typeof start === 'undefined') {
  8. return 0;
  9. }
  10. if (typeof start === 'string') {
  11. return convertDurationToSeconds(start);
  12. }
  13. return start;
  14. };
  15. const FORMAT_DEFAULT_ARGS = [
  16. '--youtube-skip-dash-manifest',
  17. '-f', 'bestvideo+bestaudio',
  18. ];
  19. const getFileExtension = (downloaderExecutablePath: string, url: string) => {
  20. const result = spawnSync(
  21. downloaderExecutablePath,
  22. [
  23. ...FORMAT_DEFAULT_ARGS,
  24. '--print', 'filename',
  25. '-o', '%(ext)s',
  26. url,
  27. ],
  28. );
  29. if (result.error) {
  30. throw result.error;
  31. }
  32. return result.stdout.toString('utf-8').trim();
  33. };
  34. const constructDownloadSectionsRegex = (start?: number | string, end?: number | string) => {
  35. const startSeconds = normalizeStartSeconds(start);
  36. if (typeof end !== 'undefined') {
  37. const endSeconds = (
  38. typeof end === 'string'
  39. ? convertDurationToSeconds(end)
  40. : end
  41. );
  42. return `*${convertSecondsToDuration(startSeconds)}-${convertSecondsToDuration(endSeconds)}`;
  43. }
  44. if (startSeconds > 0) {
  45. return `*${convertSecondsToDuration(startSeconds)}-inf`;
  46. }
  47. return null;
  48. };
  49. const constructDefaultDownloadArgs = (
  50. outputFilename: string,
  51. url: string,
  52. start?: number | string,
  53. end?: number | string,
  54. ) => {
  55. const defaultDownloadArgs = [
  56. ...FORMAT_DEFAULT_ARGS,
  57. '-o', outputFilename,
  58. ];
  59. const downloadSectionsRegex = constructDownloadSectionsRegex(start, end);
  60. if (typeof downloadSectionsRegex === 'string') {
  61. return [
  62. ...defaultDownloadArgs,
  63. '--force-keyframes-at-cuts',
  64. '--download-sections', downloadSectionsRegex,
  65. url,
  66. ];
  67. }
  68. return [
  69. ...defaultDownloadArgs,
  70. url,
  71. ];
  72. };
  73. export interface CreateYouTubeClipperParams extends CreateBaseClipperParams {
  74. downloaderExecutablePath?: string;
  75. }
  76. export class YouTubeVideoClipEventEmitter extends EventEmitter implements VideoClipEventEmitter {
  77. constructor(private readonly params: CreateYouTubeClipperParams) {
  78. super();
  79. }
  80. process() {
  81. if (!this.params.downloaderExecutablePath) {
  82. this.emit('error', new Error('Downloader not found.'));
  83. this.emit('end');
  84. return;
  85. }
  86. const fileExtension = getFileExtension(
  87. this.params.downloaderExecutablePath,
  88. this.params.url,
  89. );
  90. const cacheFilename = `output.${fileExtension}`; // todo label this on the cache
  91. const downloadArgs = constructDefaultDownloadArgs(
  92. cacheFilename,
  93. this.params.url,
  94. this.params.start,
  95. this.params.end,
  96. );
  97. this.emit('process', {
  98. type: 'download',
  99. phase: 'start',
  100. command: `${this.params.downloaderExecutablePath} ${downloadArgs.join(' ')}`,
  101. });
  102. const downloaderProcess = spawnSync(
  103. this.params.downloaderExecutablePath,
  104. downloadArgs,
  105. );
  106. if (downloaderProcess.error) {
  107. this.emit('error', downloaderProcess.error);
  108. this.emit('end');
  109. return;
  110. }
  111. this.emit('process', {
  112. type: 'download',
  113. phase: 'success',
  114. });
  115. const output = readFileSync(cacheFilename);
  116. unlinkSync(cacheFilename);
  117. this.emit('success', {
  118. type: FILE_TYPES[fileExtension],
  119. output,
  120. });
  121. this.emit('end');
  122. }
  123. }