Support video quota on client
authorChocobozzz <me@florianbigard.com>
Mon, 8 Jan 2018 11:53:09 +0000 (12:53 +0100)
committerChocobozzz <me@florianbigard.com>
Mon, 8 Jan 2018 11:53:09 +0000 (12:53 +0100)
14 files changed:
client/src/app/+admin/users/user-edit/user-edit.component.html
client/src/app/account/account-settings/account-settings.component.html
client/src/app/account/account-settings/account-settings.component.scss
client/src/app/account/account-settings/account-settings.component.ts
client/src/app/core/auth/auth.service.ts
client/src/app/shared/users/user.model.ts
client/src/app/shared/users/user.service.ts
client/src/app/videos/+video-edit/video-add.component.html
client/src/app/videos/+video-edit/video-add.component.ts
client/src/app/videos/+video-edit/video-update.component.ts
server/controllers/api/users.ts
server/models/account/user.ts
server/tests/api/users/users.ts
server/tests/utils/users/users.ts

index 77aa613a170ed7191ab4f0d0ad9439b005d7b36f..a69ffee772523717f91fe686d6f18253f315e837 100644 (file)
@@ -64,7 +64,7 @@
 
     <div class="transcoding-information" *ngIf="isTranscodingInformationDisplayed()">
       Transcoding is enabled on server. The video quota only take in account <strong>original</strong> video. <br />
-      In maximum, this user could use ~ {{ computeQuotaWithTranscoding() | bytes }}.
+      In maximum, this user could use ~ {{ computeQuotaWithTranscoding() | bytes: 0 }}.
     </div>
   </div>
 
index 0d1637c4088509ed09862026d7b3d41f0fca6258..2f41b5ecf828c1673f10065b3278e12b9b532f99 100644 (file)
 </div>
 <div class="file-max-size">(extensions: {{ avatarExtensions }}, max size: {{ maxAvatarSize | bytes }})</div>
 
+<div class="user-quota">
+  <span class="user-quota-label">Video quota:</span> {{ userVideoQuotaUsed | bytes: 0 }} / {{ user.videoQuota | bytes: 0 }}
+</div>
+
 <div class="account-title">Account settings</div>
 <my-account-change-password></my-account-change-password>
 
-<div class="account-title">Videos</div>
+<div class="account-title">Video settings</div>
 <my-account-details [user]="user"></my-account-details>
index fbd1cb9f03bfdb4c7fd2d1c826ef68a636dc894f..aaf9d79f0b3a53224a7c560f84bde361f081bc69 100644 (file)
   top: -10px;
 }
 
