Katriel tsepelevish - Software Engineer at Vimeo
How to build a Vimeo video downloader library with TypeScript?

How to build a Vimeo video downloader library with TypeScript?

November 10, 2023·10 min read

Today, we'll be diving into the development of an extendable Vimeo client library with an exportable download function. Additionally, we'll integrate event listeners into the library, allowing your application to stay tuned for real-time updates.

Requirements

  • Node.js: Ensure that Node.js is installed on your system. You can download and install Node.js from the official website: https://nodejs.org/. Node.js is required to run JavaScript applications, including our backend server.

Folders structure

src/
    Vmeo.ts
    index.ts
tsconfig.json
package.json
  • src/ directory contains the source code files Vmeo.ts and index.ts

  • tsconfig.json configures TypeScript compiler options

  • package.json lists project dependencies and scripts

Settings Up the Foundation

Begin by establishing the project for your TypeScript library.

Create a root directory for your project, and within it, initialize the package.json file:

> npm init

Then, install Typescript and other dependencies:

> npm install @katrieltsepelevish/emtr axios
> npm install -D ts-node typescript @types/node

@katrieltsepelevish/emtr is used as an event emitter, you can use any other event emitter library.

Now, add the script for running the build command, and update the main file and typings file in the package.json file:

package.json
{
  "name": "your-project-name",
  "version": "1.0.0",
  "description": "Your project description",
  "main": "dist/index.js",
  "typings": "dist/index.d.ts",
  "scripts": {
    "build": "rm -rf ./dist/ && tsc",
  },
  "keywords": [],
  "author": "Your Name",
  "license": "MIT"
}

Generate the tsconfig.json file:

> npx tsc --init

And update the tsconfig.json to the following:

tsconfig.json
{
  "compilerOptions": {
    "module": "CommonJS",
    "target": "ES2017",
    "baseUrl": "./src",
    "outDir": "./dist",
    "declaration": true
  },
  "include": ["src"]
}

Awesome! With the foundational setup now complete, let's jump into the fun part - building the actual library. Get ready for some hands-on work in the next step!

Implementing the library

TypeScript files will be placed in src directory, and compiled files including the decorations will be placed in dist directory.

In src directory let's create index.ts file, this file is the entry point of the project.

src/index.ts
import Vmeo, { DownloadOptions } from "./Vmeo";

export { DownloadOptions };
export default Vmeo;

The first method that we'll implement will be the urlToEmbedded that is used to convert a Vimeo URL into its corresponding embedded format:

src/Vmeo.ts
class Vmeo {
  private urlToEmbedded = (url: string): string => {
    const vimeoRegex =
      /^(https:\/\/vimeo\.com\/\d+|https:\/\/player\.vimeo\.com\/video\/\d+)$/;

  if (!vimeoRegex.test(url)) {
      throw new Error("Invalid Vimeo URL");
    }

    return url.startsWith("https://vimeo.com/")
      ? url.replace("https://vimeo.com/", "https://player.vimeo.com/video/")
      : url;
  };
}

export default Vmeo;

We validate whether the provided URL is a Vimeo embedded or standard Vimeo URL format, otherwise, we raise an error. If the URL is not in the embedded format, we convert it to the embedded format.

Next, we'll implement the loadFiles method that is used to load video files based on the provided URL:

src/Vmeo.ts
class Vmeo {
  private qualityToUrl = new Map<string, string>();

  private loadFiles = async (url: string): Promise<void> => {
      try {
        const { data } = await Axios({
          method: "get",
          url,
        });

        const scriptRegex =
          /<script>window\.playerConfig = ({[\s\S]+?})<\/script>/;
        const match = data.match(scriptRegex);

        if (!match) {
          throw new Error(`Cannot find data for video ${url}`);
        }

        const parsed = JSON.parse(match[1]);

        const files = parsed?.request?.files?.progressive;

        if (!files) {
          throw new Error("Cannot find files");
        }

        // Populate the qualityToUrl map with video qualities and URLs
        files.forEach(({ quality, url }) => {
          this.qualityToUrl.set(quality, url);
        });
      } catch (error) {
        throw error;
      }
  }

  private urlToEmbedded = (url: string): string => {
    const vimeoRegex =
      /^(https:\/\/vimeo\.com\/\d+|https:\/\/player\.vimeo\.com\/video\/\d+)$/;

  if (!vimeoRegex.test(url)) {
      throw new Error("Invalid Vimeo URL");
    }

    return url.startsWith("https://vimeo.com/")
      ? url.replace("https://vimeo.com/", "https://player.vimeo.com/video/")
      : url;
  };
}

export default Vmeo;

We retrieve the HTML content from the provided embedded URL, extracting video configuration by utilizing a regex match targeting the script tag containing window.playerConfig.

Upon a successful match, we transform the video configuration data into a JSON format. Subsequently, we attempt to access the progressive files within the parsed data, populating the qualityToUrl map with corresponding qualities and URLs. In case of an unsuccessful match or absence of files, an error is thrown, ensuring error handling throughout the process.

