tutorial // Aug 06, 2021

How to Fetch a YouTube Video's Duration in Node.js

How to use the YouTube API to fetch a video's metadata and parse the duration string to get hours, minutes, and seconds separately.

How to Fetch a YouTube Video's Duration in Node.js

Getting Started

For this tutorial, we're going to use the CheatCode Node.js Boilerplate to give us a starting point for our work. To start, let's clone a copy:

Terminal

git clone https://github.com/cheatcode/nodejs-server-boilerplate.git

Next, install the dependencies:

Terminal

cd nodejs-server-boilerplate && npm install

After those are installed, add the node-fetch dependency which we'll use to send requests to the YouTube API:

Terminal

npm i node-fetch

With that installed, start up the development server:

Terminal

npm run dev

Once running, we're ready to jump into the code.

Wiring up an endpoint for fetching durations

Before we jump into fetching durations, we're going to set up an HTTP endpoint using Express that we can use to call our fetch code.

/api/index.js

import graphql from "./graphql/server";
import getYoutubeVideoDuration from "../lib/getYoutubeVideoDuration";

export default (app) => {
  graphql(app);
  app.use("/youtube/duration/:videoId", async (req, res) => {
    const duration = await getYoutubeVideoDuration(req?.params?.videoId);
    res.set("Content-Type", "application/json");
    res.send(JSON.stringify(duration, null, 2));
  });
};

In the boilerplate we're using for this tutorial, an Express app is already initialized for us in /index.js at the root of the app. In that file, multiple functions are imported and passed the Express app instance. In this file, we have one of those functions defined that's responsible for defining our API-related routes.

By default, the boilerplate supports a GraphQL API which has been imported here and called handing off the Express app instance. The point here is organization; nothing technical. All you need to understand at this point is that the app being passed in as the argument to the function we're defining here is the app instance returned when we call the express() function exported by express.

The important part here is how we're using that app instance. To make fetching our video durations easier, we're defining a new route via the app.use() method exported by Express. Here, we expect the URL http://localhost:5001/youtube/duration/:videoId to return us an array of one or more objects detailing the duration for one or more videos. Here, :videoId will be replaced by one or more YouTube video IDs (e.g., http://localhost:5001/youtube/duration/RjzC1Dgh17A or http://localhost:5001/youtube/duration/RjzC1Dgh17A,KgzQuE1pR1w,VH8RoWfklg4).

In the callback of the function, we can see that we're calling to a function that we'll define next getYoutubeVideoDuration(), passing it the expected :videoId from our URL via req?.params?.videoId where the ? question marks are just a short-hand way of saying "if req exists and params exists on req, and videoId exists on req.params, return the videoId here." Again, videoId will be a string containing one or several YouTube video IDs (if more than one, we expect them to be comma-separated).

When we call this function, we make a point to put an await keyword in front of it and make sure to add the async keyword to our route's callback function. This is required. If we omit the async keyword, we'll get an error when we run this code about await being a reserved keyword. Here, await is saying "when you get to this line of code, wait until the JavaScript Promise it returns is resolved, or, wait until this code completes before evaluating the lines after this one."

Next, in order to respond to the request, we first set the Content-Type header to application/json using the res.set() method provided by Express and then finally, respond to the request with our found durations array via res.send(). Here, the JSON.stringify(duration, null, 2) part is just "prettifying" the string we return so it's spaced out in the browser and not mushed together (helpful for readability).

Now that we have our basic scaffolding set up, to make this work, let's take a look at the getYoutubeVideoDuration function we're importing up at the top of the file.

Fetching a video's metadata from the YouTube API

Two things to do. First, we need to make a request to YouTube's API to fetch the metadata for our video(s)—this will include the duration for the video—and second, we need to parse the duration from that metadata so that it's easier to use in our app (hypothetically speaking).

Let's wire up the request to the API now and get back the metadata:

/lib/getYoutubeVideoDuration.js

import fetch from "node-fetch";
import { URL, URLSearchParams } from "url";
import settings from "./settings";

const getDuration = (durationString = "") => {
  // We'll handle conversion of the duration string for each video here...
};

export default async (youtubeVideoId = '') => {
  const url = new URL("https://www.googleapis.com/youtube/v3/videos");
  url.search = new URLSearchParams({
    key: settings?.youtube?.apiKey,
    part: "contentDetails",
    id: youtubeVideoId,
  }).toString();

  return fetch(url)
    .then(async (response) => {
      const data = await response.json();
      const videos = data?.items || [];
      return videos.map((video) => {
        return {
          id: video?.id,
          duration: getDuration(video?.contentDetails?.duration),
        };
      });
    })
    .catch((error) => {
      console.warn(error);
    });
};