+.user-quota {
+  font-size: 15px;
+  margin-top: 20px;
+
+  .user-quota-label {
+    font-weight: $font-semibold;
+  }
+}
+
 .account-title {
   text-transform: uppercase;
   color: $orange-color;
index d5f5ff30f6df24184c45f99223026e882b2db0e5..a375072a0f54f502855cdbeabf40c9b2779ed879 100644 (file)
@@ -14,6 +14,7 @@ export class AccountSettingsComponent implements OnInit {
   @ViewChild('avatarfileInput') avatarfileInput
 
   user: User = null
+  userVideoQuotaUsed = 0
 
   constructor (
     private userService: UserService,
@@ -24,6 +25,9 @@ export class AccountSettingsComponent implements OnInit {
 
   ngOnInit () {
     this.user = this.authService.getUser()
+
+    this.userService.getMyVideoQuotaUsed()
+      .subscribe(data => this.userVideoQuotaUsed = data.videoQuotaUsed)
   }
 
   getAvatarUrl () {
index 8a2ba77d6b16d30bb40f6bd47f530df4cccab392..8700e8c742b541026655decfd430630472e01ec2 100644 (file)
@@ -14,7 +14,6 @@ import { User } from '../../../../../shared/models/users'
 import { UserLogin } from '../../../../../shared/models/users/user-login.model'
 import { environment } from '../../../environments/environment'
 import { RestExtractor } from '../../shared/rest'
-import { UserConstructorHash } from '../../shared/users/user.model'
 import { AuthStatus } from './auth-status.model'
 import { AuthUser } from './auth-user.model'
 
@@ -178,12 +177,7 @@ export class AuthService {
     this.mergeUserInformation(obj)
       .subscribe(
         res => {
-          this.user.displayNSFW = res.displayNSFW
-          this.user.autoPlayVideo = res.autoPlayVideo
-          this.user.role = res.role
-          this.user.videoChannels = res.videoChannels
-          this.user.account = res.account
-
+          this.user.patch(res)
           this.user.save()
 
           this.userInformationLoaded.next(true)
@@ -200,24 +194,13 @@ export class AuthService {
   }
 
   private handleLogin (obj: UserLoginWithUserInformation) {
-    const hashUser: UserConstructorHash = {
-      id: obj.id,
-      username: obj.username,
-      role: obj.role,
-      email: obj.email,
-      displayNSFW: obj.displayNSFW,
-      autoPlayVideo: obj.autoPlayVideo,
-      videoQuota: obj.videoQuota,
-      videoChannels: obj.videoChannels,
-      account: obj.account
-    }
     const hashTokens = {
       accessToken: obj.access_token,
       tokenType: obj.token_type,
       refreshToken: obj.refresh_token
     }
 
-    this.user = new AuthUser(hashUser, hashTokens)
+    this.user = new AuthUser(obj, hashTokens)
     this.user.save()
 
     this.setStatus(AuthStatus.LoggedIn)
index 83aae44639ce73fe1e2fb623a1bf70100b49e443..4a94b032d6f6670bbe2a613540c871ce0778ef19 100644 (file)
@@ -60,4 +60,10 @@ export class User implements UserServerModel {
   getAvatarUrl () {
     return Account.GET_ACCOUNT_AVATAR_URL(this.account)
   }
+
+  patch (obj: UserServerModel) {
+    for (const key of Object.keys(obj)) {
+      this[key] = obj[key]
+    }
+  }
 }
index 58ddaa5ee8d7b184155150692e6c41021152bec1..742fb0728243c717a925116af11c6bdf5c9d8c4a 100644 (file)
@@ -48,11 +48,10 @@ export class UserService {
                         .catch(res => this.restExtractor.handleError(res))
   }
 
-  getMyInformation () {
-    const url = UserService.BASE_USERS_URL + 'me'
+  getMyVideoQuotaUsed () {
+    const url = UserService.BASE_USERS_URL + '/me/video-quota-used'
 
     return this.authHttp.get(url)
-      .map((userHash: any) => new User(userHash))
       .catch(res => this.restExtractor.handleError(res))
   }
 }
index 193cc55eed02b8132daf49f37905faf76adc299a..2040ff9d428764027b454d9165b3561b6d387be4 100644 (file)
@@ -3,8 +3,6 @@
     Upload your video
   </div>
 
-  <div *ngIf="error" class="alert alert-danger">{{ error }}</div>
-
   <div *ngIf="!isUploadingVideo" class="upload-video-container">
     <div class="upload-video">
       <div class="icon icon-upload"></div>
index 066f945fcb72d30be5c00f010c8fba6a27a79f4f..a86d9d3c22cbabf4231fdb9485a34d771695ad1e 100644 (file)
@@ -2,7 +2,9 @@ import { HttpEventType, HttpResponse } from '@angular/common/http'
 import { Component, OnInit, ViewChild } from '@angular/core'
 import { FormBuilder, FormGroup } from '@angular/forms'
 import { Router } from '@angular/router'
+import { UserService } from '@app/shared'
 import { NotificationsService } from 'angular2-notifications'
+import { BytesPipe } from 'ngx-pipes'
 import { VideoPrivacy } from '../../../../../shared/models/videos'
 import { AuthService, ServerService } from '../../core'
 import { FormReactive } from '../../shared'
@@ -31,12 +33,12 @@ export class VideoAddComponent extends FormReactive implements OnInit {
     uuid: ''
   }
 
-  error: string = null
   form: FormGroup
   formErrors: { [ id: string ]: string } = {}
   validationMessages: ValidatorMessage = {}
 
   userVideoChannels = []
+  userVideoQuotaUsed = 0
   videoPrivacies = []
   firstStepPrivacyId = 0
   firstStepChannelId = 0
@@ -46,6 +48,7 @@ export class VideoAddComponent extends FormReactive implements OnInit {
     private router: Router,
     private notificationsService: NotificationsService,
     private authService: AuthService,
+    private userService: UserService,
     private serverService: ServerService,
     private videoService: VideoService
   ) {
@@ -67,6 +70,9 @@ export class VideoAddComponent extends FormReactive implements OnInit {
     populateAsyncUserVideoChannels(this.authService, this.userVideoChannels)
       .then(() => this.firstStepChannelId = this.userVideoChannels[0].id)
 
+    this.userService.getMyVideoQuotaUsed()
+      .subscribe(data => this.userVideoQuotaUsed = data.videoQuotaUsed)
+
     this.serverService.videoPrivaciesLoaded
       .subscribe(
         () => {
@@ -89,6 +95,18 @@ export class VideoAddComponent extends FormReactive implements OnInit {
 
   uploadFirstStep () {
     const videofile = this.videofileInput.nativeElement.files[0]
+    const videoQuota = this.authService.getUser().videoQuota
+    if ((this.userVideoQuotaUsed + videofile.size) > videoQuota) {
+      const bytePipes = new BytesPipe()
+
+      const msg = 'Your video quota is exceeded with this video ' +
+        `(video size: ${bytePipes.transform(videofile.size, 0)}, ` +
+        `used: ${bytePipes.transform(this.userVideoQuotaUsed, 0)}, ` +
+        `quota: ${bytePipes.transform(videoQuota, 0)})`
+      this.notificationsService.error('Error', msg)
+      return
+    }
+
     const name = videofile.name.replace(/\.[^/.]+$/, '')
     const privacy = this.firstStepPrivacyId.toString()
     const nsfw = false
@@ -127,8 +145,9 @@ export class VideoAddComponent extends FormReactive implements OnInit {
 
       err => {
         // Reset progress
+        this.isUploadingVideo = false
         this.videoUploadPercents = 0
-        this.error = err.message
+        this.notificationsService.error('Error', err.message)
       }
     )
   }
@@ -152,7 +171,7 @@ export class VideoAddComponent extends FormReactive implements OnInit {
         },
 
         err => {
-          this.error = 'Cannot update the video.'
+          this.notificationsService.error('Error', err.message)
           console.error(err)
         }
       )
index 941ef24782edb3cf06f5cdb267f3366600b2a0e1..7f41b56d8ab8771718e5b372025774bdb8825f0c 100644 (file)
@@ -21,7 +21,6 @@ import { VideoService } from '../../shared/video/video.service'
 export class VideoUpdateComponent extends FormReactive implements OnInit {
   video: VideoEdit
 
-  error: string = null
   form: FormGroup
   formErrors: { [ id: string ]: string } = {}
   validationMessages: ValidatorMessage = {}
@@ -82,7 +81,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
 
         err => {
           console.error(err)
-          this.error = 'Cannot fetch video.'
+          this.notificationsService.error('Error', err.message)
         }
       )
   }
@@ -108,7 +107,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
                        },
 
                        err => {
-                         this.error = 'Cannot update the video.'
+                         this.notificationsService.error('Error', err.message)
                          console.error(err)
                        }
                       )
index 2d77a52494e0e1cec2813db66da6b164b232a2ae..5374c4b6afdf1e34e8035d1cdd6d69f26f64a18c 100644 (file)
@@ -30,6 +30,11 @@ usersRouter.get('/me',
   asyncMiddleware(getUserInformation)
 )
 
+usersRouter.get('/me/video-quota-used',
+  authenticate,
+  asyncMiddleware(getUserVideoQuotaUsed)
+)
+
 usersRouter.get('/me/videos',
   authenticate,
   paginationValidator,
@@ -183,8 +188,18 @@ async function getUserInformation (req: express.Request, res: express.Response,
   return res.json(user.toFormattedJSON())
 }
 
+async function getUserVideoQuotaUsed (req: express.Request, res: express.Response, next: express.NextFunction) {
+  // We did not load channels in res.locals.user
+  const user = await UserModel.loadByUsernameAndPopulateChannels(res.locals.oauth.token.user.username)
+  const videoQuotaUsed = await UserModel.getOriginalVideoFileTotalFromUser(user)
+
+  return res.json({
+    videoQuotaUsed
+  })
+}
+
 function getUser (req: express.Request, res: express.Response, next: express.NextFunction) {
-  return res.json(res.locals.user.toFormattedJSON())
+  return res.json((res.locals.user as UserModel).toFormattedJSON())
 }
 
 async function getUserVideoRating (req: express.Request, res: express.Response, next: express.NextFunction) {
index 4226bcb35a579b09d81ecf06f6fc13129a2bb962..e37fd4d3be71ddc29195547187bcf0f83b9071c9 100644 (file)
@@ -181,7 +181,7 @@ export class UserModel extends Model<UserModel> {
     return UserModel.findOne(query)
   }
 
-  private static getOriginalVideoFileTotalFromUser (user: UserModel) {
+  static getOriginalVideoFileTotalFromUser (user: UserModel) {
     // Don't use sequelize because we need to use a sub query
     const query = 'SELECT SUM("size") AS "total" FROM ' +
       '(SELECT MAX("videoFile"."size") AS "size" FROM "videoFile" ' +
index f7e5972d3ecb7d6386560f262530f187855043c8..b788637e7d63a7c24cca430b44118153d24fbbc7 100644 (file)
@@ -4,7 +4,8 @@ import * as chai from 'chai'
 import 'mocha'
 import { UserRole } from '../../../../shared/index'
 import {
-  createUser, flushTests, getBlacklistedVideosList, getMyUserInformation, getMyUserVideoRating, getUserInformation, getUsersList,
+  createUser, flushTests, getBlacklistedVideosList, getMyUserInformation, getMyUserVideoQuotaUsed, getMyUserVideoRating, getUserInformation,
+  getUsersList,
   getUsersListPaginationAndSort, getVideosList, killallServers, login, makePutBodyRequest, rateVideo, registerUser, removeUser, removeVideo,
   runServer, ServerInfo, serverLogin, testVideoImage, updateMyAvatar, updateMyUser, updateUser, uploadVideo
 } from '../../utils/index'
@@ -179,11 +180,19 @@ describe('Test users', function () {
     this.timeout(5000)
 
     const videoAttributes = {
-      name: 'super user video'
+      name: 'super user video',
+      fixture: 'video_short.webm'
     }
     await uploadVideo(server.url, accessTokenUser, videoAttributes)
   })
 
+  it('Should have video quota updated', async function () {
+    const res = await getMyUserVideoQuotaUsed(server.url, accessTokenUser)
+    const data = res.body
+
+    expect(data.videoQuotaUsed).to.equal(218910)
+  })
+
   it('Should be able to list my videos', async function () {
     const res = await getMyVideos(server.url, accessTokenUser, 0, 5)
     expect(res.body.total).to.equal(1)
index 90b1ca0a668063336152ca4da20d8260bff66218..12945a805bb3404900eaadbd55991ccbd4f9085d 100644 (file)
@@ -56,6 +56,17 @@ function getMyUserInformation (url: string, accessToken: string, specialStatus =
           .expect('Content-Type', /json/)
 }
 
+function getMyUserVideoQuotaUsed (url: string, accessToken: string, specialStatus = 200) {
+  const path = '/api/v1/users/me/video-quota-used'
+
+  return request(url)
+          .get(path)
+          .set('Accept', 'application/json')
+          .set('Authorization', 'Bearer ' + accessToken)
+          .expect(specialStatus)
+          .expect('Content-Type', /json/)
+}
+
 function getUserInformation (url: string, accessToken: string, userId: number) {
   const path = '/api/v1/users/' + userId
 
@@ -192,6 +203,7 @@ export {
   registerUser,
   getMyUserInformation,
   getMyUserVideoRating,
+  getMyUserVideoQuotaUsed,
   getUsersList,
   getUsersListPaginationAndSort,
   removeUser,