Great news! We've arrived at the final step. The only thing left in our checklist is the download action, which lets you download a video from a given URL with some specific options. Let's wrap this up!

src/Vmeo.ts
import Axios from "axios";
import * as Fs from "fs";
import Emtr from "@katrieltsepelevish/emtr";

type Quality = "240p" | "360p" | "540p" | "720p" | "1080p";

type Event = "data" | "error" | "success";

type EventListener<T extends Event, U> = {
  data: (percentage: number) => U;
  success: () => U;
  error: (error: Error) => U;
}[T];

export interface DownloadOptions {
  quality: Quality;
  outputPath: string;
  override?: boolean;
  onProgress?: (percentage: number) => void;
  onSuccess?: () => void;
  onFailure?: (error: Error) => void;
}

class Vmeo {
  private qualityToUrl = new Map<string, string>();
  private emitter = new Emtr();

  public download = async (url: string, options: DownloadOptions) => {
    try {
      if (!url) {
        throw new Error("Cannot find URL");
      }

      const embeddedUrl = this.urlToEmbedded(url);
      await this.loadFiles(embeddedUrl);

      if (!this.qualityToUrl.size) {
        throw new Error(`Cannot find data for video ${embeddedUrl}`);
      }

      if (!options.quality || !this.qualityToUrl.get(options.quality)) {
        throw new Error(`Cannot download video in ${options.quality}`);
      }

      if (Fs.existsSync(options.outputPath) && !options.override) {
        throw new Error(
          `File already exists at ${options.outputPath}. To override the existing file, pass 'ovveride' as true to options.`
        );
      }
      const { data, headers } = await Axios({
        method: "get",
        url: this.qualityToUrl.get(options.quality),
        responseType: "stream",
      });

      const totalSize = Number(headers["content-length"]);
      let downloadedSize = 0;

      const writer = Fs.createWriteStream(options.outputPath, { flags: "w" });

      data.on("data", (chunk: Buffer) => {
        downloadedSize += chunk.length;
        const percentage = (downloadedSize / totalSize) * 100;
        options.onProgress?.(percentage);
        this.emitter.fire("data", percentage);
      });

      data.pipe(writer);

      return new Promise((resolve, reject) => {
        writer.on("finish", () => {
          options.onSuccess?.();
          this.emitter.fire("success");
          resolve(true);
        });

        writer.on("error", (error) => {
          options.onFailure?.(error);
          this.emitter.fire("error");
          reject(error);
        });
      });
    } catch (error) {
      throw error;
    }
  };

  public on<T extends Event>(event: T, listener: EventListener<T, void>): void {
    this.emitter.handle(event, listener);
  }

  private loadFiles = async (url: string): Promise<void> => {
    try {
      const { data } = await Axios({
        method: "get",
        url,
      });
      const scriptRegex =
        /<script>window\.playerConfig = ({[\s\S]+?})<\/script>/;
      const match = data.match(scriptRegex);
      if (!match) {
        throw new Error(`Cannot find data for video ${url}`);
      }
      const parsed = JSON.parse(match[1]);
      const files = parsed?.request?.files?.progressive;
      if (!files) {
        throw new Error("Cannot find files");
      }
      files.forEach(({ quality, url }) => {
        this.qualityToUrl.set(quality, url);
      });
    } catch (error) {
      throw error;
    }
  };

  private urlToEmbedded = (url: string): string => {
    const vimeoRegex =
      /^(https:\/\/vimeo\.com\/\d+|https:\/\/player\.vimeo\.com\/video\/\d+)$/;
    if (!vimeoRegex.test(url)) {
      throw new Error("Invalid Vimeo URL");
    }
    return url.startsWith("https://vimeo.com/")
      ? url.replace("https://vimeo.com/", "https://player.vimeo.com/video/")
      : url;
  };
}

export default Vmeo;

Initially, we ensure the URL is provided and convert it to the embedded format if needed. Following that, we load video files from the embedded URL. Subsequently, we verify the presence of qualities and URLs in the configuration.

We then check whether to override an existing file, if exists. Afterward, we select a file from the loaded files based on the desired quality, initiate a GET request for the video file, and download it. Throughout this process, we emit events for progress, success, and errors.

The on method is used to register an event listener for a specific event, allowing external code to listen for and handle events emitted by the library.

The final version should look like the following:

src/Vmeo.ts
import Axios from "axios";
import * as Fs from "fs";
import Emtr from "@katrieltsepelevish/emtr";

type Quality = "240p" | "360p" | "540p" | "720p" | "1080p";

type Event = "data" | "error" | "success";

type EventListener<T extends Event, U> = {
  data: (percentage: number) => U;
  success: () => U;
  error: (error: Error) => U;
}[T];

export interface DownloadOptions {
  quality: Quality;
  outputPath: string;
  override?: boolean;
  onProgress?: (percentage: number) => void;
  onSuccess?: () => void;
  onFailure?: (error: Error) => void;
}

class Vmeo {
  private qualityToUrl = new Map<string, string>();
  private emitter = new Emtr();

