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.
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:
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).part
which describes which part of the available data returned by the YouTube API we want in return to our request.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:
hours
describing how many hours (0
or more) the video plays for.minutes
describing how many minutes (0
or more) the video plays for.seconds
describing how many seconds the video plays for.- 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.