feat: added lesson video model

This commit is contained in:
Sergey Yarkov 2021-12-31 01:14:18 +03:00
parent 5171f460ed
commit cd4eb2513c
10 changed files with 225 additions and 27 deletions

View File

@ -1,7 +1,6 @@
import { Exception, inject, Ioc } from '@adonisjs/core/build/standalone'; import { Exception, inject, Ioc } from '@adonisjs/core/build/standalone';
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
import { schema } from '@ioc:Adonis/Core/Validator'; import { schema } from '@ioc:Adonis/Core/Validator';
import { extname } from 'path';
/** /**
* Services * Services
@ -12,6 +11,7 @@ import LessonService from 'App/Services/LessonService';
* Models * Models
*/ */
import LessonMaterial from 'App/Models/LessonMaterial'; import LessonMaterial from 'App/Models/LessonMaterial';
import LessonVideo from 'App/Models/LessonVideo';
/** /**
* Validators * Validators
@ -19,7 +19,6 @@ import LessonMaterial from 'App/Models/LessonMaterial';
import CreateLessonValidator from 'App/Validators/Lesson/CreateLessonValidator'; import CreateLessonValidator from 'App/Validators/Lesson/CreateLessonValidator';
import UpdateLessonValidator from 'App/Validators/Lesson/UpdateLessonValidator'; import UpdateLessonValidator from 'App/Validators/Lesson/UpdateLessonValidator';
import Drive from '@ioc:Adonis/Core/Drive';
import BaseController from '../../BaseController'; import BaseController from '../../BaseController';
@inject() @inject()
@ -133,21 +132,7 @@ export default class LessonsController extends BaseController {
* Send file to user * Send file to user
*/ */
if (data instanceof LessonMaterial) { if (data instanceof LessonMaterial) {
const path = `materials/${data.name}`; this.sendFileFromDrive(`materials/${data.name}`, ctx);
/**
* Material file not found
*/
if (!(await Drive.exists(path))) return ctx.response.notFound();
const { size } = await Drive.getStats(path);
ctx.response.type(extname(data.name));
ctx.response.header('content-length', size);
const stream = await Drive.getStream(path);
return this.sendStream(ctx, stream);
} }
return this.sendResponse(ctx, data, message, status); return this.sendResponse(ctx, data, message, status);
@ -185,6 +170,28 @@ export default class LessonsController extends BaseController {
return this.sendResponse(ctx, data, message, status); return this.sendResponse(ctx, data, message, status);
} }
/**
* Get video file of lesson by file name
* GET /lessons/video/:fileName
*/
public async getVideo(ctx: HttpContextContract) {
const { fileName } = ctx.request.params();
const { data, message, status, success, error } = await this.lessonService.fetchVideoFile(ctx, fileName);
if (!success && error) {
throw new Exception(message, status, error.code);
}
/**
* Send file to user
*/
if (data instanceof LessonVideo) {
return this.sendFileFromDrive(`videos/${data.name}`, ctx);
}
return this.sendResponse(ctx, data, message, status);
}
} }
new Ioc().make(LessonsController); new Ioc().make(LessonsController);

View File

