Skip to content

Commit

Permalink
Import Youtube playlist
Browse files Browse the repository at this point in the history
  • Loading branch information
abolkog committed Mar 12, 2021
1 parent 4fdd309 commit 8b81766
Show file tree
Hide file tree
Showing 12 changed files with 3,173 additions and 92 deletions.
14 changes: 14 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name: Syntax and Linting

on: [pull_request]

jobs:
run-tests:
name: Run unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Install dependencies
run: yarn
- name: Run Tests
run: yarn test
136 changes: 97 additions & 39 deletions api/helpers/youtube.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,102 @@ const axios = require('axios');
const VALIDATE_YOUTUBE_REGEX = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/gi;
const EXTRACT_YOUTUBE_REGEX = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#\&\?]*).*/;

/**
* Check if a URL is a valid Youtube URL using RegEx
* @param {string} url The URL to check
*/
const isValidYoutubeUrl = (url) => {
return VALIDATE_YOUTUBE_REGEX.test(url);
};

/**
* Extract Youtube Video ID from a Youtube URL
* @param {string} url Youtube URL
*/
const getVideoId = (url) => {
const match = url.match(EXTRACT_YOUTUBE_REGEX);
if (match && match[7].length == 11) {
return match[7];
}
return null;
};

/**
* Get Youtube video duration
* @param {string} videoId Youtube Video ID
*/
const getVideoDuration = async (videoId) => {
try {
if (!videoId) throw new Error('Video Id is not defined');
const key = process.env.YOUTUBE_API_KEY;
const url = `https://www.googleapis.com/youtube/v3/videos?id=${videoId}&part=contentDetails&key=${key}`;
const { data } = await axios.get(url);
const { items } = data;
const {
contentDetails: { duration },
} = items[0];

return duration;
} catch (e) {
return null;
}
};

/**
* Get Youtube PlayList ID from the URL
* @param {string} url youtube url
*/
const getPlayListId = (url) => {
if (!url) return null;
const REGEX = /[&?]list=([^&]+)/;
const match = url.match(REGEX);
if (match && match[1]) {
return match[1];
}
return null;
};

/**
* Get Youtube Playlist contents
* @param {string} playListId
*/
const getPlaylistContents = async (playListId) => {
try {
if (!playListId) throw new Error(`Playlist Id is not defined: ${playListId}`);
const key = process.env.YOUTUBE_API_KEY;
// TODO: Future improvements: Use pagination instead of max result?
const url = `https://www.googleapis.com/youtube/v3/playlistItems?playlistId=${playListId}&part=snippet&key=${key}&maxResults=50`;
const { data } = await axios.get(url);
const { items } = data;

const asyncResult = items.map(async (item) => {
const { snippet } = item;
const { title, position, resourceId } = snippet;
if (!resourceId || resourceId.kind !== 'youtube#video' || !resourceId.videoId) {
throw new Error('Unable to get video id');
}
const duration = await getVideoDuration(resourceId.videoId);

if (!duration) throw new Error(`Unable to get duration for video ${videoId}`);

return {
title,
position,
url: `https://www.youtube.com/watch?v=${resourceId.videoId}`,
duration,
};
});

return await Promise.all(asyncResult);
} catch (e) {
return null;
}
};

module.exports = {
/**
* Check if a URL is a valid Youtube URL using RegEx
* @param {*} url The URL to check
*/
isValidYoutubeUrl(url) {
return VALIDATE_YOUTUBE_REGEX.test(url);
},
/**
* Extract Youtube Video ID from a Youtube URL
* @param {*} url Youtube URL
*/
getVideoId(url) {
const match = url.match(EXTRACT_YOUTUBE_REGEX);
if (match && match[7].length == 11) {
return match[7];
} else {
return null;
}
},
/**
* Get Youtube video duration
* @param {*} videoId Youtube Video ID
*/
async getVideoDuration(videoId) {
try {
const key = process.env.YOUTUBE_API_KEY;

const url = `https://www.googleapis.com/youtube/v3/videos?id=${videoId}&part=contentDetails&key=${key}`;
const { data } = await axios.get(url);
const { items } = data;
const {
contentDetails: { duration },
} = items[0];

return duration;
} catch (e) {
return null;
}
},
isValidYoutubeUrl,
getVideoId,
getVideoDuration,
getPlayListId,
getPlaylistContents,
};
8 changes: 8 additions & 0 deletions api/lecture/config/routes.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@
"config": {
"policies": []
}
},
{
"method": "POST",
"path": "/lectures/import",
"handler": "lecture.import",
"config": {
"policies": []
}
}
]
}
43 changes: 42 additions & 1 deletion api/lecture/controllers/lecture.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
'use strict';
const { sanitizeEntity } = require('strapi-utils');