To make our work a bit easier, we're outputting all of the code we'll need to communicate with the YouTube API here. To start, from this file, we export a function that takes in the anticipated youtubeVideoId string (we use a singular form here but this doesn't change that we can pass a string with a comma-separated list).

Next, using the URL constructor function imported from the native Node.js url package—native meaning you don't need to install anything extra—we create a new url object, passing in the base URL for the YouTube API (specifically, v3 of the videos endpoint).

With our url object (what we get back from new URL()), next, in order to pass data to YouTube, we need to use query params (as opposed to a POST body). To make passing those query params less error-prone, we use the URLSearchParams constructor function also imported from the Node.js url package. To it, we pass an object that we want to serialize (convert) into a query string like this ?key=someAPIKey&part=contentDetails&id=someVideoId. Here, we assign url.search to this where the search property is the name used by the url library to refer to the query params on the URL object (a technical artifact of the original intent of query params which is to aid in adding context to a search operation).

Focusing in on what params we're passing, there are three we care about:

  1. key which contains our YouTube API key (if you don't have one of these yet learn how to generate one here—make sure to get the API key version, not the OAuth2 version).
  2. part which describes which part of the available data returned by the YouTube API we want in return to our request.
  3. id which is the string of one or more Youtube video IDs we want to fetch data for.

Of note, the key we're pulling in here is using the settings convention that's built-in to the boilerplate we're using. This gives us an environment-specific way to store configuration data safely in our app. The settings value being imported at the top is from the /lib/settings.js file which contains code that decides which settings file to load from the root of our app. It does this using the current value of process.env.NODE_ENV.

For this tutorial, because we're in the development environment, we'll load up the settings-development.json file at the root of our app. If we were deploying to a production environment, we'd load up settings-production.json. Taking a quick look at that file, let's see where our Youtube API key needs to go:

/settings-development.json

{
  "authentication": {
    "token": "abcdefghijklmnopqrstuvwxyz1234567890"
  },
  ...
  "youtube": {
    "apiKey": "Your key goes here..."
  }
}

Alphabetically, we add a property youtube to the main settings object with a nested apiKey property with its value set to the API key we retrieved from YouTube. Back in our code when we call to settings?.youtube?.apiKey, this is the value we're referencing.

/lib/getYoutubeVideoDuration.js

import fetch from "node-fetch";
import { URL, URLSearchParams } from "url";
import settings from "./settings";

const getDuration = (durationString = "") => {
  // We'll handle conversion of the duration string for each video here...
};

export default async (youtubeVideoId = '') => {
  const url = new URL("https://www.googleapis.com/youtube/v3/videos");
  url.search = new URLSearchParams({
    key: settings?.youtube?.apiKey,
    part: "contentDetails",
    id: youtubeVideoId,
  }).toString();

  return fetch(url)
    .then(async (response) => {
      const data = await response.json();
      const videos = data?.items || [];
      return videos.map((video) => {
        return {
          id: video?.id,
          duration: getDuration(video?.contentDetails?.duration),
        };
      });
    })
    .catch((error) => {
      console.warn(error);
    });
};

With all of our config out of the way, we're ready to fetch our video metadata from YouTube. Using the fetch function we're importing up top from the node-fetch package we installed earlier (this is just a Node-friendly implementation of the browser fetch() method), we pass in our url object, appending a .then() and .catch() callback on the end, meaning we anticipate that our call to fetch() will return a JavaScript Promise.

In the .catch() callback, if something goes wrong, we just log out the error to our server console with console.warn() (you may want to hand this off to your logging tool, if applicable).

The part we care about here, the .then() callback, is where all of the action happens. First, we take the response argument we expect to be passed to the .then() callback, calling its .json() method and using the await keyword—remembering to add the async keyword to the callback function to avoid a syntax error.

Here, response.json() is a function that fetch() provides us which allows us to convert the HTTP response object we get back into a format of our choice (within the limitations of the API we're calling to). In this case, we expect the data YouTube sends back to us to be in a JSON format, so we use the .json() method here to convert the raw response into JSON data.

With that data object, next, we expect YouTube to have added an items property on that object which contains an array of one or more objects describing the video IDs we passed via the id query param in our url.

Now for the fun part. With our list of videos (one or more), we want to format that data into something that's more usable in our application. By default, YouTube formats the duration timestamp stored under the video's contentDetails object as a string that looks something like PT1H23M15S which describes a video with a video duration of 1 hour, 23 minutes, and 15 seconds.

As-is, this string isn't very helpful, so we want to convert it into something we can actually use in our code. To do it, in the next section, we're going to rig up that getDuration() method we're calling here.

Before we do, so it's clear, once we've retrieved this formatted duration value, because we're returning our call to videos.map() back to our .then() callback and also returning our call to fetch() from our function, we expect the mapped videos array to be the value returned from the function we're exporting from this file (what ultimately gets handed back to our res.send() in `/api/index.js).

Parsing the duration string returned by the YouTube API

Let's isolate that getDuration() function we spec'd out at the top of our file and walk through how it works.

/lib/getYoutubeVideoDuration.js

const getDuration = (durationString = "") => {
  const duration = { hours: 0, minutes: 0, seconds: 0 };
  const durationParts = durationString
    .replace("PT", "")
    .replace("H", ":")
    .replace("M", ":")
    .replace("S", "")
    .split(":");

  if (durationParts.length === 3) {
    duration["hours"] = durationParts[0];
    duration["minutes"] = durationParts[1];
    duration["seconds"] = durationParts[2];
  }

  if (durationParts.length === 2) {
    duration["minutes"] = durationParts[0];
    duration["seconds"] = durationParts[1];
  }

  if (durationParts.length === 1) {
    duration["seconds"] = durationParts[0];
  }

  return {
    ...duration,
    string: `${duration.hours}h${duration.minutes}m${duration.seconds}s`,
  };
};

Our goal here is to get back an object with four properties:

  1. hours describing how many hours (0 or more) the video plays for.
  2. minutes describing how many minutes (0 or more) the video plays for.
  3. seconds describing how many seconds the video plays for.
  4. A string concatenating together the above three values that we can—hypothetically—display in the UI of our app.

To get there, first, we initialize an object called duration which will contain the hours, minutes, and seconds for our video. Here, we set those properties on the object and default them to 0.

Next, remember that our duration string looks something like: PT1H23M15S. It can also look like PT23M15S or PT15S if it's less than an hour in length or less than a minute in length. To handle these different cases, here, we take the durationString we've passed in and first remove the PT part using .replace() and then swap the H and M parts with a : symbol, and finally, remove the S value.

At the end of this chain, we call a .split() on the : character that we just added into the string to split our hours, minutes, and seconds, into an array. So it's clear, the transformation flows like this:

// 1
PT1H23M15S

// 2
1H23M15S

// 3
1:23:15S

// 4
1:23:15

// 5
['1', '23', '15']

With these durationParts we can start to move towards an easier to work with duration value. More specifically, the work we need to do is decide what the hours, minutes, and seconds properties on our duration object that we defined at the top of our function need to be set to (if at all).

The trick we're using here is to test the length of the durationParts array. If it contains 3 items, we know that it has hours, minutes, and seconds. If it contains 2 items, we know that it has minutes and seconds. And if it has 1 item, we know that it has seconds.

For each of these cases, we add an if statement, inside which we overwrite the appropriate values on our duration object corresponding to the appropriate duration part in the durationParts array. So, here, if we have 3 items, we set the duration.hours to the first item in the array, duration.minutes to the second item in the array, and duration.seconds to the third item in the array (in case the 0, 1, 2 here is confusing, remember that JavaScript arrays are zero-based meaning the first item in the array is in position zero).

We repeat this pattern for the other two cases, only overwriting the values that we expect to be greater than zero (minutes and seconds for the 2 item array and just seconds for the 1 item array).

With our duration object built, finally, at the bottom of our getDuration() function we return an object, using the JavaScript ... spread operator to "unpack" our duration object properties onto that new object and add an additional string property that concatenates our duration object's values together in a string.

That's it! Now, we're ready to take this thing for a spin.

Testing out fetching a duration

To test this out, let's load up our HTTP endpoint we defined at the beginning of the tutorial in the browser and pass it some Youtube video IDs:

Awesome! Try it out with any YouTube video ID to get the duration object back.

Wrapping Up

In this tutorial, we learned how to wire up an HTTP endpoint in Express to help us call to a function that sends a GET request for a YouTube video's metadata via the YouTube API. We learned how to use node-fetch to help us perform the request as well as how to write a function to help us parse the YouTube duration string we got back from the API.

Written By
Ryan Glover

Ryan Glover

CEO/CTO @ CheatCode