@ -1,6 +1,8 @@
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'; import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
import { ResponseContract } from '@ioc:Adonis/Core/Response'; import { ResponseContract } from '@ioc:Adonis/Core/Response';
import Drive from '@ioc:Adonis/Core/Drive';
import { extname } from 'path';
export default class BaseController { export default class BaseController {
public sendResponse(ctx: HttpContextContract, data: object, message: string, status = 200, meta?: any): void { public sendResponse(ctx: HttpContextContract, data: object, message: string, status = 200, meta?: any): void {
@ -21,4 +23,20 @@ export default class BaseController {
public sendFile(ctx: HttpContextContract, ...args: Parameters<ResponseContract['attachment']>) { public sendFile(ctx: HttpContextContract, ...args: Parameters<ResponseContract['attachment']>) {
return ctx.response.attachment(...args); return ctx.response.attachment(...args);
} }
public async sendFileFromDrive(pathname: string, ctx: HttpContextContract) {
/**
* File not found
*/
if (!(await Drive.exists(pathname))) return ctx.response.notFound();
const { size } = await Drive.getStats(pathname);
ctx.response.type(extname(pathname));
ctx.response.header('content-length', size);
const stream = await Drive.getStream(pathname);
return this.sendStream(ctx, stream);
}
} }

View File

@ -17,8 +17,9 @@ import ColorHelper from 'App/Helpers/ColorHelper';
import Course from './Course'; import Course from './Course';
import LessonMaterial from './LessonMaterial'; import LessonMaterial from './LessonMaterial';
import LessonContent from './LessonContent'; import LessonContent from './LessonContent';
import Color from './Color';
import LessonProgress from './LessonProgress'; import LessonProgress from './LessonProgress';
import LessonVideo from './LessonVideo';
import Color from './Color';
export default class Lesson extends BaseModel { export default class Lesson extends BaseModel {
@column({ isPrimary: true }) @column({ isPrimary: true })
@ -57,6 +58,11 @@ export default class Lesson extends BaseModel {
}) })
public content: HasOne<typeof LessonContent>; public content: HasOne<typeof LessonContent>;
@hasOne(() => LessonVideo, {
foreignKey: 'lesson_id',
})
public video: HasOne<typeof LessonVideo>;
@hasMany(() => LessonMaterial, { foreignKey: 'lesson_id' }) @hasMany(() => LessonMaterial, { foreignKey: 'lesson_id' })
public materials: HasMany<typeof LessonMaterial>; public materials: HasMany<typeof LessonMaterial>;

38
app/Models/LessonVideo.ts Normal file
View File

@ -0,0 +1,38 @@
/* eslint-disable import/no-cycle */
import { DateTime } from 'luxon';
import { BaseModel, BelongsTo, belongsTo, column } from '@ioc:Adonis/Lucid/Orm';
import Lesson from './Lesson';
export default class LessonVideo extends BaseModel {
@column({ isPrimary: true })
public id: number;
@column({ serializeAs: null })
public lesson_id: string;
@column()
public name: string;
@column()
public size: number;
@column()
public clientName: string;
@column()
public ext: string;
@column()
public url: string;
@belongsTo(() => Lesson, {
foreignKey: 'lesson_id',
})
public lesson: BelongsTo<typeof Lesson>;
@column.dateTime({ autoCreate: true })
public createdAt: DateTime;
@column.dateTime({ autoCreate: true, autoUpdate: true })
public updatedAt: DateTime;
}

View File

@ -7,6 +7,7 @@ import { cuid } from '@ioc:Adonis/Core/Helpers';
import Course from 'App/Models/Course'; import Course from 'App/Models/Course';
import Lesson from 'App/Models/Lesson'; import Lesson from 'App/Models/Lesson';
import LessonMaterial from 'App/Models/LessonMaterial'; import LessonMaterial from 'App/Models/LessonMaterial';
import LessonVideo from 'App/Models/LessonVideo';
/** /**
* Validators * Validators
@ -19,11 +20,14 @@ export default class LessonRepository {
private LessonMaterial: typeof LessonMaterial; private LessonMaterial: typeof LessonMaterial;
private LessonVideo: typeof LessonVideo;
private Drive: typeof Drive; private Drive: typeof Drive;
constructor() { constructor() {
this.Lesson = Lesson; this.Lesson = Lesson;
this.LessonMaterial = LessonMaterial; this.LessonMaterial = LessonMaterial;
this.LessonVideo = LessonVideo;
this.Drive = Drive; this.Drive = Drive;
} }
@ -81,6 +85,11 @@ export default class LessonRepository {
return material; return material;
} }
public async getVideoFileByName(fileName: string): Promise<LessonVideo | null> {
const video = await this.LessonVideo.query().where('name', fileName).first();
return video;
}
/** /**
* Create lesson * Create lesson
* *
@ -95,18 +104,29 @@ export default class LessonRepository {
description: data.description, description: data.description,
duration: data.duration.toFormat('HH:mm:ss'), duration: data.duration.toFormat('HH:mm:ss'),
display_order: Number.parseInt(course.$extras.lessons_count, 10) + 1, display_order: Number.parseInt(course.$extras.lessons_count, 10) + 1,
course_id: course.id,
}); });
/**
* Attach lesson to course
*/
await lesson.related('course').associate(course);
/** /**
* Create content * Create content
*/ */
await lesson.related('content').create({ video_url: data.video_url }); await lesson.related('content').create({ video_url: data.video_url });
/**
* Move video file to disk and save to database
*/
const videoName = `${cuid()}.${data.video.extname}`;
await data.video.moveToDisk('videos', { name: videoName });
if (data.video.state === 'moved') {
await lesson.related('video').create({
name: videoName,
clientName: data.video.clientName,
ext: data.video.extname,
size: data.video.size,
url: `lessons/video/${videoName}`,
});
}
/** /**
* Create materials * Create materials
*/ */
@ -150,7 +170,7 @@ export default class LessonRepository {
/** /**
* Load data * Load data
*/ */
await lesson.load(loader => loader.load('content').load('materials')); await lesson.load(loader => loader.load('content').load('materials').load('video'));
return lesson; return lesson;
} }
@ -185,15 +205,21 @@ export default class LessonRepository {
* @returns Deleted lesson or null * @returns Deleted lesson or null
*/ */
public async delete(id: string | number): Promise<Lesson | null> { public async delete(id: string | number): Promise<Lesson | null> {
const lesson = await this.Lesson.query().where('id', id).preload('materials').preload('color').first(); const lesson = await this.Lesson.query()
.where('id', id)
.preload('video')
.preload('materials')
.preload('color')
.first();
if (lesson) { if (lesson) {
/** /**
* Delete files from disk * Delete files from disk
*/ */
await this.Drive.delete(`videos/${lesson.video.name}`);
await Promise.all( await Promise.all(
lesson.materials.map(async file => { lesson.materials.map(async material => {
await this.Drive.delete(`materials/${file.name}`); await this.Drive.delete(`materials/${material.name}`);
}) })
); );

View File

@ -8,6 +8,7 @@ import Database from '@ioc:Adonis/Lucid/Database';
import HttpStatusEnum from 'App/Datatypes/Enums/HttpStatusEnum'; import HttpStatusEnum from 'App/Datatypes/Enums/HttpStatusEnum';
import IResponse from 'App/Datatypes/Interfaces/IResponse'; import IResponse from 'App/Datatypes/Interfaces/IResponse';
import LessonMaterial from 'App/Models/LessonMaterial'; import LessonMaterial from 'App/Models/LessonMaterial';
import LessonVideo from 'App/Models/LessonVideo';
/** /**
* Repositories * Repositories
@ -151,6 +152,43 @@ export default class LessonService {
}; };
} }
public async fetchVideoFile(ctx: HttpContextContract, name: string): Promise<IResponse<LessonVideo>> {
const video = await this.lessonRepository.getVideoFileByName(name);
if (!video) {
return {
success: false,
status: HttpStatusEnum.NOT_FOUND,
data: {},
message: 'Video not found.',
error: {
code: 'E_NOT_FOUND',
},
};
}
await video.load('lesson');
if (await ctx.bouncer.denies('viewLessonContent', video.lesson)) {
return {
success: false,
status: HttpStatusEnum.FORBIDDEN,
message: 'You are not able to view this file.',
data: {},
error: {
code: 'E_FORBIDDEN',
},
};
}
return {
success: true,
status: HttpStatusEnum.OK,
message: 'Fetched lesson video.',
data: video,
};
}
/** /**
* Fetch progress of lesson by lesson id * Fetch progress of lesson by lesson id
* *
@ -222,6 +260,9 @@ export default class LessonService {
}; };
} }
/**
* Create new lesson
*/
const lesson = await this.lessonRepository.create(course, data); const lesson = await this.lessonRepository.create(course, data);
return { return {

View File

@ -10,6 +10,10 @@ export default class CreateLessonValidator {
description: schema.string(), description: schema.string(),
duration: schema.date({ format: 'HH:mm:ss' }), duration: schema.date({ format: 'HH:mm:ss' }),
video_url: schema.string({}, [rules.url()]), video_url: schema.string({}, [rules.url()]),
video: schema.file({
size: '5000mb',
extnames: ['mp4', 'mov', 'avi', 'wmv', 'webm', 'flv'],
}),
materials: schema.array.optional().members( materials: schema.array.optional().members(
schema.file({ schema.file({
size: '10mb', size: '10mb',

View File

@ -0,0 +1,31 @@
import { validator, ParsedTypedSchema } from '@ioc:Adonis/Core/Validator';
export default abstract class Validator {
/**
* Object for schemas.
* Each key in object will correspong to a type of validation.
* Each values will be a valid Adonis schema, made using schema.create
*/
protected abstract schemas(type: string): ParsedTypedSchema<any>;
/**
* Object of error message objects used to pass custom error messages.
* Each key will correspond to a valid validation type defined in schemas
*/
protected errorMessages = {};
/**
* Cache key, to be used if validator schema caching is needed.
* Refer https://preview.adonisjs.com/guides/validator/schema-caching
*/
protected cacheKey: string;
public async fire(data: { [key: string]: any }, type: string): Promise<any> {
return validator.validate({
schema: this.schemas(type),
data,
...(this.errorMessages[type] ? { messages: this.errorMessages[type] } : {}),
...(this.cacheKey ? { cacheKey: this.cacheKey } : {}),
});
}
}

View File

@ -0,0 +1,24 @@
import BaseSchema from '@ioc:Adonis/Lucid/Schema';
export default class LessonVideos extends BaseSchema {
protected tableName = 'lesson_videos';
public async up() {
this.schema.createTable(this.tableName, table => {
table.increments('id');
table.string('lesson_id', 21).unsigned().references('lessons.id').onDelete('CASCADE').notNullable();
table.string('name').notNullable();
table.integer('size').notNullable();
table.string('client_name').notNullable();
table.string('ext').notNullable();
table.string('url').notNullable();
table.timestamp('created_at', { useTz: true });
table.timestamp('updated_at', { useTz: true });
table.unique(['lesson_id']);
});
}
public async down() {
this.schema.dropTable(this.tableName);
}
}

View File

@ -15,6 +15,9 @@ Route.group(() => {
Route.get('/materials/:fileName', 'Api/v1/LessonsController.getMaterial') Route.get('/materials/:fileName', 'Api/v1/LessonsController.getMaterial')
.middleware('role:admin,teacher,student') .middleware('role:admin,teacher,student')
.as('lessons.get-material'); .as('lessons.get-material');
Route.get('/video/:fileName', 'Api/v1/LessonsController.getVideo')
.middleware('role:admin,teacher,student')
.as('lessons.get-video');
Route.get('/:id/progress', 'Api/v1/LessonsController.getVideoProgress') Route.get('/:id/progress', 'Api/v1/LessonsController.getVideoProgress')
.middleware('role:admin,teacher,student') .middleware('role:admin,teacher,student')
.as('lessons.get-video-progress'); .as('lessons.get-video-progress');