const { isValidYoutubeUrl, getVideoId, getVideoDuration } = require('../../helpers/youtube');
const {
isValidYoutubeUrl,
getVideoId,
getVideoDuration,
getPlaylistContents,
getPlayListId,
} = require('../../helpers/youtube');

module.exports = {
async create(ctx) {
Expand Down Expand Up @@ -52,4 +58,39 @@ module.exports = {
const entity = await strapi.services.lecture.delete({ id });
return sanitizeEntity(entity, { model: strapi.models.lecture });
},
async import(ctx) {
const {
state: { user },
request: { body },
} = ctx;

const course = await strapi.services.course.findOne({
id: body.course,
'instructor.id': user.id,
});

if (!course) {
return ctx.response.notFound('Invalid course');
}

const playListId = getPlayListId(body.url);
if (!playListId) {
return ctx.response.send(
{ error: 'Unable to get playlist id, make sure you are passing a valid youtube URL' },
500
);
}

const data = await getPlaylistContents(playListId);
const asyncCreate = data.map(async (item) => {
return await strapi.services.lecture.create({
...item,
course: body.course,
});
});

const lectures = await Promise.all(asyncCreate);

return lectures.map((lecture) => sanitizeEntity(lecture, { model: strapi.models.lecture }));
},
};
1 change: 1 addition & 0 deletions config/functions/permissions.helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const initPermissions = async () => {
await enablePermission('teacher', 'course', 'findoneforteacher');
await enablePermission('teacher', 'course', 'findforteacher');
await enablePermission('teacher', 'course', 'patchcourse');
await enablePermission('teacher', 'lecture', 'import');

await enablePermission('authenticated', 'profile', 'create');
await enablePermission('authenticated', 'profile', 'update');
Expand Down
21 changes: 17 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@
"develop": "strapi develop",
"start": "strapi start",
"build": "strapi build",
"strapi": "strapi"
"strapi": "strapi",
"test": "jest --forceExit --detectOpenHandles"
},
"devDependencies": {
"jest": "^26.6.3",
"sqlite3": "^5.0.2",
"supertest": "^6.1.3"
},
"devDependencies": {},
"dependencies": {
"algoliasearch": "^4.3.0",
"axios": "^0.19.2",
Expand All @@ -27,7 +32,7 @@
"strapi-utils": "3.0.0"
},
"author": {
"name": "A Strapi developer"
"name": "nyala.dev"
},
"strapi": {
"uuid": "bb3598d4-69d3-4bc9-a340-db30d747dbe4"
Expand All @@ -36,5 +41,13 @@
"node": ">=10.0.0",
"npm": ">=6.0.0"
},
"license": "MIT"
"license": "MIT",
"jest": {
"testPathIgnorePatterns": [
"/node_modules/",
".tmp",
".cache"
],
"testEnvironment": "node"
}
}
19 changes: 19 additions & 0 deletions tests/helpers/strapi.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const Strapi = require('strapi');
const http = require('http');

let instance;

async function setupStrapi() {
if (!instance) {
/** the following code in copied from `./node_modules/strapi/lib/Strapi.js` */
await Strapi().load();
instance = strapi; // strapi is global now
await instance.app
.use(instance.router.routes()) // populate KOA routes
.use(instance.router.allowedMethods()); // populate KOA methods

instance.server = http.createServer(instance.app.callback());
}
return instance;
}
module.exports = { setupStrapi };
12 changes: 12 additions & 0 deletions tests/helpers/util.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const util = require('../../api/helpers/util');

describe('Test Slugify', () => {
it('should return slugify version of a string', () => {
const string = 'nyala dev';
expect(util.slugify(string)).toBe('nyala-dev');
});
it('should return slugify version of an arabicstring', () => {
const string = 'الاسطورة خالد';
expect(util.slugify(string)).toBe('الاسطورة-خالد');
});
});
Loading

0 comments on commit 8b81766

Please sign in to comment.