  public download = async (url: string, options: DownloadOptions) => {
    try {
      if (!url) {
        throw new Error("Cannot find URL");
      }

      const embeddedUrl = this.urlToEmbedded(url);
      await this.loadFiles(embeddedUrl);

      if (!this.qualityToUrl.size) {
        throw new Error(`Cannot find data for video ${embeddedUrl}`);
      }

      if (!options.quality || !this.qualityToUrl.get(options.quality)) {
        throw new Error(`Cannot download video in ${options.quality}`);
      }

      if (Fs.existsSync(options.outputPath) && !options.override) {
        throw new Error(
          `File already exists at ${options.outputPath}. To override the existing file, pass 'ovveride' as true to options.`
        );
      }
      const { data, headers } = await Axios({
        method: "get",
        url: this.qualityToUrl.get(options.quality),
        responseType: "stream",
      });

      const totalSize = Number(headers["content-length"]);
      let downloadedSize = 0;

      const writer = Fs.createWriteStream(options.outputPath, { flags: "w" });

      data.on("data", (chunk: Buffer) => {
        downloadedSize += chunk.length;
        const percentage = (downloadedSize / totalSize) * 100;
        options.onProgress?.(percentage);
        this.emitter.fire("data", percentage);
      });

      data.pipe(writer);

      return new Promise((resolve, reject) => {
        writer.on("finish", () => {
          options.onSuccess?.();
          this.emitter.fire("success");
          resolve(true);
        });

        writer.on("error", (error) => {
          options.onFailure?.(error);
          this.emitter.fire("error");
          reject(error);
        });
      });
    } catch (error) {
      throw error;
    }
  };

  public on<T extends Event>(event: T, listener: EventListener<T, void>): void {
    this.emitter.handle(event, listener);
  }

  private loadFiles = async (url: string): Promise<void> => {
    try {
      const { data } = await Axios({
        method: "get",
        url,
      });
      const scriptRegex =
        /<script>window\.playerConfig = ({[\s\S]+?})<\/script>/;
      const match = data.match(scriptRegex);
      if (!match) {
        throw new Error(`Cannot find data for video ${url}`);
      }
      const parsed = JSON.parse(match[1]);
      const files = parsed?.request?.files?.progressive;
      if (!files) {
        throw new Error("Cannot find files");
      }
      files.forEach(({ quality, url }) => {
        this.qualityToUrl.set(quality, url);
      });
    } catch (error) {
      throw error;
    }
  };

  private urlToEmbedded = (url: string): string => {
    const vimeoRegex =
      /^(https:\/\/vimeo\.com\/\d+|https:\/\/player\.vimeo\.com\/video\/\d+)$/;
    if (!vimeoRegex.test(url)) {
      throw new Error("Invalid Vimeo URL");
    }
    return url.startsWith("https://vimeo.com/")
      ? url.replace("https://vimeo.com/", "https://player.vimeo.com/video/")
      : url;
  };
}

export default Vmeo;

Publishing the library

To enable the publication of the library, you need to modify the package configuration in the package.json file to a public access. This signifies that the package will be open to the public, allowing anyone to download and install it. Public access is the default setting for packages on the npm registry.

package.json
{
  "name": "your-project-name",
  "version": "1.0.0",
  "description": "Your project description",
  "main": "dist/index.js",
  "typings": "dist/index.d.ts",
  "scripts": {
    "build": "rm -rf ./dist/ && tsc",
  },
  "keywords": [],
  "author": "Your Name",
  "license": "MIT"
  "private": false,
  "publishConfig": {
    "access": "public"
  }
}

Now, to publish the package on npm, execute the following commands:

> npm run build
> npm publish --access=public

Note: Make sure to bump the version before publishing

Using the library

The provided code snippet serves an example showing the recommended usage of the library.

import * as Path from "path";
import Vmeo, { DownloadOptions } from "@katrieltsepelevish/vmeo";

const vmeo = new Vmeo();
const videoUrl = "https://vimeo.com/VIDEO_ID";

const options: DownloadOptions = {
  quality: "720p",
  outputPath: Path.join(__dirname, "/path/to/save/video.mp4"),
  override: false,
  onProgress: (percentage) => {
    console.log(`Downloading: ${percentage.toFixed(2)}%`);
  },
  onSuccess: () => {
    console.log("Download completed successfully!");
  },
  onFailure: (error) => {
    console.error(`Download failed: ${error.message}`);
  },
};

// Add event listeners
vmeo.on("data", (percentage) => {
  console.log(`Data event received: ${percentage.toFixed(2)}%`);
});
vmeo.on("success", () => {
  console.log("Success event received!");
});
vmeo.on("error", (error) => {
  console.error(`Error event received: ${error.message}`);
});
vmeo.download(videoUrl, options);

Conclusion

Hope you enjoyed and took away something useful from this article. The source code used in this article is published on Github.

Thanks for reading!

Vimeo continually introduces new features, stay tuned and check periodically to ensure you're up-to-date with the latest enhancements.

See All Articles