diff options
147 files changed, 2249 insertions, 546 deletions
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 4b352922e..a25368cdb 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md | |||
@@ -122,6 +122,18 @@ and the web server is automatically restarted. | |||
122 | $ npm run dev | 122 | $ npm run dev |
123 | ``` | 123 | ``` |
124 | 124 | ||
125 | Depending on your OS, you may face the following error : | ||
126 | ``` | ||
127 | $ [nodemon] Internal watch failed: ENOSPC: no space left on device, watch '/PeerTube/dist' | ||
128 | ``` | ||
129 | |||
130 | This is due to your system's limit on the number of files you can monitor for live-checking changes. For example, Ubuntu uses inotify and this limit is set to 8192. Then you need to change this limit : | ||
131 | ``` | ||
132 | echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p | ||
133 | ``` | ||
134 | |||
135 | See more information here : https://github.com/guard/listen/wiki/Increasing-the-amount-of-inotify-watchers | ||
136 | |||
125 | ### Federation | 137 | ### Federation |
126 | 138 | ||
127 | Create a PostgreSQL user **with the same name as your username** in order to avoid using the *postgres* user. | 139 | Create a PostgreSQL user **with the same name as your username** in order to avoid using the *postgres* user. |
diff --git a/.gitignore b/.gitignore index 22478c444..a31da70a9 100644 --- a/.gitignore +++ b/.gitignore | |||
@@ -9,6 +9,7 @@ | |||
9 | /test4/ | 9 | /test4/ |
10 | /test5/ | 10 | /test5/ |
11 | /test6/ | 11 | /test6/ |
12 | /server/tests/fixtures/video_high_bitrate_1080p.mp4 | ||
12 | 13 | ||
13 | # Production | 14 | # Production |
14 | /storage/ | 15 | /storage/ |
diff --git a/.travis.yml b/.travis.yml index 9fd54447c..7670cb7c0 100644 --- a/.travis.yml +++ b/.travis.yml | |||
@@ -14,7 +14,10 @@ addons: | |||
14 | - g++-4.9 | 14 | - g++-4.9 |
15 | postgresql: "9.4" | 15 | postgresql: "9.4" |
16 | 16 | ||
17 | cache: yarn | 17 | cache: |
18 | directories: | ||
19 | - $HOME/.cache/yarn | ||
20 | - $HOME/fixtures | ||
18 | 21 | ||
19 | sudo: false | 22 | sudo: false |
20 | 23 | ||
@@ -18,6 +18,7 @@ | |||
18 | - [Are you going to use the Steem blockchain?](#are-you-going-to-use-the-steem-blockchain) | 18 | - [Are you going to use the Steem blockchain?](#are-you-going-to-use-the-steem-blockchain) |
19 | - [Are you going to support advertisements?](#are-you-going-to-support-advertisements) | 19 | - [Are you going to support advertisements?](#are-you-going-to-support-advertisements) |
20 | - [What is "creation dynamic" and why not modify it?](#what-is-creation-dynamic-and-why-not-modify-it) | 20 | - [What is "creation dynamic" and why not modify it?](#what-is-creation-dynamic-and-why-not-modify-it) |
21 | - [I have found a security vulnerability in PeerTube. Where and how should I report it?](#i-have-found-a-security-vulnerability-in-peertube-where-and-how-should-i-report-it) | ||
21 | 22 | ||
22 | <!-- END doctoc generated TOC please keep comment here to allow auto update --> | 23 | <!-- END doctoc generated TOC please keep comment here to allow auto update --> |
23 | 24 | ||
@@ -32,6 +33,7 @@ is named "Framatube". | |||
32 | 33 | ||
33 | Yes, the origin server always seeds videos uploaded on it thanks to | 34 | Yes, the origin server always seeds videos uploaded on it thanks to |
34 | [Webseed](http://www.bittorrent.org/beps/bep_0019.html). | 35 | [Webseed](http://www.bittorrent.org/beps/bep_0019.html). |
36 | It can also be helped by other servers using [redundancy](/support/doc/redundancy.md). | ||
35 | 37 | ||
36 | 38 | ||
37 | ## What is WebSeed? | 39 | ## What is WebSeed? |
@@ -71,7 +73,7 @@ Not really. For instance, the demonstration server [https://peertube.cpy.re](htt | |||
71 | * **RAM** -> nginx ~ 6MB, peertube ~ 120MB, postgres ~ 10MB, redis ~ 5MB | 73 | * **RAM** -> nginx ~ 6MB, peertube ~ 120MB, postgres ~ 10MB, redis ~ 5MB |
72 | 74 | ||
73 | So you would need: | 75 | So you would need: |
74 | * **CPU** 1 core if you don't enable transcoding, 2 at least if you enable it | 76 | * **CPU** 1 core if you don't enable transcoding, 2 at least if you enable it (works with 1 but this is really slow) |
75 | * **RAM** 1GB | 77 | * **RAM** 1GB |
76 | * **Storage** Completely depends on how many videos your users will upload | 78 | * **Storage** Completely depends on how many videos your users will upload |
77 | 79 | ||
@@ -80,7 +82,7 @@ So you would need: | |||
80 | 82 | ||
81 | Yes you can, but you won't be able to send data to users that watch the video in their web browser. | 83 | Yes you can, but you won't be able to send data to users that watch the video in their web browser. |
82 | The reason is they connects to peers through WebRTC whereas your BitTorrent client uses classic TCP/UDP. | 84 | The reason is they connects to peers through WebRTC whereas your BitTorrent client uses classic TCP/UDP. |
83 | We hope to see compatibility with WebRTC in popular BitTorrent client in the future. See this issue for more information: https://github.com/webtorrent/webtorrent/issues/369 | 85 | To check if your BitTorrent client supports WebTorrent you can see this issue: https://github.com/webtorrent/webtorrent/issues/369 |
84 | 86 | ||
85 | 87 | ||
86 | ## Why host on GitHub and Framagit? | 88 | ## Why host on GitHub and Framagit? |
@@ -119,3 +121,7 @@ If you still want to use a functionality potentially altering that state of thin | |||
119 | 121 | ||
120 | With that being said, know that we are not against these features *per se*. | 122 | With that being said, know that we are not against these features *per se*. |
121 | We are always open to discussion about potential PRs bringing in features, even of that kind. But we certainly won't dedicate our limited resources to develop them ourselves when there is so much to be done elsewhere. | 123 | We are always open to discussion about potential PRs bringing in features, even of that kind. But we certainly won't dedicate our limited resources to develop them ourselves when there is so much to be done elsewhere. |
124 | |||
125 | ## I have found a security vulnerability in PeerTube. Where and how should I report it? | ||
126 | |||
127 | We have a policy for contributions related to security. Please refer to [SECURITY.md](./SECURITY.md) | ||
diff --git a/SECURITY.md b/SECURITY.md index 5c668a2a3..b80f8ad00 100644 --- a/SECURITY.md +++ b/SECURITY.md | |||
@@ -1,8 +1,6 @@ | |||
1 | **Introduction** | ||
2 | |||
3 | Security is core to our values, and we value the input of hackers acting in good faith to help us maintain a high standard for the security and privacy for our users. This includes encouraging responsible vulnerability research and disclosure. This policy sets out our definition of good faith in the context of finding and reporting vulnerabilities, as well as what you can expect from us in return. | 1 | Security is core to our values, and we value the input of hackers acting in good faith to help us maintain a high standard for the security and privacy for our users. This includes encouraging responsible vulnerability research and disclosure. This policy sets out our definition of good faith in the context of finding and reporting vulnerabilities, as well as what you can expect from us in return. |
4 | 2 | ||
5 | **Expectations** | 3 | ## Expectations |
6 | 4 | ||
7 | When working with us according to this policy, you can expect us to: | 5 | When working with us according to this policy, you can expect us to: |
8 | - Extend Safe Harbor (see below) for your vulnerability research that is related to this policy; | 6 | - Extend Safe Harbor (see below) for your vulnerability research that is related to this policy; |
@@ -10,7 +8,7 @@ When working with us according to this policy, you can expect us to: | |||
10 | - Work to remediate discovered vulnerabilities in a timely manner; and | 8 | - Work to remediate discovered vulnerabilities in a timely manner; and |
11 | - Recognize your contribution to improving our security if you are the first to report a unique vulnerability, and your report triggers a code or configuration change. | 9 | - Recognize your contribution to improving our security if you are the first to report a unique vulnerability, and your report triggers a code or configuration change. |
12 | 10 | ||
13 | **Safe Harbor** | 11 | ## Safe Harbor |
14 | 12 | ||
15 | When conducting vulnerability research according to this policy, we consider this research to be: | 13 | When conducting vulnerability research according to this policy, we consider this research to be: |
16 | - Authorized in accordance with the law, and we will not initiate or support legal action against you for accidental, good faith violations of this policy; | 14 | - Authorized in accordance with the law, and we will not initiate or support legal action against you for accidental, good faith violations of this policy; |
@@ -22,7 +20,7 @@ You are expected, as always, to comply with all applicable laws. | |||
22 | 20 | ||
23 | If at any time you have concerns or are uncertain whether your security research is consistent with this policy, please submit a report through one of our Official Channels before going any further. | 21 | If at any time you have concerns or are uncertain whether your security research is consistent with this policy, please submit a report through one of our Official Channels before going any further. |
24 | 22 | ||
25 | **Ground Rules** | 23 | ## Ground Rules |
26 | 24 | ||
27 | To encourage vulnerability research and to avoid any confusion between good-faith hacking and malicious attack, we ask that you: | 25 | To encourage vulnerability research and to avoid any confusion between good-faith hacking and malicious attack, we ask that you: |
28 | - Play by the rules. This includes following this policy, as well as any other relevant agreements. If there is any inconsistency between this policy and any other relevant terms, the terms of this policy will prevail. | 26 | - Play by the rules. This includes following this policy, as well as any other relevant agreements. If there is any inconsistency between this policy and any other relevant terms, the terms of this policy will prevail. |
@@ -35,10 +33,15 @@ To encourage vulnerability research and to avoid any confusion between good-fait | |||
35 | - You should only interact with test accounts you own or with explicit permission from the account holder. | 33 | - You should only interact with test accounts you own or with explicit permission from the account holder. |
36 | - Do not engage in extortion. | 34 | - Do not engage in extortion. |
37 | 35 | ||
38 | **Official Channels** | 36 | ## Disclosure Terms |
37 | |||
38 | The vulnerability is kept private until a majority of instances known on instances.joinpeertube.org have updated to a safe version of PeerTube or applied a hotfix. The PeerTube development team coordinates efforts to update once the patch is issued. | ||
39 | |||
40 | ## Official Channels | ||
39 | 41 | ||
40 | To help us receive vulnerability submissions we use the following official reporting channels: | 42 | To help us receive vulnerability submissions we use the following official reporting channels: |
41 | - chocobozzz@cpy.re (GPG: [583A612D890159BE](https://keybase.io/chocobozzz/pgp_keys.asc?fingerprint=c44aad638367912ca93edd57583a612d890159be)) | 43 | - chocobozzz@cpy.re (GPG: [583A612D890159BE](https://keybase.io/chocobozzz/pgp_keys.asc?fingerprint=c44aad638367912ca93edd57583a612d890159be)) |
44 | - sendmemail@rigelk.eu (GPG: [EA12971B0E438F36](https://api.github.com/users/rigelk/gpg_keys)) | ||
42 | 45 | ||
43 | If you think you have found a vulnerability, please include the following details with your report and be as descriptive as possible: | 46 | If you think you have found a vulnerability, please include the following details with your report and be as descriptive as possible: |
44 | - The location and nature of the vulnerability, | 47 | - The location and nature of the vulnerability, |
diff --git a/client/src/app/+accounts/accounts.component.html b/client/src/app/+accounts/accounts.component.html index 69f648269..036e794d2 100644 --- a/client/src/app/+accounts/accounts.component.html +++ b/client/src/app/+accounts/accounts.component.html | |||
@@ -8,6 +8,11 @@ | |||
8 | <div class="actor-names"> | 8 | <div class="actor-names"> |
9 | <div class="actor-display-name">{{ account.displayName }}</div> | 9 | <div class="actor-display-name">{{ account.displayName }}</div> |
10 | <div class="actor-name">{{ account.nameWithHost }}</div> | 10 | <div class="actor-name">{{ account.nameWithHost }}</div> |
11 | |||
12 | <span *ngIf="user?.blocked" [ngbTooltip]="user.blockedReason" class="badge badge-danger" i18n>Banned</span> | ||
13 | |||
14 | <my-user-moderation-dropdown buttonSize="small" [user]="user" (userChanged)="onUserChanged()" (userDeleted)="onUserDeleted()"> | ||
15 | </my-user-moderation-dropdown> | ||
11 | </div> | 16 | </div> |
12 | <div i18n class="actor-followers">{{ account.followersCount }} subscribers</div> | 17 | <div i18n class="actor-followers">{{ account.followersCount }} subscribers</div> |
13 | </div> | 18 | </div> |
diff --git a/client/src/app/+accounts/accounts.component.scss b/client/src/app/+accounts/accounts.component.scss index 909b65bc7..3cedda889 100644 --- a/client/src/app/+accounts/accounts.component.scss +++ b/client/src/app/+accounts/accounts.component.scss | |||
@@ -3,4 +3,16 @@ | |||
3 | 3 | ||
4 | .sub-menu { | 4 | .sub-menu { |
5 | @include sub-menu-with-actor; | 5 | @include sub-menu-with-actor; |
6 | } | ||
7 | |||
8 | my-user-moderation-dropdown, | ||
9 | .badge { | ||
10 | margin-left: 10px; | ||
11 | |||
12 | position: relative; | ||
13 | top: 3px; | ||
14 | } | ||
15 | |||
16 | .badge { | ||
17 | font-size: 13px; | ||
6 | } \ No newline at end of file | 18 | } \ No newline at end of file |
diff --git a/client/src/app/+accounts/accounts.component.ts b/client/src/app/+accounts/accounts.component.ts index af0451e91..e19927d6b 100644 --- a/client/src/app/+accounts/accounts.component.ts +++ b/client/src/app/+accounts/accounts.component.ts | |||
@@ -1,10 +1,14 @@ | |||
1 | import { Component, OnInit, OnDestroy } from '@angular/core' | 1 | import { Component, OnDestroy, OnInit } from '@angular/core' |
2 | import { ActivatedRoute } from '@angular/router' | 2 | import { ActivatedRoute } from '@angular/router' |
3 | import { AccountService } from '@app/shared/account/account.service' | 3 | import { AccountService } from '@app/shared/account/account.service' |
4 | import { Account } from '@app/shared/account/account.model' | 4 | import { Account } from '@app/shared/account/account.model' |
5 | import { RestExtractor } from '@app/shared' | 5 | import { RestExtractor, UserService } from '@app/shared' |
6 | import { catchError, switchMap, distinctUntilChanged, map } from 'rxjs/operators' | 6 | import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators' |
7 | import { Subscription } from 'rxjs' | 7 | import { Subscription } from 'rxjs' |
8 | import { NotificationsService } from 'angular2-notifications' | ||
9 | import { User, UserRight } from '../../../../shared' | ||
10 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
11 | import { AuthService, RedirectService } from '@app/core' | ||
8 | 12 | ||
9 | @Component({ | 13 | @Component({ |
10 | templateUrl: './accounts.component.html', | 14 | templateUrl: './accounts.component.html', |
@@ -12,13 +16,19 @@ import { Subscription } from 'rxjs' | |||
12 | }) | 16 | }) |
13 | export class AccountsComponent implements OnInit, OnDestroy { | 17 | export class AccountsComponent implements OnInit, OnDestroy { |
14 | account: Account | 18 | account: Account |
19 | user: User | ||
15 | 20 | ||
16 | private routeSub: Subscription | 21 | private routeSub: Subscription |
17 | 22 | ||
18 | constructor ( | 23 | constructor ( |
19 | private route: ActivatedRoute, | 24 | private route: ActivatedRoute, |
25 | private userService: UserService, | ||
20 | private accountService: AccountService, | 26 | private accountService: AccountService, |
21 | private restExtractor: RestExtractor | 27 | private notificationsService: NotificationsService, |
28 | private restExtractor: RestExtractor, | ||
29 | private redirectService: RedirectService, | ||
30 | private authService: AuthService, | ||
31 | private i18n: I18n | ||
22 | ) {} | 32 | ) {} |
23 | 33 | ||
24 | ngOnInit () { | 34 | ngOnInit () { |
@@ -27,12 +37,40 @@ export class AccountsComponent implements OnInit, OnDestroy { | |||
27 | map(params => params[ 'accountId' ]), | 37 | map(params => params[ 'accountId' ]), |
28 | distinctUntilChanged(), | 38 | distinctUntilChanged(), |
29 | switchMap(accountId => this.accountService.getAccount(accountId)), | 39 | switchMap(accountId => this.accountService.getAccount(accountId)), |
40 | tap(account => this.getUserIfNeeded(account)), | ||
30 | catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ])) | 41 | catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ])) |
31 | ) | 42 | ) |
32 | .subscribe(account => this.account = account) | 43 | .subscribe( |
44 | account => this.account = account, | ||
45 | |||
46 | err => this.notificationsService.error(this.i18n('Error'), err.message) | ||
47 | ) | ||
33 | } | 48 | } |
34 | 49 | ||
35 | ngOnDestroy () { | 50 | ngOnDestroy () { |
36 | if (this.routeSub) this.routeSub.unsubscribe() | 51 | if (this.routeSub) this.routeSub.unsubscribe() |
37 | } | 52 | } |
53 | |||
54 | onUserChanged () { | ||
55 | this.getUserIfNeeded(this.account) | ||
56 | } | ||
57 | |||
58 | onUserDeleted () { | ||
59 | this.redirectService.redirectToHomepage() | ||
60 | } | ||
61 | |||
62 | private getUserIfNeeded (account: Account) { | ||
63 | if (!account.userId) return | ||
64 | if (!this.authService.isLoggedIn()) return | ||
65 | |||
66 | const user = this.authService.getUser() | ||
67 | if (user.hasRight(UserRight.MANAGE_USERS)) { | ||
68 | this.userService.getUser(account.userId) | ||
69 | .subscribe( | ||
70 | user => this.user = user, | ||
71 | |||
72 | err => this.notificationsService.error(this.i18n('Error'), err.message) | ||
73 | ) | ||
74 | } | ||
75 | } | ||
38 | } | 76 | } |
diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts index 5784609ef..8c6db98d9 100644 --- a/client/src/app/+admin/admin.module.ts +++ b/client/src/app/+admin/admin.module.ts | |||
@@ -10,9 +10,8 @@ import { FollowingListComponent } from './follows/following-list/following-list. | |||
10 | import { JobsComponent } from './jobs/job.component' | 10 | import { JobsComponent } from './jobs/job.component' |
11 | import { JobsListComponent } from './jobs/jobs-list/jobs-list.component' | 11 | import { JobsListComponent } from './jobs/jobs-list/jobs-list.component' |
12 | import { JobService } from './jobs/shared/job.service' | 12 | import { JobService } from './jobs/shared/job.service' |
13 | import { UserCreateComponent, UserListComponent, UsersComponent, UserService, UserUpdateComponent } from './users' | 13 | import { UserCreateComponent, UserListComponent, UsersComponent, UserUpdateComponent } from './users' |
14 | import { ModerationCommentModalComponent, VideoAbuseListComponent, VideoBlacklistListComponent } from './moderation' | 14 | import { ModerationCommentModalComponent, VideoAbuseListComponent, VideoBlacklistListComponent } from './moderation' |
15 | import { UserBanModalComponent } from '@app/+admin/users/user-list/user-ban-modal.component' | ||
16 | import { ModerationComponent } from '@app/+admin/moderation/moderation.component' | 15 | import { ModerationComponent } from '@app/+admin/moderation/moderation.component' |
17 | import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component' | 16 | import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component' |
18 | import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service' | 17 | import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service' |
@@ -37,7 +36,6 @@ import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service | |||
37 | UserCreateComponent, | 36 | UserCreateComponent, |
38 | UserUpdateComponent, | 37 | UserUpdateComponent, |
39 | UserListComponent, | 38 | UserListComponent, |
40 | UserBanModalComponent, | ||
41 | 39 | ||
42 | ModerationComponent, | 40 | ModerationComponent, |
43 | VideoBlacklistListComponent, | 41 | VideoBlacklistListComponent, |
@@ -58,7 +56,6 @@ import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service | |||
58 | providers: [ | 56 | providers: [ |
59 | FollowService, | 57 | FollowService, |
60 | RedundancyService, | 58 | RedundancyService, |
61 | UserService, | ||
62 | JobService, | 59 | JobService, |
63 | ConfigService | 60 | ConfigService |
64 | ] | 61 | ] |
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts index 4983b0425..25b303f44 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts | |||
@@ -1,6 +1,5 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | 1 | import { Component, OnInit } from '@angular/core' |
2 | import { ConfigService } from '@app/+admin/config/shared/config.service' | 2 | import { ConfigService } from '@app/+admin/config/shared/config.service' |
3 | import { ConfirmService } from '@app/core' | ||
4 | import { ServerService } from '@app/core/server/server.service' | 3 | import { ServerService } from '@app/core/server/server.service' |
5 | import { CustomConfigValidatorsService, FormReactive, UserValidatorsService } from '@app/shared' | 4 | import { CustomConfigValidatorsService, FormReactive, UserValidatorsService } from '@app/shared' |
6 | import { NotificationsService } from 'angular2-notifications' | 5 | import { NotificationsService } from 'angular2-notifications' |
@@ -29,7 +28,6 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { | |||
29 | private notificationsService: NotificationsService, | 28 | private notificationsService: NotificationsService, |
30 | private configService: ConfigService, | 29 | private configService: ConfigService, |
31 | private serverService: ServerService, | 30 | private serverService: ServerService, |
32 | private confirmService: ConfirmService, | ||
33 | private i18n: I18n | 31 | private i18n: I18n |
34 | ) { | 32 | ) { |
35 | super() | 33 | super() |
@@ -124,28 +122,6 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { | |||
124 | } | 122 | } |
125 | 123 | ||
126 | async formValidated () { | 124 | async formValidated () { |
127 | const newCustomizationJavascript = this.form.value['customizationJavascript'] | ||
128 | const newCustomizationCSS = this.form.value['customizationCSS'] | ||
129 | |||
130 | const customizations = [] | ||
131 | if (newCustomizationJavascript && newCustomizationJavascript !== this.oldCustomJavascript) customizations.push('JavaScript') | ||
132 | if (newCustomizationCSS && newCustomizationCSS !== this.oldCustomCSS) customizations.push('CSS') | ||
133 | |||
134 | if (customizations.length !== 0) { | ||
135 | const customizationsText = customizations.join('/') | ||
136 | |||
137 | // FIXME: i18n service does not support string concatenation | ||
138 | const message = this.i18n('You set custom {{customizationsText}}. ', { customizationsText }) + | ||
139 | this.i18n('This could lead to security issues or bugs if you do not understand it. ') + | ||
140 | this.i18n('Are you sure you want to update the configuration?') | ||
141 | |||
142 | const label = this.i18n('Please type') + ` "I understand the ${customizationsText} I set" ` + this.i18n('to confirm.') | ||
143 | const expectedInputValue = `I understand the ${customizationsText} I set` | ||
144 | |||
145 | const confirmRes = await this.confirmService.confirmWithInput(message, label, expectedInputValue) | ||
146 | if (confirmRes === false) return | ||
147 | } | ||
148 | |||
149 | const data: CustomConfig = { | 125 | const data: CustomConfig = { |
150 | instance: { | 126 | instance: { |
151 | name: this.form.value['instanceName'], | 127 | name: this.form.value['instanceName'], |
diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.html b/client/src/app/+admin/follows/followers-list/followers-list.component.html index 5645a60cc..fc022bdb4 100644 --- a/client/src/app/+admin/follows/followers-list/followers-list.component.html +++ b/client/src/app/+admin/follows/followers-list/followers-list.component.html | |||
@@ -2,6 +2,15 @@ | |||
2 | [value]="followers" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" | 2 | [value]="followers" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" |
3 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" | 3 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" |
4 | > | 4 | > |
5 | <ng-template pTemplate="caption"> | ||
6 | <div class="caption"> | ||
7 | <input | ||
8 | type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..." | ||
9 | (keyup)="onSearch($event.target.value)" | ||
10 | > | ||
11 | </div> | ||
12 | </ng-template> | ||
13 | |||
5 | <ng-template pTemplate="header"> | 14 | <ng-template pTemplate="header"> |
6 | <tr> | 15 | <tr> |
7 | <th i18n style="width: 60px">ID</th> | 16 | <th i18n style="width: 60px">ID</th> |
diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.scss b/client/src/app/+admin/follows/followers-list/followers-list.component.scss index e69de29bb..a6f0656b8 100644 --- a/client/src/app/+admin/follows/followers-list/followers-list.component.scss +++ b/client/src/app/+admin/follows/followers-list/followers-list.component.scss | |||
@@ -0,0 +1,10 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | .caption { | ||
5 | justify-content: flex-end; | ||
6 | |||
7 | input { | ||
8 | @include peertube-input-text(250px); | ||
9 | } | ||
10 | } \ No newline at end of file | ||
diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.ts b/client/src/app/+admin/follows/followers-list/followers-list.component.ts index ca993dcd3..4a25b7ff3 100644 --- a/client/src/app/+admin/follows/followers-list/followers-list.component.ts +++ b/client/src/app/+admin/follows/followers-list/followers-list.component.ts | |||
@@ -28,7 +28,7 @@ export class FollowersListComponent extends RestTable implements OnInit { | |||
28 | } | 28 | } |
29 | 29 | ||
30 | ngOnInit () { | 30 | ngOnInit () { |
31 | this.loadSort() | 31 | this.initialize() |
32 | } | 32 | } |
33 | 33 | ||
34 | protected loadData () { | 34 | protected loadData () { |
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.html b/client/src/app/+admin/follows/following-list/following-list.component.html index 8af624ac5..5bc8fbc2d 100644 --- a/client/src/app/+admin/follows/following-list/following-list.component.html +++ b/client/src/app/+admin/follows/following-list/following-list.component.html | |||
@@ -2,6 +2,17 @@ | |||
2 | [value]="following" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" | 2 | [value]="following" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" |
3 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" | 3 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" |
4 | > | 4 | > |
5 | <ng-template pTemplate="caption"> | ||
6 | <div class="caption"> | ||
7 | <div> | ||
8 | <input | ||
9 | type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..." | ||
10 | (keyup)="onSearch($event.target.value)" | ||
11 | > | ||
12 | </div> | ||
13 | </div> | ||
14 | </ng-template> | ||
15 | |||
5 | <ng-template pTemplate="header"> | 16 | <ng-template pTemplate="header"> |
6 | <tr> | 17 | <tr> |
7 | <th i18n style="width: 60px">ID</th> | 18 | <th i18n style="width: 60px">ID</th> |
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.scss b/client/src/app/+admin/follows/following-list/following-list.component.scss index bfcdcaa49..a6f0656b8 100644 --- a/client/src/app/+admin/follows/following-list/following-list.component.scss +++ b/client/src/app/+admin/follows/following-list/following-list.component.scss | |||
@@ -1,13 +1,10 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | my-redundancy-checkbox /deep/ my-peertube-checkbox { | 4 | .caption { |
5 | .form-group { | 5 | justify-content: flex-end; |
6 | margin-bottom: 0; | ||
7 | align-items: center; | ||
8 | } | ||
9 | 6 | ||
10 | label { | 7 | input { |
11 | margin: 0; | 8 | @include peertube-input-text(250px); |
12 | } | 9 | } |
13 | } \ No newline at end of file | 10 | } \ No newline at end of file |
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.ts b/client/src/app/+admin/follows/following-list/following-list.component.ts index dd57884c6..9b7029f75 100644 --- a/client/src/app/+admin/follows/following-list/following-list.component.ts +++ b/client/src/app/+admin/follows/following-list/following-list.component.ts | |||
@@ -29,7 +29,7 @@ export class FollowingListComponent extends RestTable implements OnInit { | |||
29 | } | 29 | } |
30 | 30 | ||
31 | ngOnInit () { | 31 | ngOnInit () { |
32 | this.loadSort() | 32 | this.initialize() |
33 | } | 33 | } |
34 | 34 | ||
35 | async removeFollowing (follow: ActorFollow) { | 35 | async removeFollowing (follow: ActorFollow) { |
@@ -53,7 +53,7 @@ export class FollowingListComponent extends RestTable implements OnInit { | |||
53 | } | 53 | } |
54 | 54 | ||
55 | protected loadData () { | 55 | protected loadData () { |
56 | this.followService.getFollowing(this.pagination, this.sort) | 56 | this.followService.getFollowing(this.pagination, this.sort, this.search) |
57 | .subscribe( | 57 | .subscribe( |
58 | resultList => { | 58 | resultList => { |
59 | this.following = resultList.data | 59 | this.following = resultList.data |
diff --git a/client/src/app/+admin/follows/shared/follow.service.ts b/client/src/app/+admin/follows/shared/follow.service.ts index 27169a9cd..a2904179e 100644 --- a/client/src/app/+admin/follows/shared/follow.service.ts +++ b/client/src/app/+admin/follows/shared/follow.service.ts | |||
@@ -18,10 +18,12 @@ export class FollowService { | |||
18 | ) { | 18 | ) { |
19 | } | 19 | } |
20 | 20 | ||
21 | getFollowing (pagination: RestPagination, sort: SortMeta): Observable<ResultList<ActorFollow>> { | 21 | getFollowing (pagination: RestPagination, sort: SortMeta, search?: string): Observable<ResultList<ActorFollow>> { |
22 | let params = new HttpParams() | 22 | let params = new HttpParams() |
23 | params = this.restService.addRestGetParams(params, pagination, sort) | 23 | params = this.restService.addRestGetParams(params, pagination, sort) |
24 | 24 | ||
25 | if (search) params = params.append('search', search) | ||
26 | |||
25 | return this.authHttp.get<ResultList<ActorFollow>>(FollowService.BASE_APPLICATION_URL + '/following', { params }) | 27 | return this.authHttp.get<ResultList<ActorFollow>>(FollowService.BASE_APPLICATION_URL + '/following', { params }) |
26 | .pipe( | 28 | .pipe( |
27 | map(res => this.restExtractor.convertResultListDateToHuman(res)), | 29 | map(res => this.restExtractor.convertResultListDateToHuman(res)), |
@@ -29,10 +31,12 @@ export class FollowService { | |||
29 | ) | 31 | ) |
30 | } | 32 | } |
31 | 33 | ||
32 | getFollowers (pagination: RestPagination, sort: SortMeta): Observable<ResultList<ActorFollow>> { | 34 | getFollowers (pagination: RestPagination, sort: SortMeta, search?: string): Observable<ResultList<ActorFollow>> { |
33 | let params = new HttpParams() | 35 | let params = new HttpParams() |
34 | params = this.restService.addRestGetParams(params, pagination, sort) | 36 | params = this.restService.addRestGetParams(params, pagination, sort) |
35 | 37 | ||
38 | if (search) params = params.append('search', search) | ||
39 | |||
36 | return this.authHttp.get<ResultList<ActorFollow>>(FollowService.BASE_APPLICATION_URL + '/followers', { params }) | 40 | return this.authHttp.get<ResultList<ActorFollow>>(FollowService.BASE_APPLICATION_URL + '/followers', { params }) |
37 | .pipe( | 41 | .pipe( |
38 | map(res => this.restExtractor.convertResultListDateToHuman(res)), | 42 | map(res => this.restExtractor.convertResultListDateToHuman(res)), |
diff --git a/client/src/app/+admin/jobs/jobs-list/jobs-list.component.ts b/client/src/app/+admin/jobs/jobs-list/jobs-list.component.ts index 866ba1b23..44778ab56 100644 --- a/client/src/app/+admin/jobs/jobs-list/jobs-list.component.ts +++ b/client/src/app/+admin/jobs/jobs-list/jobs-list.component.ts | |||
@@ -34,7 +34,7 @@ export class JobsListComponent extends RestTable implements OnInit { | |||
34 | 34 | ||
35 | ngOnInit () { | 35 | ngOnInit () { |
36 | this.loadJobState() | 36 | this.loadJobState() |
37 | this.loadSort() | 37 | this.initialize() |
38 | } | 38 | } |
39 | 39 | ||
40 | onJobStateChanged () { | 40 | onJobStateChanged () { |
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts index 681db7434..9837af586 100644 --- a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts +++ b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts | |||
@@ -57,7 +57,7 @@ export class VideoAbuseListComponent extends RestTable implements OnInit { | |||
57 | } | 57 | } |
58 | 58 | ||
59 | ngOnInit () { | 59 | ngOnInit () { |
60 | this.loadSort() | 60 | this.initialize() |
61 | } | 61 | } |
62 | 62 | ||
63 | openModerationCommentModal (videoAbuse: VideoAbuse) { | 63 | openModerationCommentModal (videoAbuse: VideoAbuse) { |
diff --git a/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts b/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts index bb051d00f..e491edaca 100644 --- a/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts +++ b/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts | |||
@@ -39,7 +39,7 @@ export class VideoBlacklistListComponent extends RestTable implements OnInit { | |||
39 | } | 39 | } |
40 | 40 | ||
41 | ngOnInit () { | 41 | ngOnInit () { |
42 | this.loadSort() | 42 | this.initialize() |
43 | } | 43 | } |
44 | 44 | ||
45 | getVideoUrl (videoBlacklist: VideoBlacklist) { | 45 | getVideoUrl (videoBlacklist: VideoBlacklist) { |
diff --git a/client/src/app/+admin/users/index.ts b/client/src/app/+admin/users/index.ts index efcd0d9cb..156e54d89 100644 --- a/client/src/app/+admin/users/index.ts +++ b/client/src/app/+admin/users/index.ts | |||
@@ -1,4 +1,3 @@ | |||
1 | export * from './shared' | ||
2 | export * from './user-edit' | 1 | export * from './user-edit' |
3 | export * from './user-list' | 2 | export * from './user-list' |
4 | export * from './users.component' | 3 | export * from './users.component' |
diff --git a/client/src/app/+admin/users/shared/index.ts b/client/src/app/+admin/users/shared/index.ts deleted file mode 100644 index 1f1302dc5..000000000 --- a/client/src/app/+admin/users/shared/index.ts +++ /dev/null | |||
@@ -1 +0,0 @@ | |||
1 | export * from './user.service' | ||
diff --git a/client/src/app/+admin/users/shared/user.service.ts b/client/src/app/+admin/users/shared/user.service.ts deleted file mode 100644 index 470beef08..000000000 --- a/client/src/app/+admin/users/shared/user.service.ts +++ /dev/null | |||
@@ -1,96 +0,0 @@ | |||
1 | import { catchError, map } from 'rxjs/operators' | ||
2 | import { HttpClient, HttpParams } from '@angular/common/http' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { BytesPipe } from 'ngx-pipes' | ||
5 | import { SortMeta } from 'primeng/components/common/sortmeta' | ||
6 | import { Observable } from 'rxjs' | ||
7 | import { ResultList, UserCreate, UserUpdate, User, UserRole } from '../../../../../../shared' | ||
8 | import { environment } from '../../../../environments/environment' | ||
9 | import { RestExtractor, RestPagination, RestService } from '../../../shared' | ||
10 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
11 | |||
12 | @Injectable() | ||
13 | export class UserService { | ||
14 | private static BASE_USERS_URL = environment.apiUrl + '/api/v1/users/' | ||
15 | private bytesPipe = new BytesPipe() | ||
16 | |||
17 | constructor ( | ||
18 | private authHttp: HttpClient, | ||
19 | private restService: RestService, | ||
20 | private restExtractor: RestExtractor, | ||
21 | private i18n: I18n | ||
22 | ) { } | ||
23 | |||
24 | addUser (userCreate: UserCreate) { | ||
25 | return this.authHttp.post(UserService.BASE_USERS_URL, userCreate) | ||
26 | .pipe( | ||
27 | map(this.restExtractor.extractDataBool), | ||
28 | catchError(err => this.restExtractor.handleError(err)) | ||
29 | ) | ||
30 | } | ||
31 | |||
32 | updateUser (userId: number, userUpdate: UserUpdate) { | ||
33 | return this.authHttp.put(UserService.BASE_USERS_URL + userId, userUpdate) | ||
34 | .pipe( | ||
35 | map(this.restExtractor.extractDataBool), | ||
36 | catchError(err => this.restExtractor.handleError(err)) | ||
37 | ) | ||
38 | } | ||
39 | |||
40 | getUser (userId: number) { | ||
41 | return this.authHttp.get<User>(UserService.BASE_USERS_URL + userId) | ||
42 | .pipe(catchError(err => this.restExtractor.handleError(err))) | ||
43 | } | ||
44 | |||
45 | getUsers (pagination: RestPagination, sort: SortMeta): Observable<ResultList<User>> { | ||
46 | let params = new HttpParams() | ||
47 | params = this.restService.addRestGetParams(params, pagination, sort) | ||
48 | |||
49 | return this.authHttp.get<ResultList<User>>(UserService.BASE_USERS_URL, { params }) | ||
50 | .pipe( | ||
51 | map(res => this.restExtractor.convertResultListDateToHuman(res)), | ||
52 | map(res => this.restExtractor.applyToResultListData(res, this.formatUser.bind(this))), | ||
53 | catchError(err => this.restExtractor.handleError(err)) | ||
54 | ) | ||
55 | } | ||
56 | |||
57 | removeUser (user: User) { | ||
58 | return this.authHttp.delete(UserService.BASE_USERS_URL + user.id) | ||
59 | .pipe(catchError(err => this.restExtractor.handleError(err))) | ||
60 | } | ||
61 | |||
62 | banUser (user: User, reason?: string) { | ||
63 | const body = reason ? { reason } : {} | ||
64 | |||
65 | return this.authHttp.post(UserService.BASE_USERS_URL + user.id + '/block', body) | ||
66 | .pipe(catchError(err => this.restExtractor.handleError(err))) | ||
67 | } | ||
68 | |||
69 | unbanUser (user: User) { | ||
70 | return this.authHttp.post(UserService.BASE_USERS_URL + user.id + '/unblock', {}) | ||
71 | .pipe(catchError(err => this.restExtractor.handleError(err))) | ||
72 | } | ||
73 | |||
74 | private formatUser (user: User) { | ||
75 | let videoQuota | ||
76 | if (user.videoQuota === -1) { | ||
77 | videoQuota = this.i18n('Unlimited') | ||
78 | } else { | ||
79 | videoQuota = this.bytesPipe.transform(user.videoQuota, 0) | ||
80 | } | ||
81 | |||
82 | const videoQuotaUsed = this.bytesPipe.transform(user.videoQuotaUsed, 0) | ||
83 | |||
84 | const roleLabels: { [ id in UserRole ]: string } = { | ||
85 | [UserRole.USER]: this.i18n('User'), | ||
86 | [UserRole.ADMINISTRATOR]: this.i18n('Administrator'), | ||
87 | [UserRole.MODERATOR]: this.i18n('Moderator') | ||
88 | } | ||
89 | |||
90 | return Object.assign(user, { | ||
91 | roleLabel: roleLabels[user.role], | ||
92 | videoQuota, | ||
93 | videoQuotaUsed | ||
94 | }) | ||
95 | } | ||
96 | } | ||
diff --git a/client/src/app/+admin/users/user-edit/user-create.component.ts b/client/src/app/+admin/users/user-edit/user-create.component.ts index 132e280b9..dd8e4efd5 100644 --- a/client/src/app/+admin/users/user-edit/user-create.component.ts +++ b/client/src/app/+admin/users/user-edit/user-create.component.ts | |||
@@ -1,7 +1,6 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | 1 | import { Component, OnInit } from '@angular/core' |
2 | import { Router } from '@angular/router' | 2 | import { Router } from '@angular/router' |
3 | import { NotificationsService } from 'angular2-notifications' | 3 | import { NotificationsService } from 'angular2-notifications' |
4 | import { UserService } from '../shared' | ||
5 | import { ServerService } from '../../../core' | 4 | import { ServerService } from '../../../core' |
6 | import { UserCreate, UserRole } from '../../../../../../shared' | 5 | import { UserCreate, UserRole } from '../../../../../../shared' |
7 | import { UserEdit } from './user-edit' | 6 | import { UserEdit } from './user-edit' |
@@ -9,6 +8,7 @@ import { I18n } from '@ngx-translate/i18n-polyfill' | |||
9 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 8 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
10 | import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service' | 9 | import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service' |
11 | import { ConfigService } from '@app/+admin/config/shared/config.service' | 10 | import { ConfigService } from '@app/+admin/config/shared/config.service' |
11 | import { UserService } from '@app/shared' | ||
12 | 12 | ||
13 | @Component({ | 13 | @Component({ |
14 | selector: 'my-user-create', | 14 | selector: 'my-user-create', |
diff --git a/client/src/app/+admin/users/user-edit/user-update.component.ts b/client/src/app/+admin/users/user-edit/user-update.component.ts index 9eb91ac95..cd3885a99 100644 --- a/client/src/app/+admin/users/user-edit/user-update.component.ts +++ b/client/src/app/+admin/users/user-edit/user-update.component.ts | |||
@@ -2,7 +2,6 @@ import { Component, OnDestroy, OnInit } from '@angular/core' | |||
2 | import { ActivatedRoute, Router } from '@angular/router' | 2 | import { ActivatedRoute, Router } from '@angular/router' |
3 | import { Subscription } from 'rxjs' | 3 | import { Subscription } from 'rxjs' |
4 | import { NotificationsService } from 'angular2-notifications' | 4 | import { NotificationsService } from 'angular2-notifications' |
5 | import { UserService } from '../shared' | ||
6 | import { ServerService } from '../../../core' | 5 | import { ServerService } from '../../../core' |
7 | import { UserEdit } from './user-edit' | 6 | import { UserEdit } from './user-edit' |
8 | import { User, UserUpdate } from '../../../../../../shared' | 7 | import { User, UserUpdate } from '../../../../../../shared' |
@@ -10,6 +9,7 @@ import { I18n } from '@ngx-translate/i18n-polyfill' | |||
10 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 9 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
11 | import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service' | 10 | import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service' |
12 | import { ConfigService } from '@app/+admin/config/shared/config.service' | 11 | import { ConfigService } from '@app/+admin/config/shared/config.service' |
12 | import { UserService } from '@app/shared' | ||
13 | 13 | ||
14 | @Component({ | 14 | @Component({ |
15 | selector: 'my-user-update', | 15 | selector: 'my-user-update', |
diff --git a/client/src/app/+admin/users/user-list/user-list.component.html b/client/src/app/+admin/users/user-list/user-list.component.html index bb1b26442..afa9ccfe4 100644 --- a/client/src/app/+admin/users/user-list/user-list.component.html +++ b/client/src/app/+admin/users/user-list/user-list.component.html | |||
@@ -10,9 +10,32 @@ | |||
10 | <p-table | 10 | <p-table |
11 | [value]="users" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" | 11 | [value]="users" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" |
12 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" | 12 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" |
13 | [(selection)]="selectedUsers" | ||
13 | > | 14 | > |
15 | <ng-template pTemplate="caption"> | ||
16 | <div class="caption"> | ||
17 | <div> | ||
18 | <my-action-dropdown | ||
19 | *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange" | ||
20 | [actions]="bulkUserActions" [entry]="selectedUsers" | ||
21 | > | ||
22 | </my-action-dropdown> | ||
23 | </div> | ||
24 | |||
25 | <div> | ||
26 | <input | ||
27 | type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..." | ||
28 | (keyup)="onSearch($event.target.value)" | ||
29 | > | ||
30 | </div> | ||
31 | </div> | ||
32 | </ng-template> | ||
33 | |||
14 | <ng-template pTemplate="header"> | 34 | <ng-template pTemplate="header"> |
15 | <tr> | 35 | <tr> |
36 | <th style="width: 40px"> | ||
37 | <p-tableHeaderCheckbox></p-tableHeaderCheckbox> | ||
38 | </th> | ||
16 | <th style="width: 40px"></th> | 39 | <th style="width: 40px"></th> |
17 | <th i18n pSortableColumn="username">Username <p-sortIcon field="username"></p-sortIcon></th> | 40 | <th i18n pSortableColumn="username">Username <p-sortIcon field="username"></p-sortIcon></th> |
18 | <th i18n>Email</th> | 41 | <th i18n>Email</th> |
@@ -25,12 +48,17 @@ | |||
25 | 48 | ||
26 | <ng-template pTemplate="body" let-expanded="expanded" let-user> | 49 | <ng-template pTemplate="body" let-expanded="expanded" let-user> |
27 | 50 | ||
28 | <tr [ngClass]="{ banned: user.blocked }"> | 51 | <tr [pSelectableRow]="user" [ngClass]="{ banned: user.blocked }"> |
52 | <td> | ||
53 | <p-tableCheckbox [value]="user"></p-tableCheckbox> | ||
54 | </td> | ||
55 | |||
29 | <td> | 56 | <td> |
30 | <span *ngIf="user.blockedReason" class="expander" [pRowToggler]="user"> | 57 | <span *ngIf="user.blockedReason" class="expander" [pRowToggler]="user"> |
31 | <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i> | 58 | <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i> |
32 | </span> | 59 | </span> |
33 | </td> | 60 | </td> |
61 | |||
34 | <td> | 62 | <td> |
35 | {{ user.username }} | 63 | {{ user.username }} |
36 | <span *ngIf="user.blocked" class="banned-info">(banned)</span> | 64 | <span *ngIf="user.blocked" class="banned-info">(banned)</span> |
@@ -40,7 +68,8 @@ | |||
40 | <td>{{ user.roleLabel }}</td> | 68 | <td>{{ user.roleLabel }}</td> |
41 | <td>{{ user.createdAt }}</td> | 69 | <td>{{ user.createdAt }}</td> |
42 | <td class="action-cell"> | 70 | <td class="action-cell"> |
43 | <my-action-dropdown i18n-label label="Actions" [actions]="userActions" [entry]="user"></my-action-dropdown> | 71 | <my-user-moderation-dropdown *ngIf="!isInSelectionMode()" [user]="user" (userChanged)="onUserChanged()" (userDeleted)="onUserChanged()"> |
72 | </my-user-moderation-dropdown> | ||
44 | </td> | 73 | </td> |
45 | </tr> | 74 | </tr> |
46 | </ng-template> | 75 | </ng-template> |
@@ -55,4 +84,4 @@ | |||
55 | </ng-template> | 84 | </ng-template> |
56 | </p-table> | 85 | </p-table> |
57 | 86 | ||
58 | <my-user-ban-modal #userBanModal (userBanned)="onUserBanned()"></my-user-ban-modal> \ No newline at end of file | 87 | <my-user-ban-modal #userBanModal (userBanned)="onUsersBanned()"></my-user-ban-modal> |
diff --git a/client/src/app/+admin/users/user-list/user-list.component.scss b/client/src/app/+admin/users/user-list/user-list.component.scss index 47291918d..f235769f0 100644 --- a/client/src/app/+admin/users/user-list/user-list.component.scss +++ b/client/src/app/+admin/users/user-list/user-list.component.scss | |||
@@ -15,4 +15,12 @@ tr.banned { | |||
15 | 15 | ||
16 | .ban-reason-label { | 16 | .ban-reason-label { |
17 | font-weight: $font-semibold; | 17 | font-weight: $font-semibold; |
18 | } | ||
19 | |||
20 | .caption { | ||
21 | justify-content: space-between; | ||
22 | |||
23 | input { | ||
24 | @include peertube-input-text(250px); | ||
25 | } | ||
18 | } \ No newline at end of file | 26 | } \ No newline at end of file |
diff --git a/client/src/app/+admin/users/user-list/user-list.component.ts b/client/src/app/+admin/users/user-list/user-list.component.ts index 100ffc00e..33384dc35 100644 --- a/client/src/app/+admin/users/user-list/user-list.component.ts +++ b/client/src/app/+admin/users/user-list/user-list.component.ts | |||
@@ -2,13 +2,11 @@ import { Component, OnInit, ViewChild } from '@angular/core' | |||
2 | import { NotificationsService } from 'angular2-notifications' | 2 | import { NotificationsService } from 'angular2-notifications' |
3 | import { SortMeta } from 'primeng/components/common/sortmeta' | 3 | import { SortMeta } from 'primeng/components/common/sortmeta' |
4 | import { ConfirmService } from '../../../core' | 4 | import { ConfirmService } from '../../../core' |
5 | import { RestPagination, RestTable } from '../../../shared' | 5 | import { RestPagination, RestTable, UserService } from '../../../shared' |
6 | import { UserService } from '../shared' | ||
7 | import { I18n } from '@ngx-translate/i18n-polyfill' | 6 | import { I18n } from '@ngx-translate/i18n-polyfill' |
8 | import { DropdownAction } from '@app/shared/buttons/action-dropdown.component' | ||
9 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' | ||
10 | import { UserBanModalComponent } from '@app/+admin/users/user-list/user-ban-modal.component' | ||
11 | import { User } from '../../../../../../shared' | 7 | import { User } from '../../../../../../shared' |
8 | import { UserBanModalComponent } from '@app/shared/moderation' | ||
9 | import { DropdownAction } from '@app/shared/buttons/action-dropdown.component' | ||
12 | 10 | ||
13 | @Component({ | 11 | @Component({ |
14 | selector: 'my-user-list', | 12 | selector: 'my-user-list', |
@@ -23,9 +21,9 @@ export class UserListComponent extends RestTable implements OnInit { | |||
23 | rowsPerPage = 10 | 21 | rowsPerPage = 10 |
24 | sort: SortMeta = { field: 'createdAt', order: 1 } | 22 | sort: SortMeta = { field: 'createdAt', order: 1 } |
25 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } | 23 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } |
26 | userActions: DropdownAction<User>[] = [] | ||
27 | 24 | ||
28 | private openedModal: NgbModalRef | 25 | selectedUsers: User[] = [] |
26 | bulkUserActions: DropdownAction<User>[] = [] | ||
29 | 27 | ||
30 | constructor ( | 28 | constructor ( |
31 | private notificationsService: NotificationsService, | 29 | private notificationsService: NotificationsService, |
@@ -34,84 +32,94 @@ export class UserListComponent extends RestTable implements OnInit { | |||
34 | private i18n: I18n | 32 | private i18n: I18n |
35 | ) { | 33 | ) { |
36 | super() | 34 | super() |
35 | } | ||
37 | 36 | ||
38 | this.userActions = [ | 37 | ngOnInit () { |
39 | { | 38 | this.initialize() |
40 | label: this.i18n('Edit'), | 39 | |
41 | linkBuilder: this.getRouterUserEditLink | 40 | this.bulkUserActions = [ |
42 | }, | ||
43 | { | 41 | { |
44 | label: this.i18n('Delete'), | 42 | label: this.i18n('Delete'), |
45 | handler: user => this.removeUser(user) | 43 | handler: users => this.removeUsers(users) |
46 | }, | 44 | }, |
47 | { | 45 | { |
48 | label: this.i18n('Ban'), | 46 | label: this.i18n('Ban'), |
49 | handler: user => this.openBanUserModal(user), | 47 | handler: users => this.openBanUserModal(users), |
50 | isDisplayed: user => !user.blocked | 48 | isDisplayed: users => users.every(u => u.blocked === false) |
51 | }, | 49 | }, |
52 | { | 50 | { |
53 | label: this.i18n('Unban'), | 51 | label: this.i18n('Unban'), |
54 | handler: user => this.unbanUser(user), | 52 | handler: users => this.unbanUsers(users), |
55 | isDisplayed: user => user.blocked | 53 | isDisplayed: users => users.every(u => u.blocked === true) |
56 | } | 54 | } |
57 | ] | 55 | ] |
58 | } | 56 | } |
59 | 57 | ||
60 | ngOnInit () { | 58 | protected loadData () { |
61 | this.loadSort() | 59 | this.selectedUsers = [] |
62 | } | ||
63 | 60 | ||
64 | hideBanUserModal () { | 61 | this.userService.getUsers(this.pagination, this.sort, this.search) |
65 | this.openedModal.close() | 62 | .subscribe( |
63 | resultList => { | ||
64 | this.users = resultList.data | ||
65 | this.totalRecords = resultList.total | ||
66 | }, | ||
67 | |||
68 | err => this.notificationsService.error(this.i18n('Error'), err.message) | ||
69 | ) | ||
66 | } | 70 | } |
67 | 71 | ||
68 | openBanUserModal (user: User) { | 72 | openBanUserModal (users: User[]) { |
69 | if (user.username === 'root') { | 73 | for (const user of users) { |
70 | this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot ban root.')) | 74 | if (user.username === 'root') { |
71 | return | 75 | this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot ban root.')) |
76 | return | ||
77 | } | ||
72 | } | 78 | } |
73 | 79 | ||
74 | this.userBanModal.openModal(user) | 80 | this.userBanModal.openModal(users) |
75 | } | 81 | } |
76 | 82 | ||
77 | onUserBanned () { | 83 | onUsersBanned () { |
78 | this.loadData() | 84 | this.loadData() |
79 | } | 85 | } |
80 | 86 | ||
81 | async unbanUser (user: User) { | 87 | async unbanUsers (users: User[]) { |
82 | const message = this.i18n('Do you really want to unban {{username}}?', { username: user.username }) | 88 | const message = this.i18n('Do you really want to unban {{num}} users?', { num: users.length }) |
89 | |||
83 | const res = await this.confirmService.confirm(message, this.i18n('Unban')) | 90 | const res = await this.confirmService.confirm(message, this.i18n('Unban')) |
84 | if (res === false) return | 91 | if (res === false) return |
85 | 92 | ||
86 | this.userService.unbanUser(user) | 93 | this.userService.unbanUsers(users) |
87 | .subscribe( | 94 | .subscribe( |
88 | () => { | 95 | () => { |
89 | this.notificationsService.success( | 96 | const message = this.i18n('{{num}} users unbanned.', { num: users.length }) |
90 | this.i18n('Success'), | 97 | |
91 | this.i18n('User {{username}} unbanned.', { username: user.username }) | 98 | this.notificationsService.success(this.i18n('Success'), message) |
92 | ) | 99 | this.loadData() |
93 | this.loadData() | 100 | }, |
94 | }, | 101 | |
95 | 102 | err => this.notificationsService.error(this.i18n('Error'), err.message) | |
96 | err => this.notificationsService.error(this.i18n('Error'), err.message) | 103 | ) |
97 | ) | ||
98 | } | 104 | } |
99 | 105 | ||
100 | async removeUser (user: User) { | 106 | async removeUsers (users: User[]) { |
101 | if (user.username === 'root') { | 107 | for (const user of users) { |
102 | this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot delete root.')) | 108 | if (user.username === 'root') { |
103 | return | 109 | this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot delete root.')) |
110 | return | ||
111 | } | ||
104 | } | 112 | } |
105 | 113 | ||
106 | const message = this.i18n('If you remove this user, you will not be able to create another with the same username!') | 114 | const message = this.i18n('If you remove these users, you will not be able to create others with the same username!') |
107 | const res = await this.confirmService.confirm(message, this.i18n('Delete')) | 115 | const res = await this.confirmService.confirm(message, this.i18n('Delete')) |
108 | if (res === false) return | 116 | if (res === false) return |
109 | 117 | ||
110 | this.userService.removeUser(user).subscribe( | 118 | this.userService.removeUser(users).subscribe( |
111 | () => { | 119 | () => { |
112 | this.notificationsService.success( | 120 | this.notificationsService.success( |
113 | this.i18n('Success'), | 121 | this.i18n('Success'), |
114 | this.i18n('User {{username}} deleted.', { username: user.username }) | 122 | this.i18n('{{num}} users deleted.', { num: users.length }) |
115 | ) | 123 | ) |
116 | this.loadData() | 124 | this.loadData() |
117 | }, | 125 | }, |
@@ -120,19 +128,7 @@ export class UserListComponent extends RestTable implements OnInit { | |||
120 | ) | 128 | ) |
121 | } | 129 | } |
122 | 130 | ||
123 | getRouterUserEditLink (user: User) { | 131 | isInSelectionMode () { |
124 | return [ '/admin', 'users', 'update', user.id ] | 132 | return this.selectedUsers.length !== 0 |
125 | } | ||
126 | |||
127 | protected loadData () { | ||
128 | this.userService.getUsers(this.pagination, this.sort) | ||
129 | .subscribe( | ||
130 | resultList => { | ||
131 | this.users = resultList.data | ||
132 | this.totalRecords = resultList.total | ||
133 | }, | ||
134 | |||
135 | err => this.notificationsService.error(this.i18n('Error'), err.message) | ||
136 | ) | ||
137 | } | 133 | } |
138 | } | 134 | } |
diff --git a/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts b/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts index 13517b9f4..520278671 100644 --- a/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts +++ b/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts | |||
@@ -31,7 +31,7 @@ export class MyAccountOwnershipComponent extends RestTable implements OnInit { | |||
31 | } | 31 | } |
32 | 32 | ||
33 | ngOnInit () { | 33 | ngOnInit () { |
34 | this.loadSort() | 34 | this.initialize() |
35 | } | 35 | } |
36 | 36 | ||
37 | protected loadData () { | 37 | protected loadData () { |
diff --git a/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts b/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts index d9fb20446..5b920c98d 100644 --- a/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts +++ b/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts | |||
@@ -27,7 +27,7 @@ export class MyAccountVideoImportsComponent extends RestTable implements OnInit | |||
27 | } | 27 | } |
28 | 28 | ||
29 | ngOnInit () { | 29 | ngOnInit () { |
30 | this.loadSort() | 30 | this.initialize() |
31 | } | 31 | } |
32 | 32 | ||
33 | isVideoImportSuccess (videoImport: VideoImport) { | 33 | isVideoImportSuccess (videoImport: VideoImport) { |
diff --git a/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html b/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html index 69b198faa..7c0df850d 100644 --- a/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html +++ b/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html | |||
@@ -22,9 +22,9 @@ | |||
22 | </span> | 22 | </span> |
23 | 23 | ||
24 | <input | 24 | <input |
25 | type="submit" i18n-value value="Submit" class="action-button-submit" | 25 | type="submit" i18n-value value="Submit" class="action-button-submit" |
26 | [disabled]="!form.valid" | 26 | [disabled]="!form.valid" |
27 | (click)="close()" | 27 | (click)="close()" |
28 | /> | 28 | /> |
29 | </div> | 29 | </div> |
30 | </div> | 30 | </div> |
diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index 7cd0fff1b..dc4d0bf6a 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts | |||
@@ -4,9 +4,10 @@ import { GuardsCheckStart, NavigationEnd, Router } from '@angular/router' | |||
4 | import { AuthService, RedirectService, ServerService, ThemeService } from '@app/core' | 4 | import { AuthService, RedirectService, ServerService, ThemeService } from '@app/core' |
5 | import { is18nPath } from '../../../shared/models/i18n' | 5 | import { is18nPath } from '../../../shared/models/i18n' |
6 | import { ScreenService } from '@app/shared/misc/screen.service' | 6 | import { ScreenService } from '@app/shared/misc/screen.service' |
7 | import { skip } from 'rxjs/operators' | 7 | import { skip, debounceTime } from 'rxjs/operators' |
8 | import { HotkeysService, Hotkey } from 'angular2-hotkeys' | 8 | import { HotkeysService, Hotkey } from 'angular2-hotkeys' |
9 | import { I18n } from '@ngx-translate/i18n-polyfill' | 9 | import { I18n } from '@ngx-translate/i18n-polyfill' |
10 | import { fromEvent } from 'rxjs' | ||
10 | 11 | ||
11 | @Component({ | 12 | @Component({ |
12 | selector: 'my-app', | 13 | selector: 'my-app', |
@@ -28,6 +29,7 @@ export class AppComponent implements OnInit { | |||
28 | } | 29 | } |
29 | 30 | ||
30 | isMenuDisplayed = true | 31 | isMenuDisplayed = true |
32 | isMenuChangedByUser = false | ||
31 | 33 | ||
32 | customCSS: SafeHtml | 34 | customCSS: SafeHtml |
33 | 35 | ||
@@ -165,6 +167,10 @@ export class AppComponent implements OnInit { | |||
165 | return false | 167 | return false |
166 | }, undefined, this.i18n('Toggle Dark theme')) | 168 | }, undefined, this.i18n('Toggle Dark theme')) |
167 | ]) | 169 | ]) |
170 | |||
171 | fromEvent(window, 'resize') | ||
172 | .pipe(debounceTime(200)) | ||
173 | .subscribe(() => this.onResize()) | ||
168 | } | 174 | } |
169 | 175 | ||
170 | isUserLoggedIn () { | 176 | isUserLoggedIn () { |
@@ -173,5 +179,10 @@ export class AppComponent implements OnInit { | |||
173 | 179 | ||
174 | toggleMenu () { | 180 | toggleMenu () { |
175 | this.isMenuDisplayed = !this.isMenuDisplayed | 181 | this.isMenuDisplayed = !this.isMenuDisplayed |
182 | this.isMenuChangedByUser = true | ||
183 | } | ||
184 | |||
185 | onResize () { | ||
186 | this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser | ||
176 | } | 187 | } |
177 | } | 188 | } |
diff --git a/client/src/app/header/header.component.html b/client/src/app/header/header.component.html index a04354db5..c23e0c55d 100644 --- a/client/src/app/header/header.component.html +++ b/client/src/app/header/header.component.html | |||
@@ -1,6 +1,6 @@ | |||
1 | <input | 1 | <input |
2 | type="text" id="search-video" name="search-video" i18n-placeholder placeholder="Search..." | 2 | type="text" id="search-video" name="search-video" i18n-placeholder placeholder="Search..." |
3 | [(ngModel)]="searchValue" (keyup.enter)="doSearch()" | 3 | [(ngModel)]="searchValue" (keyup.enter)="doSearch()" |
4 | > | 4 | > |
5 | <span (click)="doSearch()" class="icon icon-search"></span> | 5 | <span (click)="doSearch()" class="icon icon-search"></span> |
6 | 6 | ||
diff --git a/client/src/app/shared/account/account.model.ts b/client/src/app/shared/account/account.model.ts index 5058e372f..42f2cfeaf 100644 --- a/client/src/app/shared/account/account.model.ts +++ b/client/src/app/shared/account/account.model.ts | |||
@@ -6,11 +6,14 @@ export class Account extends Actor implements ServerAccount { | |||
6 | description: string | 6 | description: string |
7 | nameWithHost: string | 7 | nameWithHost: string |
8 | 8 | ||
9 | userId?: number | ||
10 | |||
9 | constructor (hash: ServerAccount) { | 11 | constructor (hash: ServerAccount) { |
10 | super(hash) | 12 | super(hash) |
11 | 13 | ||
12 | this.displayName = hash.displayName | 14 | this.displayName = hash.displayName |
13 | this.description = hash.description | 15 | this.description = hash.description |
16 | this.userId = hash.userId | ||
14 | this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host) | 17 | this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host) |
15 | } | 18 | } |
16 | } | 19 | } |
diff --git a/client/src/app/shared/buttons/action-dropdown.component.html b/client/src/app/shared/buttons/action-dropdown.component.html index 8b7241379..111627424 100644 --- a/client/src/app/shared/buttons/action-dropdown.component.html +++ b/client/src/app/shared/buttons/action-dropdown.component.html | |||
@@ -1,6 +1,10 @@ | |||
1 | <div class="dropdown-root" ngbDropdown [placement]="placement"> | 1 | <div class="dropdown-root" ngbDropdown [placement]="placement"> |
2 | <div class="action-button" ngbDropdownToggle role="button"> | 2 | <div |
3 | <span class="icon icon-action"></span> | 3 | class="action-button" [ngClass]="{ small: buttonSize === 'small', grey: theme === 'grey', orange: theme === 'orange' }" |
4 | ngbDropdownToggle role="button" | ||
5 | > | ||
6 | <span *ngIf="!label" class="icon icon-action"></span> | ||
7 | <span *ngIf="label" class="dropdown-toggle">{{ label }}</span> | ||
4 | </div> | 8 | </div> |
5 | 9 | ||
6 | <div ngbDropdownMenu class="dropdown-menu"> | 10 | <div ngbDropdownMenu class="dropdown-menu"> |
diff --git a/client/src/app/shared/buttons/action-dropdown.component.scss b/client/src/app/shared/buttons/action-dropdown.component.scss index 615511093..0a9aa7b04 100644 --- a/client/src/app/shared/buttons/action-dropdown.component.scss +++ b/client/src/app/shared/buttons/action-dropdown.component.scss | |||
@@ -3,7 +3,14 @@ | |||
3 | 3 | ||
4 | .action-button { | 4 | .action-button { |
5 | @include peertube-button; | 5 | @include peertube-button; |
6 | @include grey-button; | 6 | |
7 | &.grey { | ||
8 | @include grey-button; | ||
9 | } | ||
10 | |||
11 | &.orange { | ||
12 | @include orange-button; | ||
13 | } | ||
7 | 14 | ||
8 | display: inline-block; | 15 | display: inline-block; |
9 | padding: 0 10px; | 16 | padding: 0 10px; |
@@ -22,6 +29,17 @@ | |||
22 | background-image: url('../../../assets/images/video/more.svg'); | 29 | background-image: url('../../../assets/images/video/more.svg'); |
23 | top: -1px; | 30 | top: -1px; |
24 | } | 31 | } |
32 | |||
33 | &.small { | ||
34 | font-size: 14px; | ||
35 | height: 20px; | ||
36 | line-height: 20px; | ||
37 | } | ||
38 | } | ||
39 | |||
40 | .dropdown-toggle::after { | ||
41 | position: relative; | ||
42 | top: 1px; | ||
25 | } | 43 | } |
26 | 44 | ||
27 | .dropdown-menu { | 45 | .dropdown-menu { |
diff --git a/client/src/app/shared/buttons/action-dropdown.component.ts b/client/src/app/shared/buttons/action-dropdown.component.ts index 17f9cc618..022ab5ee8 100644 --- a/client/src/app/shared/buttons/action-dropdown.component.ts +++ b/client/src/app/shared/buttons/action-dropdown.component.ts | |||
@@ -16,5 +16,8 @@ export type DropdownAction<T> = { | |||
16 | export class ActionDropdownComponent<T> { | 16 | export class ActionDropdownComponent<T> { |
17 | @Input() actions: DropdownAction<T>[] = [] | 17 | @Input() actions: DropdownAction<T>[] = [] |
18 | @Input() entry: T | 18 | @Input() entry: T |
19 | @Input() placement = 'left' | 19 | @Input() placement = 'bottom-left' |
20 | @Input() buttonSize: 'normal' | 'small' = 'normal' | ||
21 | @Input() label: string | ||
22 | @Input() theme: 'orange' | 'grey' = 'grey' | ||
20 | } | 23 | } |
diff --git a/client/src/app/shared/forms/form-validators/video-change-ownership-validators.service.ts b/client/src/app/shared/forms/form-validators/video-change-ownership-validators.service.ts index 087b80b44..c6fbb7538 100644 --- a/client/src/app/shared/forms/form-validators/video-change-ownership-validators.service.ts +++ b/client/src/app/shared/forms/form-validators/video-change-ownership-validators.service.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | 1 | import { I18n } from '@ngx-translate/i18n-polyfill' |
2 | import { Validators } from '@angular/forms' | 2 | import { AbstractControl, ValidationErrors, Validators } from '@angular/forms' |
3 | import { Injectable } from '@angular/core' | 3 | import { Injectable } from '@angular/core' |
4 | import { BuildFormValidator } from '@app/shared' | 4 | import { BuildFormValidator } from '@app/shared' |
5 | 5 | ||
@@ -9,10 +9,19 @@ export class VideoChangeOwnershipValidatorsService { | |||
9 | 9 | ||
10 | constructor (private i18n: I18n) { | 10 | constructor (private i18n: I18n) { |
11 | this.USERNAME = { | 11 | this.USERNAME = { |
12 | VALIDATORS: [ Validators.required ], | 12 | VALIDATORS: [ Validators.required, this.localAccountValidator ], |
13 | MESSAGES: { | 13 | MESSAGES: { |
14 | 'required': this.i18n('The username is required.') | 14 | 'required': this.i18n('The username is required.'), |
15 | 'localAccountOnly': this.i18n('You can only transfer ownership to a local account') | ||
15 | } | 16 | } |
16 | } | 17 | } |
17 | } | 18 | } |
19 | |||
20 | localAccountValidator (control: AbstractControl): ValidationErrors { | ||
21 | if (control.value.includes('@')) { | ||
22 | return { 'localAccountOnly': true } | ||
23 | } | ||
24 | |||
25 | return null | ||
26 | } | ||
18 | } | 27 | } |
diff --git a/client/src/app/shared/forms/peertube-checkbox.component.html b/client/src/app/shared/forms/peertube-checkbox.component.html index 38691f050..fb3006b53 100644 --- a/client/src/app/shared/forms/peertube-checkbox.component.html +++ b/client/src/app/shared/forms/peertube-checkbox.component.html | |||
@@ -1,4 +1,4 @@ | |||
1 | <div class="form-group"> | 1 | <div class="root"> |
2 | <label class="form-group-checkbox"> | 2 | <label class="form-group-checkbox"> |
3 | <input type="checkbox" [(ngModel)]="checked" (ngModelChange)="onModelChange()" [id]="inputName" [disabled]="isDisabled" /> | 3 | <input type="checkbox" [(ngModel)]="checked" (ngModelChange)="onModelChange()" [id]="inputName" [disabled]="isDisabled" /> |
4 | <span role="checkbox" [attr.aria-checked]="checked"></span> | 4 | <span role="checkbox" [attr.aria-checked]="checked"></span> |
diff --git a/client/src/app/shared/forms/peertube-checkbox.component.scss b/client/src/app/shared/forms/peertube-checkbox.component.scss index ee133f190..6e4e20775 100644 --- a/client/src/app/shared/forms/peertube-checkbox.component.scss +++ b/client/src/app/shared/forms/peertube-checkbox.component.scss | |||
@@ -1,7 +1,7 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | .form-group { | 4 | .root { |
5 | display: flex; | 5 | display: flex; |
6 | 6 | ||
7 | .form-group-checkbox { | 7 | .form-group-checkbox { |
@@ -20,6 +20,10 @@ | |||
20 | } | 20 | } |
21 | } | 21 | } |
22 | 22 | ||
23 | label { | ||
24 | margin-bottom: 0; | ||
25 | } | ||
26 | |||
23 | my-help { | 27 | my-help { |
24 | position: relative; | 28 | position: relative; |
25 | top: -2px; | 29 | top: -2px; |
diff --git a/client/src/app/shared/moderation/index.ts b/client/src/app/shared/moderation/index.ts new file mode 100644 index 000000000..9a77c64c0 --- /dev/null +++ b/client/src/app/shared/moderation/index.ts | |||
@@ -0,0 +1,2 @@ | |||
1 | export * from './user-ban-modal.component' | ||
2 | export * from './user-moderation-dropdown.component' | ||
diff --git a/client/src/app/+admin/users/user-list/user-ban-modal.component.html b/client/src/app/shared/moderation/user-ban-modal.component.html index b2958caa4..fa5cb7404 100644 --- a/client/src/app/+admin/users/user-list/user-ban-modal.component.html +++ b/client/src/app/shared/moderation/user-ban-modal.component.html | |||
@@ -1,6 +1,6 @@ | |||
1 | <ng-template #modal> | 1 | <ng-template #modal> |
2 | <div class="modal-header"> | 2 | <div class="modal-header"> |
3 | <h4 i18n class="modal-title">Ban {{ userToBan.username }}</h4> | 3 | <h4 i18n class="modal-title">Ban</h4> |
4 | <span class="close" aria-hidden="true" (click)="hideBanUserModal()"></span> | 4 | <span class="close" aria-hidden="true" (click)="hideBanUserModal()"></span> |
5 | </div> | 5 | </div> |
6 | 6 | ||
diff --git a/client/src/app/+admin/users/user-list/user-ban-modal.component.scss b/client/src/app/shared/moderation/user-ban-modal.component.scss index 84562f15c..84562f15c 100644 --- a/client/src/app/+admin/users/user-list/user-ban-modal.component.scss +++ b/client/src/app/shared/moderation/user-ban-modal.component.scss | |||
diff --git a/client/src/app/+admin/users/user-list/user-ban-modal.component.ts b/client/src/app/shared/moderation/user-ban-modal.component.ts index 4fd4d561c..60bd442dd 100644 --- a/client/src/app/+admin/users/user-list/user-ban-modal.component.ts +++ b/client/src/app/shared/moderation/user-ban-modal.component.ts | |||
@@ -1,12 +1,12 @@ | |||
1 | import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' | 1 | import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' |
2 | import { NotificationsService } from 'angular2-notifications' | 2 | import { NotificationsService } from 'angular2-notifications' |
3 | import { FormReactive, UserValidatorsService } from '../../../shared' | ||
4 | import { UserService } from '../shared' | ||
5 | import { I18n } from '@ngx-translate/i18n-polyfill' | 3 | import { I18n } from '@ngx-translate/i18n-polyfill' |
6 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | 4 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' |
7 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' | 5 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' |
8 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 6 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
9 | import { User } from '../../../../../../shared' | 7 | import { FormReactive, UserValidatorsService } from '@app/shared/forms' |
8 | import { UserService } from '@app/shared/users' | ||
9 | import { User } from '../../../../../shared' | ||
10 | 10 | ||
11 | @Component({ | 11 | @Component({ |
12 | selector: 'my-user-ban-modal', | 12 | selector: 'my-user-ban-modal', |
@@ -15,9 +15,9 @@ import { User } from '../../../../../../shared' | |||
15 | }) | 15 | }) |
16 | export class UserBanModalComponent extends FormReactive implements OnInit { | 16 | export class UserBanModalComponent extends FormReactive implements OnInit { |
17 | @ViewChild('modal') modal: NgbModal | 17 | @ViewChild('modal') modal: NgbModal |
18 | @Output() userBanned = new EventEmitter<User>() | 18 | @Output() userBanned = new EventEmitter<User | User[]>() |
19 | 19 | ||
20 | private userToBan: User | 20 | private usersToBan: User | User[] |
21 | private openedModal: NgbModalRef | 21 | private openedModal: NgbModalRef |
22 | 22 | ||
23 | constructor ( | 23 | constructor ( |
@@ -37,28 +37,29 @@ export class UserBanModalComponent extends FormReactive implements OnInit { | |||
37 | }) | 37 | }) |
38 | } | 38 | } |
39 | 39 | ||
40 | openModal (user: User) { | 40 | openModal (user: User | User[]) { |
41 | this.userToBan = user | 41 | this.usersToBan = user |
42 | this.openedModal = this.modalService.open(this.modal) | 42 | this.openedModal = this.modalService.open(this.modal) |
43 | } | 43 | } |
44 | 44 | ||
45 | hideBanUserModal () { | 45 | hideBanUserModal () { |
46 | this.userToBan = undefined | 46 | this.usersToBan = undefined |
47 | this.openedModal.close() | 47 | this.openedModal.close() |
48 | } | 48 | } |
49 | 49 | ||
50 | async banUser () { | 50 | async banUser () { |
51 | const reason = this.form.value['reason'] || undefined | 51 | const reason = this.form.value['reason'] || undefined |
52 | 52 | ||
53 | this.userService.banUser(this.userToBan, reason) | 53 | this.userService.banUsers(this.usersToBan, reason) |
54 | .subscribe( | 54 | .subscribe( |
55 | () => { | 55 | () => { |
56 | this.notificationsService.success( | 56 | const message = Array.isArray(this.usersToBan) |
57 | this.i18n('Success'), | 57 | ? this.i18n('{{num}} users banned.', { num: this.usersToBan.length }) |
58 | this.i18n('User {{username}} banned.', { username: this.userToBan.username }) | 58 | : this.i18n('User {{username}} banned.', { username: this.usersToBan.username }) |
59 | ) | ||
60 | 59 | ||
61 | this.userBanned.emit(this.userToBan) | 60 | this.notificationsService.success(this.i18n('Success'), message) |
61 | |||
62 | this.userBanned.emit(this.usersToBan) | ||
62 | this.hideBanUserModal() | 63 | this.hideBanUserModal() |
63 | }, | 64 | }, |
64 | 65 | ||
diff --git a/client/src/app/shared/moderation/user-moderation-dropdown.component.html b/client/src/app/shared/moderation/user-moderation-dropdown.component.html new file mode 100644 index 000000000..01db7cd4a --- /dev/null +++ b/client/src/app/shared/moderation/user-moderation-dropdown.component.html | |||
@@ -0,0 +1,5 @@ | |||
1 | <ng-container *ngIf="user && userActions.length !== 0"> | ||
2 | <my-user-ban-modal #userBanModal (userBanned)="onUserBanned()"></my-user-ban-modal> | ||
3 | |||
4 | <my-action-dropdown [actions]="userActions" [entry]="user" [buttonSize]="buttonSize" [placement]="placement"></my-action-dropdown> | ||
5 | </ng-container> \ No newline at end of file | ||
diff --git a/client/src/app/shared/moderation/user-moderation-dropdown.component.scss b/client/src/app/shared/moderation/user-moderation-dropdown.component.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/client/src/app/shared/moderation/user-moderation-dropdown.component.scss | |||
diff --git a/client/src/app/shared/moderation/user-moderation-dropdown.component.ts b/client/src/app/shared/moderation/user-moderation-dropdown.component.ts new file mode 100644 index 000000000..105c99d8b --- /dev/null +++ b/client/src/app/shared/moderation/user-moderation-dropdown.component.ts | |||
@@ -0,0 +1,129 @@ | |||
1 | import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' | ||
2 | import { NotificationsService } from 'angular2-notifications' | ||
3 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
4 | import { DropdownAction } from '@app/shared/buttons/action-dropdown.component' | ||
5 | import { UserBanModalComponent } from '@app/shared/moderation/user-ban-modal.component' | ||
6 | import { UserService } from '@app/shared/users' | ||
7 | import { AuthService, ConfirmService } from '@app/core' | ||
8 | import { User, UserRight } from '../../../../../shared/models/users' | ||
9 | |||
10 | @Component({ | ||
11 | selector: 'my-user-moderation-dropdown', | ||
12 | templateUrl: './user-moderation-dropdown.component.html', | ||
13 | styleUrls: [ './user-moderation-dropdown.component.scss' ] | ||
14 | }) | ||
15 | export class UserModerationDropdownComponent implements OnInit { | ||
16 | @ViewChild('userBanModal') userBanModal: UserBanModalComponent | ||
17 | |||
18 | @Input() user: User | ||
19 | @Input() buttonSize: 'normal' | 'small' = 'normal' | ||
20 | @Input() placement = 'left' | ||
21 | |||
22 | @Output() userChanged = new EventEmitter() | ||
23 | @Output() userDeleted = new EventEmitter() | ||
24 | |||
25 | userActions: DropdownAction<User>[] = [] | ||
26 | |||
27 | constructor ( | ||
28 | private authService: AuthService, | ||
29 | private notificationsService: NotificationsService, | ||
30 | private confirmService: ConfirmService, | ||
31 | private userService: UserService, | ||
32 | private i18n: I18n | ||
33 | ) { } | ||
34 | |||
35 | ngOnInit () { | ||
36 | this.buildActions() | ||
37 | } | ||
38 | |||
39 | openBanUserModal (user: User) { | ||
40 | if (user.username === 'root') { | ||
41 | this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot ban root.')) | ||
42 | return | ||
43 | } | ||
44 | |||
45 | this.userBanModal.openModal(user) | ||
46 | } | ||
47 | |||
48 | onUserBanned () { | ||
49 | this.userChanged.emit() | ||
50 | } | ||
51 | |||
52 | async unbanUser (user: User) { | ||
53 | const message = this.i18n('Do you really want to unban {{username}}?', { username: user.username }) | ||
54 | const res = await this.confirmService.confirm(message, this.i18n('Unban')) | ||
55 | if (res === false) return | ||
56 | |||
57 | this.userService.unbanUsers(user) | ||
58 | .subscribe( | ||
59 | () => { | ||
60 | this.notificationsService.success( | ||
61 | this.i18n('Success'), | ||
62 | this.i18n('User {{username}} unbanned.', { username: user.username }) | ||
63 | ) | ||
64 | |||
65 | this.userChanged.emit() | ||
66 | }, | ||
67 | |||
68 | err => this.notificationsService.error(this.i18n('Error'), err.message) | ||
69 | ) | ||
70 | } | ||
71 | |||
72 | async removeUser (user: User) { | ||
73 | if (user.username === 'root') { | ||
74 | this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot delete root.')) | ||
75 | return | ||
76 | } | ||
77 | |||
78 | const message = this.i18n('If you remove this user, you will not be able to create another with the same username!') | ||
79 | const res = await this.confirmService.confirm(message, this.i18n('Delete')) | ||
80 | if (res === false) return | ||
81 | |||
82 | this.userService.removeUser(user).subscribe( | ||
83 | () => { | ||
84 | this.notificationsService.success( | ||
85 | this.i18n('Success'), | ||
86 | this.i18n('User {{username}} deleted.', { username: user.username }) | ||
87 | ) | ||
88 | this.userDeleted.emit() | ||
89 | }, | ||
90 | |||
91 | err => this.notificationsService.error(this.i18n('Error'), err.message) | ||
92 | ) | ||
93 | } | ||
94 | |||
95 | getRouterUserEditLink (user: User) { | ||
96 | return [ '/admin', 'users', 'update', user.id ] | ||
97 | } | ||
98 | |||
99 | private buildActions () { | ||
100 | this.userActions = [] | ||
101 | |||
102 | if (this.authService.isLoggedIn()) { | ||
103 | const authUser = this.authService.getUser() | ||
104 | |||
105 | if (authUser.hasRight(UserRight.MANAGE_USERS)) { | ||
106 | this.userActions = this.userActions.concat([ | ||
107 | { | ||
108 | label: this.i18n('Edit'), | ||
109 | linkBuilder: this.getRouterUserEditLink | ||
110 | }, | ||
111 | { | ||
112 | label: this.i18n('Delete'), | ||
113 | handler: user => this.removeUser(user) | ||
114 | }, | ||
115 | { | ||
116 | label: this.i18n('Ban'), | ||
117 | handler: user => this.openBanUserModal(user), | ||
118 | isDisplayed: user => !user.blocked | ||
119 | }, | ||
120 | { | ||
121 | label: this.i18n('Unban'), | ||
122 | handler: user => this.unbanUser(user), | ||
123 | isDisplayed: user => user.blocked | ||
124 | } | ||
125 | ]) | ||
126 | } | ||
127 | } | ||
128 | } | ||
129 | } | ||
diff --git a/client/src/app/shared/rest/rest-table.ts b/client/src/app/shared/rest/rest-table.ts index fe1a91d2d..26748f245 100644 --- a/client/src/app/shared/rest/rest-table.ts +++ b/client/src/app/shared/rest/rest-table.ts | |||
@@ -1,8 +1,9 @@ | |||
1 | import { peertubeLocalStorage } from '@app/shared/misc/peertube-local-storage' | 1 | import { peertubeLocalStorage } from '@app/shared/misc/peertube-local-storage' |
2 | import { LazyLoadEvent } from 'primeng/components/common/lazyloadevent' | 2 | import { LazyLoadEvent } from 'primeng/components/common/lazyloadevent' |
3 | import { SortMeta } from 'primeng/components/common/sortmeta' | 3 | import { SortMeta } from 'primeng/components/common/sortmeta' |
4 | |||
5 | import { RestPagination } from './rest-pagination' | 4 | import { RestPagination } from './rest-pagination' |
5 | import { Subject } from 'rxjs' | ||
6 | import { debounceTime, distinctUntilChanged } from 'rxjs/operators' | ||
6 | 7 | ||
7 | export abstract class RestTable { | 8 | export abstract class RestTable { |
8 | 9 | ||
@@ -11,10 +12,17 @@ export abstract class RestTable { | |||
11 | abstract sort: SortMeta | 12 | abstract sort: SortMeta |
12 | abstract pagination: RestPagination | 13 | abstract pagination: RestPagination |
13 | 14 | ||
15 | protected search: string | ||
16 | private searchStream: Subject<string> | ||
14 | private sortLocalStorageKey = 'rest-table-sort-' + this.constructor.name | 17 | private sortLocalStorageKey = 'rest-table-sort-' + this.constructor.name |
15 | 18 | ||
16 | protected abstract loadData (): void | 19 | protected abstract loadData (): void |
17 | 20 | ||
21 | initialize () { | ||
22 | this.loadSort() | ||
23 | this.initSearch() | ||
24 | } | ||
25 | |||
18 | loadSort () { | 26 | loadSort () { |
19 | const result = peertubeLocalStorage.getItem(this.sortLocalStorageKey) | 27 | const result = peertubeLocalStorage.getItem(this.sortLocalStorageKey) |
20 | 28 | ||
@@ -46,4 +54,21 @@ export abstract class RestTable { | |||
46 | peertubeLocalStorage.setItem(this.sortLocalStorageKey, JSON.stringify(this.sort)) | 54 | peertubeLocalStorage.setItem(this.sortLocalStorageKey, JSON.stringify(this.sort)) |
47 | } | 55 | } |
48 | 56 | ||
57 | initSearch () { | ||
58 | this.searchStream = new Subject() | ||
59 | |||
60 | this.searchStream | ||
61 | .pipe( | ||
62 | debounceTime(400), | ||
63 | distinctUntilChanged() | ||
64 | ) | ||
65 | .subscribe(search => { | ||
66 | this.search = search | ||
67 | this.loadData() | ||
68 | }) | ||
69 | } | ||
70 | |||
71 | onSearch (search: string) { | ||
72 | this.searchStream.next(search) | ||
73 | } | ||
49 | } | 74 | } |
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 076f1d275..9647a7966 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts | |||
@@ -56,6 +56,8 @@ import { NgbDropdownModule, NgbModalModule, NgbPopoverModule, NgbTabsetModule, N | |||
56 | import { SubscribeButtonComponent, RemoteSubscribeComponent, UserSubscriptionService } from '@app/shared/user-subscription' | 56 | import { SubscribeButtonComponent, RemoteSubscribeComponent, UserSubscriptionService } from '@app/shared/user-subscription' |
57 | import { InstanceFeaturesTableComponent } from '@app/shared/instance/instance-features-table.component' | 57 | import { InstanceFeaturesTableComponent } from '@app/shared/instance/instance-features-table.component' |
58 | import { OverviewService } from '@app/shared/overview' | 58 | import { OverviewService } from '@app/shared/overview' |
59 | import { UserBanModalComponent } from '@app/shared/moderation' | ||
60 | import { UserModerationDropdownComponent } from '@app/shared/moderation/user-moderation-dropdown.component' | ||
59 | 61 | ||
60 | @NgModule({ | 62 | @NgModule({ |
61 | imports: [ | 63 | imports: [ |
@@ -94,7 +96,9 @@ import { OverviewService } from '@app/shared/overview' | |||
94 | PeertubeCheckboxComponent, | 96 | PeertubeCheckboxComponent, |
95 | SubscribeButtonComponent, | 97 | SubscribeButtonComponent, |
96 | RemoteSubscribeComponent, | 98 | RemoteSubscribeComponent, |
97 | InstanceFeaturesTableComponent | 99 | InstanceFeaturesTableComponent, |
100 | UserBanModalComponent, | ||
101 | UserModerationDropdownComponent | ||
98 | ], | 102 | ], |
99 | 103 | ||
100 | exports: [ | 104 | exports: [ |
@@ -130,6 +134,8 @@ import { OverviewService } from '@app/shared/overview' | |||
130 | SubscribeButtonComponent, | 134 | SubscribeButtonComponent, |
131 | RemoteSubscribeComponent, | 135 | RemoteSubscribeComponent, |
132 | InstanceFeaturesTableComponent, | 136 | InstanceFeaturesTableComponent, |
137 | UserBanModalComponent, | ||
138 | UserModerationDropdownComponent, | ||
133 | 139 | ||
134 | NumberFormatterPipe, | 140 | NumberFormatterPipe, |
135 | ObjectLengthPipe, | 141 | ObjectLengthPipe, |
diff --git a/client/src/app/shared/users/user.service.ts b/client/src/app/shared/users/user.service.ts index bd5cd45d4..27a81f0a2 100644 --- a/client/src/app/shared/users/user.service.ts +++ b/client/src/app/shared/users/user.service.ts | |||
@@ -1,21 +1,27 @@ | |||
1 | import { Observable } from 'rxjs' | 1 | import { from, Observable } from 'rxjs' |
2 | import { catchError, map } from 'rxjs/operators' | 2 | import { catchError, concatMap, map, toArray } from 'rxjs/operators' |
3 | import { HttpClient, HttpParams } from '@angular/common/http' | 3 | import { HttpClient, HttpParams } from '@angular/common/http' |
4 | import { Injectable } from '@angular/core' | 4 | import { Injectable } from '@angular/core' |
5 | import { UserCreate, UserUpdateMe, UserVideoQuota } from '../../../../../shared' | 5 | import { ResultList, User, UserCreate, UserRole, UserUpdate, UserUpdateMe, UserVideoQuota } from '../../../../../shared' |
6 | import { environment } from '../../../environments/environment' | 6 | import { environment } from '../../../environments/environment' |
7 | import { RestExtractor } from '../rest' | 7 | import { RestExtractor, RestPagination, RestService } from '../rest' |
8 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' | 8 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' |
9 | import { SortMeta } from 'primeng/api' | ||
10 | import { BytesPipe } from 'ngx-pipes' | ||
11 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
9 | 12 | ||
10 | @Injectable() | 13 | @Injectable() |
11 | export class UserService { | 14 | export class UserService { |
12 | static BASE_USERS_URL = environment.apiUrl + '/api/v1/users/' | 15 | static BASE_USERS_URL = environment.apiUrl + '/api/v1/users/' |
13 | 16 | ||
17 | private bytesPipe = new BytesPipe() | ||
18 | |||
14 | constructor ( | 19 | constructor ( |
15 | private authHttp: HttpClient, | 20 | private authHttp: HttpClient, |
16 | private restExtractor: RestExtractor | 21 | private restExtractor: RestExtractor, |
17 | ) { | 22 | private restService: RestService, |
18 | } | 23 | private i18n: I18n |
24 | ) { } | ||
19 | 25 | ||
20 | changePassword (currentPassword: string, newPassword: string) { | 26 | changePassword (currentPassword: string, newPassword: string) { |
21 | const url = UserService.BASE_USERS_URL + 'me' | 27 | const url = UserService.BASE_USERS_URL + 'me' |
@@ -128,4 +134,98 @@ export class UserService { | |||
128 | .get<string[]>(url, { params }) | 134 | .get<string[]>(url, { params }) |
129 | .pipe(catchError(res => this.restExtractor.handleError(res))) | 135 | .pipe(catchError(res => this.restExtractor.handleError(res))) |
130 | } | 136 | } |
137 | |||
138 | /* ###### Admin methods ###### */ | ||
139 | |||
140 | addUser (userCreate: UserCreate) { | ||
141 | return this.authHttp.post(UserService.BASE_USERS_URL, userCreate) | ||
142 | .pipe( | ||
143 | map(this.restExtractor.extractDataBool), | ||
144 | catchError(err => this.restExtractor.handleError(err)) | ||
145 | ) | ||
146 | } | ||
147 | |||
148 | updateUser (userId: number, userUpdate: UserUpdate) { | ||
149 | return this.authHttp.put(UserService.BASE_USERS_URL + userId, userUpdate) | ||
150 | .pipe( | ||
151 | map(this.restExtractor.extractDataBool), | ||
152 | catchError(err => this.restExtractor.handleError(err)) | ||
153 | ) | ||
154 | } | ||
155 | |||
156 | getUser (userId: number) { | ||
157 | return this.authHttp.get<User>(UserService.BASE_USERS_URL + userId) | ||
158 | .pipe(catchError(err => this.restExtractor.handleError(err))) | ||
159 | } | ||
160 | |||
161 | getUsers (pagination: RestPagination, sort: SortMeta, search?: string): Observable<ResultList<User>> { | ||
162 | let params = new HttpParams() | ||
163 | params = this.restService.addRestGetParams(params, pagination, sort) | ||
164 | |||
165 | if (search) params = params.append('search', search) | ||
166 | |||
167 | return this.authHttp.get<ResultList<User>>(UserService.BASE_USERS_URL, { params }) | ||
168 | .pipe( | ||
169 | map(res => this.restExtractor.convertResultListDateToHuman(res)), | ||
170 | map(res => this.restExtractor.applyToResultListData(res, this.formatUser.bind(this))), | ||
171 | catchError(err => this.restExtractor.handleError(err)) | ||
172 | ) | ||
173 | } | ||
174 | |||
175 | removeUser (usersArg: User | User[]) { | ||
176 | const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] | ||
177 | |||
178 | return from(users) | ||
179 | .pipe( | ||
180 | concatMap(u => this.authHttp.delete(UserService.BASE_USERS_URL + u.id)), | ||
181 | toArray(), | ||
182 | catchError(err => this.restExtractor.handleError(err)) | ||
183 | ) | ||
184 | } | ||
185 | |||
186 | banUsers (usersArg: User | User[], reason?: string) { | ||
187 | const body = reason ? { reason } : {} | ||
188 | const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] | ||
189 | |||
190 | return from(users) | ||
191 | .pipe( | ||
192 | concatMap(u => this.authHttp.post(UserService.BASE_USERS_URL + u.id + '/block', body)), | ||
193 | toArray(), | ||
194 | catchError(err => this.restExtractor.handleError(err)) | ||
195 | ) | ||
196 | } | ||
197 | |||
198 | unbanUsers (usersArg: User | User[]) { | ||
199 | const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] | ||
200 | |||
201 | return from(users) | ||
202 | .pipe( | ||
203 | concatMap(u => this.authHttp.post(UserService.BASE_USERS_URL + u.id + '/unblock', {})), | ||
204 | toArray(), | ||
205 | catchError(err => this.restExtractor.handleError(err)) | ||
206 | ) | ||
207 | } | ||
208 | |||
209 | private formatUser (user: User) { | ||
210 | let videoQuota | ||
211 | if (user.videoQuota === -1) { | ||
212 | videoQuota = this.i18n('Unlimited') | ||
213 | } else { | ||
214 | videoQuota = this.bytesPipe.transform(user.videoQuota, 0) | ||
215 | } | ||
216 | |||
217 | const videoQuotaUsed = this.bytesPipe.transform(user.videoQuotaUsed, 0) | ||
218 | |||
219 | const roleLabels: { [ id in UserRole ]: string } = { | ||
220 | [UserRole.USER]: this.i18n('User'), | ||
221 | [UserRole.ADMINISTRATOR]: this.i18n('Administrator'), | ||
222 | [UserRole.MODERATOR]: this.i18n('Moderator') | ||
223 | } | ||
224 | |||
225 | return Object.assign(user, { | ||
226 | roleLabel: roleLabels[user.role], | ||
227 | videoQuota, | ||
228 | videoQuotaUsed | ||
229 | }) | ||
230 | } | ||
131 | } | 231 | } |
diff --git a/client/src/app/shared/video/abstract-video-list.html b/client/src/app/shared/video/abstract-video-list.html index d543ab7c1..69a619b76 100644 --- a/client/src/app/shared/video/abstract-video-list.html +++ b/client/src/app/shared/video/abstract-video-list.html | |||
@@ -1,8 +1,18 @@ | |||
1 | <div [ngClass]="{ 'margin-content': marginContent }"> | 1 | <div [ngClass]="{ 'margin-content': marginContent }"> |
2 | <div *ngIf="titlePage" class="title-page title-page-single"> | 2 | <div class="videos-header"> |
3 | {{ titlePage }} | 3 | <div *ngIf="titlePage" class="title-page title-page-single"> |
4 | {{ titlePage }} | ||
5 | </div> | ||
6 | <my-video-feed [syndicationItems]="syndicationItems"></my-video-feed> | ||
7 | |||
8 | <div class="moderation-block" *ngIf="displayModerationBlock"> | ||
9 | <my-peertube-checkbox | ||
10 | (change)="toggleModerationDisplay()" | ||
11 | inputName="display-unlisted-private" i18n-labelText labelText="Display unlisted and private videos" | ||
12 | > | ||
13 | </my-peertube-checkbox> | ||
14 | </div> | ||
4 | </div> | 15 | </div> |
5 | <my-video-feed [syndicationItems]="syndicationItems"></my-video-feed> | ||
6 | 16 | ||
7 | <div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div> | 17 | <div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div> |
8 | <div | 18 | <div |
diff --git a/client/src/app/shared/video/abstract-video-list.scss b/client/src/app/shared/video/abstract-video-list.scss index 3f9c73a29..92998cb44 100644 --- a/client/src/app/shared/video/abstract-video-list.scss +++ b/client/src/app/shared/video/abstract-video-list.scss | |||
@@ -8,12 +8,27 @@ | |||
8 | } | 8 | } |
9 | } | 9 | } |
10 | 10 | ||
11 | .title-page.title-page-single { | 11 | .videos-header { |
12 | margin-right: 5px; | 12 | display: flex; |
13 | } | 13 | height: 80px; |
14 | align-items: center; | ||
15 | |||
16 | .title-page.title-page-single { | ||
17 | margin: 0 5px 0 0; | ||
18 | } | ||
14 | 19 | ||
15 | my-video-feed { | 20 | my-video-feed { |
16 | display: inline-block; | 21 | display: inline-block; |
22 | position: relative; | ||
23 | top: 1px; | ||
24 | } | ||
25 | |||
26 | .moderation-block { | ||
27 | display: flex; | ||
28 | flex-grow: 1; | ||
29 | justify-content: flex-end; | ||
30 | align-items: center; | ||
31 | } | ||
17 | } | 32 | } |
18 | 33 | ||
19 | @media screen and (max-width: 500px) { | 34 | @media screen and (max-width: 500px) { |
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts index 6a758ebe0..1f43f974c 100644 --- a/client/src/app/shared/video/abstract-video-list.ts +++ b/client/src/app/shared/video/abstract-video-list.ts | |||
@@ -37,6 +37,7 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy { | |||
37 | videoPages: Video[][] = [] | 37 | videoPages: Video[][] = [] |
38 | ownerDisplayType: OwnerDisplayType = 'account' | 38 | ownerDisplayType: OwnerDisplayType = 'account' |
39 | firstLoadedPage: number | 39 | firstLoadedPage: number |
40 | displayModerationBlock = false | ||
40 | 41 | ||
41 | protected baseVideoWidth = 215 | 42 | protected baseVideoWidth = 215 |
42 | protected baseVideoHeight = 205 | 43 | protected baseVideoHeight = 205 |
@@ -83,7 +84,7 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy { | |||
83 | 84 | ||
84 | pageByVideoId (index: number, page: Video[]) { | 85 | pageByVideoId (index: number, page: Video[]) { |
85 | // Video are unique in all pages | 86 | // Video are unique in all pages |
86 | return page[0].id | 87 | return page.length !== 0 ? page[0].id : 0 |
87 | } | 88 | } |
88 | 89 | ||
89 | videoById (index: number, video: Video) { | 90 | videoById (index: number, video: Video) { |
@@ -160,6 +161,10 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy { | |||
160 | ) | 161 | ) |
161 | } | 162 | } |
162 | 163 | ||
164 | toggleModerationDisplay () { | ||
165 | throw new Error('toggleModerationDisplay is not implemented') | ||
166 | } | ||
167 | |||
163 | protected hasMoreVideos () { | 168 | protected hasMoreVideos () { |
164 | // No results | 169 | // No results |
165 | if (this.pagination.totalItems === 0) return false | 170 | if (this.pagination.totalItems === 0) return false |
diff --git a/client/src/app/shared/video/video-miniature.component.html b/client/src/app/shared/video/video-miniature.component.html index cfc483018..277a0cf35 100644 --- a/client/src/app/shared/video/video-miniature.component.html +++ b/client/src/app/shared/video/video-miniature.component.html | |||
@@ -8,6 +8,9 @@ | |||
8 | [routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }" | 8 | [routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }" |
9 | > | 9 | > |
10 | {{ video.name }} | 10 | {{ video.name }} |
11 | |||
12 | <span *ngIf="isUnlistedVideo()" class="badge badge-warning" i18n>Unlisted</span> | ||
13 | <span *ngIf="isPrivateVideo()" class="badge badge-danger" i18n>Private</span> | ||
11 | </a> | 14 | </a> |
12 | 15 | ||
13 | <span i18n class="video-miniature-created-at-views">{{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views</span> | 16 | <span i18n class="video-miniature-created-at-views">{{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views</span> |
diff --git a/client/src/app/shared/video/video-miniature.component.ts b/client/src/app/shared/video/video-miniature.component.ts index 7e8692b0b..2f951a1f1 100644 --- a/client/src/app/shared/video/video-miniature.component.ts +++ b/client/src/app/shared/video/video-miniature.component.ts | |||
@@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core | |||
2 | import { User } from '../users' | 2 | import { User } from '../users' |
3 | import { Video } from './video.model' | 3 | import { Video } from './video.model' |
4 | import { ServerService } from '@app/core' | 4 | import { ServerService } from '@app/core' |
5 | import { VideoPrivacy } from '../../../../../shared' | ||
5 | 6 | ||
6 | export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto' | 7 | export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto' |
7 | 8 | ||
@@ -49,4 +50,12 @@ export class VideoMiniatureComponent implements OnInit { | |||
49 | displayOwnerVideoChannel () { | 50 | displayOwnerVideoChannel () { |
50 | return this.ownerDisplayTypeChosen === 'videoChannel' | 51 | return this.ownerDisplayTypeChosen === 'videoChannel' |
51 | } | 52 | } |
53 | |||
54 | isUnlistedVideo () { | ||
55 | return this.video.privacy.id === VideoPrivacy.UNLISTED | ||
56 | } | ||
57 | |||
58 | isPrivateVideo () { | ||
59 | return this.video.privacy.id === VideoPrivacy.PRIVATE | ||
60 | } | ||
52 | } | 61 | } |
diff --git a/client/src/app/shared/video/video-thumbnail.component.html b/client/src/app/shared/video/video-thumbnail.component.html index c1d45ea18..d25666916 100644 --- a/client/src/app/shared/video/video-thumbnail.component.html +++ b/client/src/app/shared/video/video-thumbnail.component.html | |||
@@ -2,9 +2,11 @@ | |||
2 | [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name" | 2 | [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name" |
3 | class="video-thumbnail" | 3 | class="video-thumbnail" |
4 | > | 4 | > |
5 | <img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" /> | 5 | <img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" /> |
6 | 6 | ||
7 | <div class="video-thumbnail-overlay"> | 7 | <div class="video-thumbnail-overlay">{{ video.durationLabel }}</div> |
8 | {{ video.durationLabel }} | 8 | |
9 | </div> | 9 | <div class="progress-bar" *ngIf="video.userHistory?.currentTime"> |
10 | <div [ngStyle]="{ 'width.%': getProgressPercent() }"></div> | ||
11 | </div> | ||
10 | </a> | 12 | </a> |
diff --git a/client/src/app/shared/video/video-thumbnail.component.scss b/client/src/app/shared/video/video-thumbnail.component.scss index 1dd8e5338..4772edaf0 100644 --- a/client/src/app/shared/video/video-thumbnail.component.scss +++ b/client/src/app/shared/video/video-thumbnail.component.scss | |||
@@ -29,6 +29,19 @@ | |||
29 | } | 29 | } |
30 | } | 30 | } |
31 | 31 | ||
32 | .progress-bar { | ||
33 | height: 3px; | ||
34 | width: 100%; | ||
35 | position: relative; | ||
36 | top: -3px; | ||
37 | background-color: rgba(0, 0, 0, 0.20); | ||
38 | |||
39 | div { | ||
40 | height: 100%; | ||
41 | background-color: var(--mainColor); | ||
42 | } | ||
43 | } | ||
44 | |||
32 | .video-thumbnail-overlay { | 45 | .video-thumbnail-overlay { |
33 | position: absolute; | 46 | position: absolute; |
34 | right: 5px; | 47 | right: 5px; |
diff --git a/client/src/app/shared/video/video-thumbnail.component.ts b/client/src/app/shared/video/video-thumbnail.component.ts index 86d8f6f74..ca43700c7 100644 --- a/client/src/app/shared/video/video-thumbnail.component.ts +++ b/client/src/app/shared/video/video-thumbnail.component.ts | |||
@@ -22,4 +22,12 @@ export class VideoThumbnailComponent { | |||
22 | 22 | ||
23 | return this.video.thumbnailUrl | 23 | return this.video.thumbnailUrl |
24 | } | 24 | } |
25 | |||
26 | getProgressPercent () { | ||
27 | if (!this.video.userHistory) return 0 | ||
28 | |||
29 | const currentTime = this.video.userHistory.currentTime | ||
30 | |||
31 | return (currentTime / this.video.duration) * 100 | ||
32 | } | ||
25 | } | 33 | } |
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts index 80794faa6..b92c96450 100644 --- a/client/src/app/shared/video/video.model.ts +++ b/client/src/app/shared/video/video.model.ts | |||
@@ -66,6 +66,10 @@ export class Video implements VideoServerModel { | |||
66 | avatar: Avatar | 66 | avatar: Avatar |
67 | } | 67 | } |
68 | 68 | ||
69 | userHistory?: { | ||
70 | currentTime: number | ||
71 | } | ||
72 | |||
69 | static buildClientUrl (videoUUID: string) { | 73 | static buildClientUrl (videoUUID: string) { |
70 | return '/videos/watch/' + videoUUID | 74 | return '/videos/watch/' + videoUUID |
71 | } | 75 | } |
@@ -116,6 +120,8 @@ export class Video implements VideoServerModel { | |||
116 | 120 | ||
117 | this.blacklisted = hash.blacklisted | 121 | this.blacklisted = hash.blacklisted |
118 | this.blacklistedReason = hash.blacklistedReason | 122 | this.blacklistedReason = hash.blacklistedReason |
123 | |||
124 | this.userHistory = hash.userHistory | ||
119 | } | 125 | } |
120 | 126 | ||
121 | isVideoNSFWForUser (user: User, serverConfig: ServerConfig) { | 127 | isVideoNSFWForUser (user: User, serverConfig: ServerConfig) { |
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts index 2255a18a2..724a0bde9 100644 --- a/client/src/app/shared/video/video.service.ts +++ b/client/src/app/shared/video/video.service.ts | |||
@@ -58,6 +58,10 @@ export class VideoService implements VideosProvider { | |||
58 | return VideoService.BASE_VIDEO_URL + uuid + '/views' | 58 | return VideoService.BASE_VIDEO_URL + uuid + '/views' |
59 | } | 59 | } |
60 | 60 | ||
61 | getUserWatchingVideoUrl (uuid: string) { | ||
62 | return VideoService.BASE_VIDEO_URL + uuid + '/watching' | ||
63 | } | ||
64 | |||
61 | getVideo (uuid: string): Observable<VideoDetails> { | 65 | getVideo (uuid: string): Observable<VideoDetails> { |
62 | return this.serverService.localeObservable | 66 | return this.serverService.localeObservable |
63 | .pipe( | 67 | .pipe( |
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.scss b/client/src/app/videos/+video-edit/shared/video-edit.component.scss index b039d7ad4..25db8e8ed 100644 --- a/client/src/app/videos/+video-edit/shared/video-edit.component.scss +++ b/client/src/app/videos/+video-edit/shared/video-edit.component.scss | |||
@@ -5,6 +5,11 @@ | |||
5 | @include peertube-select-container(auto); | 5 | @include peertube-select-container(auto); |
6 | } | 6 | } |
7 | 7 | ||
8 | my-peertube-checkbox { | ||
9 | display: block; | ||
10 | margin-bottom: 1rem; | ||
11 | } | ||
12 | |||
8 | .video-edit { | 13 | .video-edit { |
9 | height: 100%; | 14 | height: 100%; |
10 | min-height: 300px; | 15 | min-height: 300px; |
diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts index ea10b22ad..c5deddf05 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.ts +++ b/client/src/app/videos/+video-watch/video-watch.component.ts | |||
@@ -369,7 +369,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
369 | ) | 369 | ) |
370 | } | 370 | } |
371 | 371 | ||
372 | private async onVideoFetched (video: VideoDetails, videoCaptions: VideoCaption[], startTime = 0) { | 372 | private async onVideoFetched (video: VideoDetails, videoCaptions: VideoCaption[], startTimeFromUrl: number) { |
373 | this.video = video | 373 | this.video = video |
374 | 374 | ||
375 | // Re init attributes | 375 | // Re init attributes |
@@ -377,6 +377,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
377 | this.completeDescriptionShown = false | 377 | this.completeDescriptionShown = false |
378 | this.remoteServerDown = false | 378 | this.remoteServerDown = false |
379 | 379 | ||
380 | let startTime = startTimeFromUrl || (this.video.userHistory ? this.video.userHistory.currentTime : 0) | ||
381 | // Don't start the video if we are at the end | ||
382 | if (this.video.duration - startTime <= 1) startTime = 0 | ||
383 | |||
380 | if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) { | 384 | if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) { |
381 | const res = await this.confirmService.confirm( | 385 | const res = await this.confirmService.confirm( |
382 | this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'), | 386 | this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'), |
@@ -414,7 +418,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
414 | poster: this.video.previewUrl, | 418 | poster: this.video.previewUrl, |
415 | startTime, | 419 | startTime, |
416 | theaterMode: true, | 420 | theaterMode: true, |
417 | language: this.localeId | 421 | language: this.localeId, |
422 | |||
423 | userWatching: this.user ? { | ||
424 | url: this.videoService.getUserWatchingVideoUrl(this.video.uuid), | ||
425 | authorizationHeader: this.authService.getRequestHeaderValue() | ||
426 | } : undefined | ||
418 | }) | 427 | }) |
419 | 428 | ||
420 | if (this.videojsLocaleLoaded === false) { | 429 | if (this.videojsLocaleLoaded === false) { |
diff --git a/client/src/app/videos/video-list/video-local.component.ts b/client/src/app/videos/video-list/video-local.component.ts index c91c639ca..9d000cf2e 100644 --- a/client/src/app/videos/video-list/video-local.component.ts +++ b/client/src/app/videos/video-list/video-local.component.ts | |||
@@ -10,6 +10,7 @@ import { VideoService } from '../../shared/video/video.service' | |||
10 | import { VideoFilter } from '../../../../../shared/models/videos/video-query.type' | 10 | import { VideoFilter } from '../../../../../shared/models/videos/video-query.type' |
11 | import { I18n } from '@ngx-translate/i18n-polyfill' | 11 | import { I18n } from '@ngx-translate/i18n-polyfill' |
12 | import { ScreenService } from '@app/shared/misc/screen.service' | 12 | import { ScreenService } from '@app/shared/misc/screen.service' |
13 | import { UserRight } from '../../../../../shared/models/users' | ||
13 | 14 | ||
14 | @Component({ | 15 | @Component({ |
15 | selector: 'my-videos-local', | 16 | selector: 'my-videos-local', |
@@ -40,6 +41,11 @@ export class VideoLocalComponent extends AbstractVideoList implements OnInit, On | |||
40 | ngOnInit () { | 41 | ngOnInit () { |
41 | super.ngOnInit() | 42 | super.ngOnInit() |
42 | 43 | ||
44 | if (this.authService.isLoggedIn()) { | ||
45 | const user = this.authService.getUser() | ||
46 | this.displayModerationBlock = user.hasRight(UserRight.SEE_ALL_VIDEOS) | ||
47 | } | ||
48 | |||
43 | this.generateSyndicationList() | 49 | this.generateSyndicationList() |
44 | } | 50 | } |
45 | 51 | ||
@@ -56,4 +62,10 @@ export class VideoLocalComponent extends AbstractVideoList implements OnInit, On | |||
56 | generateSyndicationList () { | 62 | generateSyndicationList () { |
57 | this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, this.filter, this.categoryOneOf) | 63 | this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, this.filter, this.categoryOneOf) |
58 | } | 64 | } |
65 | |||
66 | toggleModerationDisplay () { | ||
67 | this.filter = this.filter === 'local' ? 'all-local' as 'all-local' : 'local' as 'local' | ||
68 | |||
69 | this.reloadVideos() | ||
70 | } | ||
59 | } | 71 | } |
diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts index 1bf6c9267..792662b6c 100644 --- a/client/src/assets/player/peertube-player.ts +++ b/client/src/assets/player/peertube-player.ts | |||
@@ -10,7 +10,7 @@ import './webtorrent-info-button' | |||
10 | import './peertube-videojs-plugin' | 10 | import './peertube-videojs-plugin' |
11 | import './peertube-load-progress-bar' | 11 | import './peertube-load-progress-bar' |
12 | import './theater-button' | 12 | import './theater-button' |
13 | import { VideoJSCaption, videojsUntyped } from './peertube-videojs-typings' | 13 | import { UserWatching, VideoJSCaption, videojsUntyped } from './peertube-videojs-typings' |
14 | import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils' | 14 | import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils' |
15 | import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n' | 15 | import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n' |
16 | 16 | ||
@@ -34,10 +34,13 @@ function getVideojsOptions (options: { | |||
34 | startTime: number | string | 34 | startTime: number | string |
35 | theaterMode: boolean, | 35 | theaterMode: boolean, |
36 | videoCaptions: VideoJSCaption[], | 36 | videoCaptions: VideoJSCaption[], |
37 | |||
37 | language?: string, | 38 | language?: string, |
38 | controls?: boolean, | 39 | controls?: boolean, |
39 | muted?: boolean, | 40 | muted?: boolean, |
40 | loop?: boolean | 41 | loop?: boolean |
42 | |||
43 | userWatching?: UserWatching | ||
41 | }) { | 44 | }) { |
42 | const videojsOptions = { | 45 | const videojsOptions = { |
43 | // We don't use text track settings for now | 46 | // We don't use text track settings for now |
@@ -57,7 +60,8 @@ function getVideojsOptions (options: { | |||
57 | playerElement: options.playerElement, | 60 | playerElement: options.playerElement, |
58 | videoViewUrl: options.videoViewUrl, | 61 | videoViewUrl: options.videoViewUrl, |
59 | videoDuration: options.videoDuration, | 62 | videoDuration: options.videoDuration, |
60 | startTime: options.startTime | 63 | startTime: options.startTime, |
64 | userWatching: options.userWatching | ||
61 | } | 65 | } |
62 | }, | 66 | }, |
63 | controlBar: { | 67 | controlBar: { |
diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/peertube-videojs-plugin.ts index adc376e94..2330f476f 100644 --- a/client/src/assets/player/peertube-videojs-plugin.ts +++ b/client/src/assets/player/peertube-videojs-plugin.ts | |||
@@ -3,7 +3,7 @@ import * as WebTorrent from 'webtorrent' | |||
3 | import { VideoFile } from '../../../../shared/models/videos/video.model' | 3 | import { VideoFile } from '../../../../shared/models/videos/video.model' |
4 | import { renderVideo } from './video-renderer' | 4 | import { renderVideo } from './video-renderer' |
5 | import './settings-menu-button' | 5 | import './settings-menu-button' |
6 | import { PeertubePluginOptions, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' | 6 | import { PeertubePluginOptions, UserWatching, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' |
7 | import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from './utils' | 7 | import { isMobile, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from './utils' |
8 | import * as CacheChunkStore from 'cache-chunk-store' | 8 | import * as CacheChunkStore from 'cache-chunk-store' |
9 | import { PeertubeChunkStore } from './peertube-chunk-store' | 9 | import { PeertubeChunkStore } from './peertube-chunk-store' |
@@ -32,7 +32,8 @@ class PeerTubePlugin extends Plugin { | |||
32 | AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it | 32 | AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it |
33 | AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check | 33 | AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check |
34 | AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds | 34 | AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds |
35 | BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth | 35 | BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5, // Last 5 seconds to build average bandwidth |
36 | USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video | ||
36 | } | 37 | } |
37 | 38 | ||
38 | private readonly webtorrent = new WebTorrent({ | 39 | private readonly webtorrent = new WebTorrent({ |
@@ -67,6 +68,7 @@ class PeerTubePlugin extends Plugin { | |||
67 | private videoViewInterval | 68 | private videoViewInterval |
68 | private torrentInfoInterval | 69 | private torrentInfoInterval |
69 | private autoQualityInterval | 70 | private autoQualityInterval |
71 | private userWatchingVideoInterval | ||
70 | private addTorrentDelay | 72 | private addTorrentDelay |
71 | private qualityObservationTimer | 73 | private qualityObservationTimer |
72 | private runAutoQualitySchedulerTimer | 74 | private runAutoQualitySchedulerTimer |
@@ -100,6 +102,8 @@ class PeerTubePlugin extends Plugin { | |||
100 | this.runTorrentInfoScheduler() | 102 | this.runTorrentInfoScheduler() |
101 | this.runViewAdd() | 103 | this.runViewAdd() |
102 | 104 | ||
105 | if (options.userWatching) this.runUserWatchVideo(options.userWatching) | ||
106 | |||
103 | this.player.one('play', () => { | 107 | this.player.one('play', () => { |
104 | // Don't run immediately scheduler, wait some seconds the TCP connections are made | 108 | // Don't run immediately scheduler, wait some seconds the TCP connections are made |
105 | this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER) | 109 | this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER) |
@@ -121,6 +125,8 @@ class PeerTubePlugin extends Plugin { | |||
121 | clearInterval(this.torrentInfoInterval) | 125 | clearInterval(this.torrentInfoInterval) |
122 | clearInterval(this.autoQualityInterval) | 126 | clearInterval(this.autoQualityInterval) |
123 | 127 | ||
128 | if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval) | ||
129 | |||
124 | // Don't need to destroy renderer, video player will be destroyed | 130 | // Don't need to destroy renderer, video player will be destroyed |
125 | this.flushVideoFile(this.currentVideoFile, false) | 131 | this.flushVideoFile(this.currentVideoFile, false) |
126 | 132 | ||
@@ -524,6 +530,21 @@ class PeerTubePlugin extends Plugin { | |||
524 | }, 1000) | 530 | }, 1000) |
525 | } | 531 | } |
526 | 532 | ||
533 | private runUserWatchVideo (options: UserWatching) { | ||
534 | let lastCurrentTime = 0 | ||
535 | |||
536 | this.userWatchingVideoInterval = setInterval(() => { | ||
537 | const currentTime = Math.floor(this.player.currentTime()) | ||
538 | |||
539 | if (currentTime - lastCurrentTime >= 1) { | ||
540 | lastCurrentTime = currentTime | ||
541 | |||
542 | this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader) | ||
543 | .catch(err => console.error('Cannot notify user is watching.', err)) | ||
544 | } | ||
545 | }, this.CONSTANTS.USER_WATCHING_VIDEO_INTERVAL) | ||
546 | } | ||
547 | |||
527 | private clearVideoViewInterval () { | 548 | private clearVideoViewInterval () { |
528 | if (this.videoViewInterval !== undefined) { | 549 | if (this.videoViewInterval !== undefined) { |
529 | clearInterval(this.videoViewInterval) | 550 | clearInterval(this.videoViewInterval) |
@@ -537,6 +558,15 @@ class PeerTubePlugin extends Plugin { | |||
537 | return fetch(this.videoViewUrl, { method: 'POST' }) | 558 | return fetch(this.videoViewUrl, { method: 'POST' }) |
538 | } | 559 | } |
539 | 560 | ||
561 | private notifyUserIsWatching (currentTime: number, url: string, authorizationHeader: string) { | ||
562 | const body = new URLSearchParams() | ||
563 | body.append('currentTime', currentTime.toString()) | ||
564 | |||
565 | const headers = new Headers({ 'Authorization': authorizationHeader }) | ||
566 | |||
567 | return fetch(url, { method: 'PUT', body, headers }) | ||
568 | } | ||
569 | |||
540 | private fallbackToHttp (done?: Function, play = true) { | 570 | private fallbackToHttp (done?: Function, play = true) { |
541 | this.disableAutoResolution(true) | 571 | this.disableAutoResolution(true) |
542 | 572 | ||
diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts index 993d5ee6b..b117007af 100644 --- a/client/src/assets/player/peertube-videojs-typings.ts +++ b/client/src/assets/player/peertube-videojs-typings.ts | |||
@@ -22,6 +22,11 @@ type VideoJSCaption = { | |||
22 | src: string | 22 | src: string |
23 | } | 23 | } |
24 | 24 | ||
25 | type UserWatching = { | ||
26 | url: string, | ||
27 | authorizationHeader: string | ||
28 | } | ||
29 | |||
25 | type PeertubePluginOptions = { | 30 | type PeertubePluginOptions = { |
26 | videoFiles: VideoFile[] | 31 | videoFiles: VideoFile[] |
27 | playerElement: HTMLVideoElement | 32 | playerElement: HTMLVideoElement |
@@ -30,6 +35,8 @@ type PeertubePluginOptions = { | |||
30 | startTime: number | string | 35 | startTime: number | string |
31 | autoplay: boolean, | 36 | autoplay: boolean, |
32 | videoCaptions: VideoJSCaption[] | 37 | videoCaptions: VideoJSCaption[] |
38 | |||
39 | userWatching?: UserWatching | ||
33 | } | 40 | } |
34 | 41 | ||
35 | // videojs typings don't have some method we need | 42 | // videojs typings don't have some method we need |
@@ -39,5 +46,6 @@ export { | |||
39 | VideoJSComponentInterface, | 46 | VideoJSComponentInterface, |
40 | PeertubePluginOptions, | 47 | PeertubePluginOptions, |
41 | videojsUntyped, | 48 | videojsUntyped, |
42 | VideoJSCaption | 49 | VideoJSCaption, |
50 | UserWatching | ||
43 | } | 51 | } |
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss index 2efd6a1d3..b25d7ae0f 100644 --- a/client/src/sass/include/_mixins.scss +++ b/client/src/sass/include/_mixins.scss | |||
@@ -29,7 +29,7 @@ | |||
29 | display: block; | 29 | display: block; |
30 | /* Fallback for non-webkit */ | 30 | /* Fallback for non-webkit */ |
31 | display: -webkit-box; | 31 | display: -webkit-box; |
32 | max-height: $font-size*$line-height*$lines-to-show; | 32 | max-height: $font-size*$line-height*$lines-to-show + 0.2; |
33 | /* Fallback for non-webkit */ | 33 | /* Fallback for non-webkit */ |
34 | font-size: $font-size; | 34 | font-size: $font-size; |
35 | line-height: $line-height; | 35 | line-height: $line-height; |
diff --git a/client/src/sass/primeng-custom.scss b/client/src/sass/primeng-custom.scss index 5a03ac9c5..0568de4e2 100644 --- a/client/src/sass/primeng-custom.scss +++ b/client/src/sass/primeng-custom.scss | |||
@@ -14,8 +14,17 @@ | |||
14 | p-table { | 14 | p-table { |
15 | font-size: 15px !important; | 15 | font-size: 15px !important; |
16 | 16 | ||
17 | .ui-table-caption { | ||
18 | border: none; | ||
19 | |||
20 | .caption { | ||
21 | height: 40px; | ||
22 | display: flex; | ||
23 | align-items: center; | ||
24 | } | ||
25 | } | ||
26 | |||
17 | td { | 27 | td { |
18 | // border: 1px solid #E5E5E5 !important; | ||
19 | padding-left: 15px !important; | 28 | padding-left: 15px !important; |
20 | 29 | ||
21 | &:not(.action-cell) { | 30 | &:not(.action-cell) { |
@@ -28,6 +37,11 @@ p-table { | |||
28 | tr { | 37 | tr { |
29 | background-color: var(--mainBackgroundColor) !important; | 38 | background-color: var(--mainBackgroundColor) !important; |
30 | height: 46px; | 39 | height: 46px; |
40 | |||
41 | &.ui-state-highlight { | ||
42 | background-color:var(--submenuColor) !important; | ||
43 | color:var(--mainForegroundColor) !important; | ||
44 | } | ||
31 | } | 45 | } |
32 | 46 | ||
33 | .ui-table-tbody { | 47 | .ui-table-tbody { |
@@ -216,4 +230,32 @@ p-calendar .ui-datepicker { | |||
216 | @include glyphicon-light; | 230 | @include glyphicon-light; |
217 | } | 231 | } |
218 | } | 232 | } |
233 | } | ||
234 | |||
235 | .ui-chkbox-box { | ||
236 | &.ui-state-active { | ||
237 | border-color: var(--mainColor) !important; | ||
238 | background-color: var(--mainColor) !important; | ||
239 | } | ||
240 | |||
241 | .ui-chkbox-icon { | ||
242 | position: relative; | ||
243 | |||
244 | &:after { | ||
245 | content: ''; | ||
246 | position: absolute; | ||
247 | left: 5px; | ||
248 | width: 5px; | ||
249 | height: 12px; | ||
250 | opacity: 0; | ||
251 | transform: rotate(45deg) scale(0); | ||
252 | border-right: 2px solid var(--mainBackgroundColor); | ||
253 | border-bottom: 2px solid var(--mainBackgroundColor); | ||
254 | } | ||
255 | |||
256 | &.pi-check:after { | ||
257 | opacity: 1; | ||
258 | transform: rotate(45deg) scale(1); | ||
259 | } | ||
260 | } | ||
219 | } \ No newline at end of file | 261 | } \ No newline at end of file |
diff --git a/config/test.yaml b/config/test.yaml index 04c999966..9c051fabc 100644 --- a/config/test.yaml +++ b/config/test.yaml | |||
@@ -1,5 +1,5 @@ | |||
1 | listen: | 1 | listen: |
2 | listen: '0.0.0.0' | 2 | hostname: '0.0.0.0' |
3 | port: 9000 | 3 | port: 9000 |
4 | 4 | ||
5 | webserver: | 5 | webserver: |
diff --git a/package.json b/package.json index 5aaaa32a7..1fd6d7d19 100644 --- a/package.json +++ b/package.json | |||
@@ -51,6 +51,7 @@ | |||
51 | "generate-api-doc": "scripty", | 51 | "generate-api-doc": "scripty", |
52 | "parse-log": "node ./dist/scripts/parse-log.js", | 52 | "parse-log": "node ./dist/scripts/parse-log.js", |
53 | "prune-storage": "node ./dist/scripts/prune-storage.js", | 53 | "prune-storage": "node ./dist/scripts/prune-storage.js", |
54 | "optimize-old-videos": "node ./dist/scripts/optimize-old-videos.js", | ||
54 | "postinstall": "cd client && yarn install --pure-lockfile", | 55 | "postinstall": "cd client && yarn install --pure-lockfile", |
55 | "tsc": "tsc", | 56 | "tsc": "tsc", |
56 | "spectacle-docs": "node_modules/spectacle-docs/bin/spectacle.js", | 57 | "spectacle-docs": "node_modules/spectacle-docs/bin/spectacle.js", |
diff --git a/scripts/help.sh b/scripts/help.sh index 8ac090139..bc38bdb40 100755 --- a/scripts/help.sh +++ b/scripts/help.sh | |||
@@ -18,6 +18,7 @@ printf " reset-password -- -u [user] -> Reset the password of user [user]\n" | |||
18 | printf " create-transcoding-job -- -v [video UUID] \n" | 18 | printf " create-transcoding-job -- -v [video UUID] \n" |
19 | printf " -> Create a transcoding job for a particular video\n" | 19 | printf " -> Create a transcoding job for a particular video\n" |
20 | printf " prune-storage -> Delete (after confirmation) unknown video files/thumbnails/previews... (due to a bad video deletion, transcoding job not finished...)\n" | 20 | printf " prune-storage -> Delete (after confirmation) unknown video files/thumbnails/previews... (due to a bad video deletion, transcoding job not finished...)\n" |
21 | printf " optimize-old-videos -> Re-transcode videos that have a high bitrate, to make them suitable for streaming over slow connections" | ||
21 | printf " dev -> Watch, run the livereload and run the server so that you can develop the application\n" | 22 | printf " dev -> Watch, run the livereload and run the server so that you can develop the application\n" |
22 | printf " start -> Run the server\n" | 23 | printf " start -> Run the server\n" |
23 | printf " update-host -> Upgrade scheme/host in torrent files according to the webserver configuration (config/ folder)\n" | 24 | printf " update-host -> Upgrade scheme/host in torrent files according to the webserver configuration (config/ folder)\n" |
diff --git a/scripts/optimize-old-videos.ts b/scripts/optimize-old-videos.ts new file mode 100644 index 000000000..02026b3da --- /dev/null +++ b/scripts/optimize-old-videos.ts | |||
@@ -0,0 +1,35 @@ | |||
1 | import { VIDEO_TRANSCODING_FPS } from '../server/initializers/constants' | ||
2 | import { getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '../server/helpers/ffmpeg-utils' | ||
3 | import { getMaxBitrate } from '../shared/models/videos' | ||
4 | import { VideoModel } from '../server/models/video/video' | ||
5 | import { optimizeVideofile } from '../server/lib/video-transcoding' | ||
6 | |||
7 | run() | ||
8 | .then(() => process.exit(0)) | ||
9 | .catch(err => { | ||
10 | console.error(err) | ||
11 | process.exit(-1) | ||
12 | }) | ||
13 | |||
14 | async function run () { | ||
15 | const localVideos = await VideoModel.listLocal() | ||
16 | |||
17 | for (const video of localVideos) { | ||
18 | for (const file of video.VideoFiles) { | ||
19 | const inputPath = video.getVideoFilename(file) | ||
20 | |||
21 | const [ videoBitrate, fps, resolution ] = await Promise.all([ | ||
22 | getVideoFileBitrate(inputPath), | ||
23 | getVideoFileFPS(inputPath), | ||
24 | getVideoFileResolution(inputPath) | ||
25 | ]) | ||
26 | |||
27 | const isMaxBitrateExceeded = videoBitrate > getMaxBitrate(resolution.videoFileResolution, fps, VIDEO_TRANSCODING_FPS) | ||
28 | if (isMaxBitrateExceeded) { | ||
29 | await optimizeVideofile(video, file) | ||
30 | } | ||
31 | } | ||
32 | } | ||
33 | |||
34 | console.log('Finished optimizing videos') | ||
35 | } | ||
diff --git a/scripts/prune-storage.ts b/scripts/prune-storage.ts index 4088fa700..4ab0b4863 100755 --- a/scripts/prune-storage.ts +++ b/scripts/prune-storage.ts | |||
@@ -5,6 +5,7 @@ import { VideoModel } from '../server/models/video/video' | |||
5 | import { initDatabaseModels } from '../server/initializers' | 5 | import { initDatabaseModels } from '../server/initializers' |
6 | import { remove, readdir } from 'fs-extra' | 6 | import { remove, readdir } from 'fs-extra' |
7 | import { VideoRedundancyModel } from '../server/models/redundancy/video-redundancy' | 7 | import { VideoRedundancyModel } from '../server/models/redundancy/video-redundancy' |
8 | import { getUUIDFromFilename } from '../server/helpers/utils' | ||
8 | 9 | ||
9 | run() | 10 | run() |
10 | .then(() => process.exit(0)) | 11 | .then(() => process.exit(0)) |
@@ -82,15 +83,6 @@ async function pruneDirectory (directory: string, onlyOwned = false) { | |||
82 | return toDelete | 83 | return toDelete |
83 | } | 84 | } |
84 | 85 | ||
85 | function getUUIDFromFilename (filename: string) { | ||
86 | const regex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ | ||
87 | const result = filename.match(regex) | ||
88 | |||
89 | if (!result || Array.isArray(result) === false) return null | ||
90 | |||
91 | return result[0] | ||
92 | } | ||
93 | |||
94 | async function askConfirmation () { | 86 | async function askConfirmation () { |
95 | return new Promise((res, rej) => { | 87 | return new Promise((res, rej) => { |
96 | prompt.start() | 88 | prompt.start() |
diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts index 6229c44aa..433186179 100644 --- a/server/controllers/activitypub/client.ts +++ b/server/controllers/activitypub/client.ts | |||
@@ -13,8 +13,7 @@ import { | |||
13 | localVideoChannelValidator, | 13 | localVideoChannelValidator, |
14 | videosCustomGetValidator | 14 | videosCustomGetValidator |
15 | } from '../../middlewares' | 15 | } from '../../middlewares' |
16 | import { videosGetValidator, videosShareValidator } from '../../middlewares/validators' | 16 | import { videoCommentGetValidator, videosGetValidator, videosShareValidator } from '../../middlewares/validators' |
17 | import { videoCommentGetValidator } from '../../middlewares/validators/video-comments' | ||
18 | import { AccountModel } from '../../models/account/account' | 17 | import { AccountModel } from '../../models/account/account' |
19 | import { ActorModel } from '../../models/activitypub/actor' | 18 | import { ActorModel } from '../../models/activitypub/actor' |
20 | import { ActorFollowModel } from '../../models/activitypub/actor-follow' | 19 | import { ActorFollowModel } from '../../models/activitypub/actor-follow' |
diff --git a/server/controllers/api/accounts.ts b/server/controllers/api/accounts.ts index b7691ccba..8e3f60010 100644 --- a/server/controllers/api/accounts.ts +++ b/server/controllers/api/accounts.ts | |||
@@ -86,9 +86,11 @@ async function listAccountVideos (req: express.Request, res: express.Response, n | |||
86 | languageOneOf: req.query.languageOneOf, | 86 | languageOneOf: req.query.languageOneOf, |
87 | tagsOneOf: req.query.tagsOneOf, | 87 | tagsOneOf: req.query.tagsOneOf, |
88 | tagsAllOf: req.query.tagsAllOf, | 88 | tagsAllOf: req.query.tagsAllOf, |
89 | filter: req.query.filter, | ||
89 | nsfw: buildNSFWFilter(res, req.query.nsfw), | 90 | nsfw: buildNSFWFilter(res, req.query.nsfw), |
90 | withFiles: false, | 91 | withFiles: false, |
91 | accountId: account.id | 92 | accountId: account.id, |
93 | userId: res.locals.oauth ? res.locals.oauth.token.User.id : undefined | ||
92 | }) | 94 | }) |
93 | 95 | ||
94 | return res.json(getFormattedObjects(resultList.data, resultList.total)) | 96 | return res.json(getFormattedObjects(resultList.data, resultList.total)) |
diff --git a/server/controllers/api/search.ts b/server/controllers/api/search.ts index fd4db7a54..a8a6cfb08 100644 --- a/server/controllers/api/search.ts +++ b/server/controllers/api/search.ts | |||
@@ -117,7 +117,9 @@ function searchVideos (req: express.Request, res: express.Response) { | |||
117 | async function searchVideosDB (query: VideosSearchQuery, res: express.Response) { | 117 | async function searchVideosDB (query: VideosSearchQuery, res: express.Response) { |
118 | const options = Object.assign(query, { | 118 | const options = Object.assign(query, { |
119 | includeLocalVideos: true, | 119 | includeLocalVideos: true, |
120 | nsfw: buildNSFWFilter(res, query.nsfw) | 120 | nsfw: buildNSFWFilter(res, query.nsfw), |
121 | filter: query.filter, | ||
122 | userId: res.locals.oauth ? res.locals.oauth.token.User.id : undefined | ||
121 | }) | 123 | }) |
122 | const resultList = await VideoModel.searchAndPopulateAccountAndServer(options) | 124 | const resultList = await VideoModel.searchAndPopulateAccountAndServer(options) |
123 | 125 | ||
diff --git a/server/controllers/api/server/follows.ts b/server/controllers/api/server/follows.ts index d62400e42..9fa6c34ba 100644 --- a/server/controllers/api/server/follows.ts +++ b/server/controllers/api/server/follows.ts | |||
@@ -61,14 +61,26 @@ export { | |||
61 | 61 | ||
62 | async function listFollowing (req: express.Request, res: express.Response, next: express.NextFunction) { | 62 | async function listFollowing (req: express.Request, res: express.Response, next: express.NextFunction) { |
63 | const serverActor = await getServerActor() | 63 | const serverActor = await getServerActor() |
64 | const resultList = await ActorFollowModel.listFollowingForApi(serverActor.id, req.query.start, req.query.count, req.query.sort) | 64 | const resultList = await ActorFollowModel.listFollowingForApi( |
65 | serverActor.id, | ||
66 | req.query.start, | ||
67 | req.query.count, | ||
68 | req.query.sort, | ||
69 | req.query.search | ||
70 | ) | ||
65 | 71 | ||
66 | return res.json(getFormattedObjects(resultList.data, resultList.total)) | 72 | return res.json(getFormattedObjects(resultList.data, resultList.total)) |
67 | } | 73 | } |
68 | 74 | ||
69 | async function listFollowers (req: express.Request, res: express.Response, next: express.NextFunction) { | 75 | async function listFollowers (req: express.Request, res: express.Response, next: express.NextFunction) { |
70 | const serverActor = await getServerActor() | 76 | const serverActor = await getServerActor() |
71 | const resultList = await ActorFollowModel.listFollowersForApi(serverActor.id, req.query.start, req.query.count, req.query.sort) | 77 | const resultList = await ActorFollowModel.listFollowersForApi( |
78 | serverActor.id, | ||
79 | req.query.start, | ||
80 | req.query.count, | ||
81 | req.query.sort, | ||
82 | req.query.search | ||
83 | ) | ||
72 | 84 | ||
73 | return res.json(getFormattedObjects(resultList.data, resultList.total)) | 85 | return res.json(getFormattedObjects(resultList.data, resultList.total)) |
74 | } | 86 | } |
diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts index 0b0081520..4f8137c03 100644 --- a/server/controllers/api/users/index.ts +++ b/server/controllers/api/users/index.ts | |||
@@ -238,7 +238,7 @@ async function autocompleteUsers (req: express.Request, res: express.Response, n | |||
238 | } | 238 | } |
239 | 239 | ||
240 | async function listUsers (req: express.Request, res: express.Response, next: express.NextFunction) { | 240 | async function listUsers (req: express.Request, res: express.Response, next: express.NextFunction) { |
241 | const resultList = await UserModel.listForApi(req.query.start, req.query.count, req.query.sort) | 241 | const resultList = await UserModel.listForApi(req.query.start, req.query.count, req.query.sort, req.query.search) |
242 | 242 | ||
243 | return res.json(getFormattedObjects(resultList.data, resultList.total)) | 243 | return res.json(getFormattedObjects(resultList.data, resultList.total)) |
244 | } | 244 | } |
diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts index 1fa842d9c..c84d1be58 100644 --- a/server/controllers/api/video-channel.ts +++ b/server/controllers/api/video-channel.ts | |||
@@ -215,9 +215,11 @@ async function listVideoChannelVideos (req: express.Request, res: express.Respon | |||
215 | languageOneOf: req.query.languageOneOf, | 215 | languageOneOf: req.query.languageOneOf, |
216 | tagsOneOf: req.query.tagsOneOf, | 216 | tagsOneOf: req.query.tagsOneOf, |
217 | tagsAllOf: req.query.tagsAllOf, | 217 | tagsAllOf: req.query.tagsAllOf, |
218 | filter: req.query.filter, | ||
218 | nsfw: buildNSFWFilter(res, req.query.nsfw), | 219 | nsfw: buildNSFWFilter(res, req.query.nsfw), |
219 | withFiles: false, | 220 | withFiles: false, |
220 | videoChannelId: videoChannelInstance.id | 221 | videoChannelId: videoChannelInstance.id, |
222 | userId: res.locals.oauth ? res.locals.oauth.token.User.id : undefined | ||
221 | }) | 223 | }) |
222 | 224 | ||
223 | return res.json(getFormattedObjects(resultList.data, resultList.total)) | 225 | return res.json(getFormattedObjects(resultList.data, resultList.total)) |
diff --git a/server/controllers/api/videos/captions.ts b/server/controllers/api/videos/captions.ts index 4cf8de1ef..3ba918189 100644 --- a/server/controllers/api/videos/captions.ts +++ b/server/controllers/api/videos/captions.ts | |||
@@ -1,10 +1,6 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares' | 2 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares' |
3 | import { | 3 | import { addVideoCaptionValidator, deleteVideoCaptionValidator, listVideoCaptionsValidator } from '../../../middlewares/validators' |
4 | addVideoCaptionValidator, | ||
5 | deleteVideoCaptionValidator, | ||
6 | listVideoCaptionsValidator | ||
7 | } from '../../../middlewares/validators/video-captions' | ||
8 | import { createReqFiles } from '../../../helpers/express-utils' | 4 | import { createReqFiles } from '../../../helpers/express-utils' |
9 | import { CONFIG, sequelizeTypescript, VIDEO_CAPTIONS_MIMETYPE_EXT } from '../../../initializers' | 5 | import { CONFIG, sequelizeTypescript, VIDEO_CAPTIONS_MIMETYPE_EXT } from '../../../initializers' |
10 | import { getFormattedObjects } from '../../../helpers/utils' | 6 | import { getFormattedObjects } from '../../../helpers/utils' |
diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts index dc25e1e85..4f2b4faee 100644 --- a/server/controllers/api/videos/comment.ts +++ b/server/controllers/api/videos/comment.ts | |||
@@ -13,14 +13,14 @@ import { | |||
13 | setDefaultPagination, | 13 | setDefaultPagination, |
14 | setDefaultSort | 14 | setDefaultSort |
15 | } from '../../../middlewares' | 15 | } from '../../../middlewares' |
16 | import { videoCommentThreadsSortValidator } from '../../../middlewares/validators' | ||
17 | import { | 16 | import { |
18 | addVideoCommentReplyValidator, | 17 | addVideoCommentReplyValidator, |
19 | addVideoCommentThreadValidator, | 18 | addVideoCommentThreadValidator, |
20 | listVideoCommentThreadsValidator, | 19 | listVideoCommentThreadsValidator, |
21 | listVideoThreadCommentsValidator, | 20 | listVideoThreadCommentsValidator, |
22 | removeVideoCommentValidator | 21 | removeVideoCommentValidator, |
23 | } from '../../../middlewares/validators/video-comments' | 22 | videoCommentThreadsSortValidator |
23 | } from '../../../middlewares/validators' | ||
24 | import { VideoModel } from '../../../models/video/video' | 24 | import { VideoModel } from '../../../models/video/video' |
25 | import { VideoCommentModel } from '../../../models/video/video-comment' | 25 | import { VideoCommentModel } from '../../../models/video/video-comment' |
26 | import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger' | 26 | import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger' |
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 15ef8d458..6a73e13d0 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts | |||
@@ -57,6 +57,7 @@ import { videoCaptionsRouter } from './captions' | |||
57 | import { videoImportsRouter } from './import' | 57 | import { videoImportsRouter } from './import' |
58 | import { resetSequelizeInstance } from '../../../helpers/database-utils' | 58 | import { resetSequelizeInstance } from '../../../helpers/database-utils' |
59 | import { rename } from 'fs-extra' | 59 | import { rename } from 'fs-extra' |
60 | import { watchingRouter } from './watching' | ||
60 | 61 | ||
61 | const auditLogger = auditLoggerFactory('videos') | 62 | const auditLogger = auditLoggerFactory('videos') |
62 | const videosRouter = express.Router() | 63 | const videosRouter = express.Router() |
@@ -86,6 +87,7 @@ videosRouter.use('/', videoCommentRouter) | |||
86 | videosRouter.use('/', videoCaptionsRouter) | 87 | videosRouter.use('/', videoCaptionsRouter) |
87 | videosRouter.use('/', videoImportsRouter) | 88 | videosRouter.use('/', videoImportsRouter) |
88 | videosRouter.use('/', ownershipVideoRouter) | 89 | videosRouter.use('/', ownershipVideoRouter) |
90 | videosRouter.use('/', watchingRouter) | ||
89 | 91 | ||
90 | videosRouter.get('/categories', listVideoCategories) | 92 | videosRouter.get('/categories', listVideoCategories) |
91 | videosRouter.get('/licences', listVideoLicences) | 93 | videosRouter.get('/licences', listVideoLicences) |
@@ -119,6 +121,7 @@ videosRouter.get('/:id/description', | |||
119 | asyncMiddleware(getVideoDescription) | 121 | asyncMiddleware(getVideoDescription) |
120 | ) | 122 | ) |
121 | videosRouter.get('/:id', | 123 | videosRouter.get('/:id', |
124 | optionalAuthenticate, | ||
122 | asyncMiddleware(videosGetValidator), | 125 | asyncMiddleware(videosGetValidator), |
123 | getVideo | 126 | getVideo |
124 | ) | 127 | ) |
@@ -433,7 +436,8 @@ async function listVideos (req: express.Request, res: express.Response, next: ex | |||
433 | tagsAllOf: req.query.tagsAllOf, | 436 | tagsAllOf: req.query.tagsAllOf, |
434 | nsfw: buildNSFWFilter(res, req.query.nsfw), | 437 | nsfw: buildNSFWFilter(res, req.query.nsfw), |
435 | filter: req.query.filter as VideoFilter, | 438 | filter: req.query.filter as VideoFilter, |
436 | withFiles: false | 439 | withFiles: false, |
440 | userId: res.locals.oauth ? res.locals.oauth.token.User.id : undefined | ||
437 | }) | 441 | }) |
438 | 442 | ||
439 | return res.json(getFormattedObjects(resultList.data, resultList.total)) | 443 | return res.json(getFormattedObjects(resultList.data, resultList.total)) |
diff --git a/server/controllers/api/videos/watching.ts b/server/controllers/api/videos/watching.ts new file mode 100644 index 000000000..e8876b47a --- /dev/null +++ b/server/controllers/api/videos/watching.ts | |||
@@ -0,0 +1,36 @@ | |||
1 | import * as express from 'express' | ||
2 | import { UserWatchingVideo } from '../../../../shared' | ||
3 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoWatchingValidator } from '../../../middlewares' | ||
4 | import { UserVideoHistoryModel } from '../../../models/account/user-video-history' | ||
5 | import { UserModel } from '../../../models/account/user' | ||
6 | |||
7 | const watchingRouter = express.Router() | ||
8 | |||
9 | watchingRouter.put('/:videoId/watching', | ||
10 | authenticate, | ||
11 | asyncMiddleware(videoWatchingValidator), | ||
12 | asyncRetryTransactionMiddleware(userWatchVideo) | ||
13 | ) | ||
14 | |||
15 | // --------------------------------------------------------------------------- | ||
16 | |||
17 | export { | ||
18 | watchingRouter | ||
19 | } | ||
20 | |||
21 | // --------------------------------------------------------------------------- | ||
22 | |||
23 | async function userWatchVideo (req: express.Request, res: express.Response) { | ||
24 | const user = res.locals.oauth.token.User as UserModel | ||
25 | |||
26 | const body: UserWatchingVideo = req.body | ||
27 | const { id: videoId } = res.locals.video as { id: number } | ||
28 | |||
29 | await UserVideoHistoryModel.upsert({ | ||
30 | videoId, | ||
31 | userId: user.id, | ||
32 | currentTime: body.currentTime | ||
33 | }) | ||
34 | |||
35 | return res.type('json').status(204).end() | ||
36 | } | ||
diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts index b30ad8e8d..ccb9b6029 100644 --- a/server/controllers/feeds.ts +++ b/server/controllers/feeds.ts | |||
@@ -1,7 +1,14 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { CONFIG, FEEDS, ROUTE_CACHE_LIFETIME } from '../initializers/constants' | 2 | import { CONFIG, FEEDS, ROUTE_CACHE_LIFETIME } from '../initializers/constants' |
3 | import { THUMBNAILS_SIZE } from '../initializers' | 3 | import { THUMBNAILS_SIZE } from '../initializers' |
4 | import { asyncMiddleware, setDefaultSort, videoCommentsFeedsValidator, videoFeedsValidator, videosSortValidator } from '../middlewares' | 4 | import { |
5 | asyncMiddleware, | ||
6 | commonVideosFiltersValidator, | ||
7 | setDefaultSort, | ||
8 | videoCommentsFeedsValidator, | ||
9 | videoFeedsValidator, | ||
10 | videosSortValidator | ||
11 | } from '../middlewares' | ||
5 | import { VideoModel } from '../models/video/video' | 12 | import { VideoModel } from '../models/video/video' |
6 | import * as Feed from 'pfeed' | 13 | import * as Feed from 'pfeed' |
7 | import { AccountModel } from '../models/account/account' | 14 | import { AccountModel } from '../models/account/account' |
@@ -22,6 +29,7 @@ feedsRouter.get('/feeds/videos.:format', | |||
22 | videosSortValidator, | 29 | videosSortValidator, |
23 | setDefaultSort, | 30 | setDefaultSort, |
24 | asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS)), | 31 | asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS)), |
32 | commonVideosFiltersValidator, | ||
25 | asyncMiddleware(videoFeedsValidator), | 33 | asyncMiddleware(videoFeedsValidator), |
26 | asyncMiddleware(generateVideoFeed) | 34 | asyncMiddleware(generateVideoFeed) |
27 | ) | 35 | ) |
diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts index 9875c68bd..a13b09ac8 100644 --- a/server/helpers/custom-validators/videos.ts +++ b/server/helpers/custom-validators/videos.ts | |||
@@ -3,7 +3,7 @@ import 'express-validator' | |||
3 | import { values } from 'lodash' | 3 | import { values } from 'lodash' |
4 | import 'multer' | 4 | import 'multer' |
5 | import * as validator from 'validator' | 5 | import * as validator from 'validator' |
6 | import { UserRight, VideoPrivacy, VideoRateType } from '../../../shared' | 6 | import { UserRight, VideoFilter, VideoPrivacy, VideoRateType } from '../../../shared' |
7 | import { | 7 | import { |
8 | CONSTRAINTS_FIELDS, | 8 | CONSTRAINTS_FIELDS, |
9 | VIDEO_CATEGORIES, | 9 | VIDEO_CATEGORIES, |
@@ -22,6 +22,10 @@ import { fetchVideo, VideoFetchType } from '../video' | |||
22 | 22 | ||
23 | const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS | 23 | const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS |
24 | 24 | ||
25 | function isVideoFilterValid (filter: VideoFilter) { | ||
26 | return filter === 'local' || filter === 'all-local' | ||
27 | } | ||
28 | |||
25 | function isVideoCategoryValid (value: any) { | 29 | function isVideoCategoryValid (value: any) { |
26 | return value === null || VIDEO_CATEGORIES[ value ] !== undefined | 30 | return value === null || VIDEO_CATEGORIES[ value ] !== undefined |
27 | } | 31 | } |
@@ -154,7 +158,9 @@ function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: Use | |||
154 | } | 158 | } |
155 | 159 | ||
156 | async function isVideoExist (id: string, res: Response, fetchType: VideoFetchType = 'all') { | 160 | async function isVideoExist (id: string, res: Response, fetchType: VideoFetchType = 'all') { |
157 | const video = await fetchVideo(id, fetchType) | 161 | const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined |
162 | |||
163 | const video = await fetchVideo(id, fetchType, userId) | ||
158 | 164 | ||
159 | if (video === null) { | 165 | if (video === null) { |
160 | res.status(404) | 166 | res.status(404) |
@@ -223,5 +229,6 @@ export { | |||
223 | isVideoExist, | 229 | isVideoExist, |
224 | isVideoImage, | 230 | isVideoImage, |
225 | isVideoChannelOfAccountExist, | 231 | isVideoChannelOfAccountExist, |
226 | isVideoSupportValid | 232 | isVideoSupportValid, |
233 | isVideoFilterValid | ||
227 | } | 234 | } |
diff --git a/server/helpers/express-utils.ts b/server/helpers/express-utils.ts index 8a9cee8c5..162fe2244 100644 --- a/server/helpers/express-utils.ts +++ b/server/helpers/express-utils.ts | |||
@@ -2,7 +2,6 @@ import * as express from 'express' | |||
2 | import * as multer from 'multer' | 2 | import * as multer from 'multer' |
3 | import { CONFIG, REMOTE_SCHEME } from '../initializers' | 3 | import { CONFIG, REMOTE_SCHEME } from '../initializers' |
4 | import { logger } from './logger' | 4 | import { logger } from './logger' |
5 | import { User } from '../../shared/models/users' | ||
6 | import { deleteFileAsync, generateRandomString } from './utils' | 5 | import { deleteFileAsync, generateRandomString } from './utils' |
7 | import { extname } from 'path' | 6 | import { extname } from 'path' |
8 | import { isArray } from './custom-validators/misc' | 7 | import { isArray } from './custom-validators/misc' |
@@ -101,7 +100,7 @@ function createReqFiles ( | |||
101 | } | 100 | } |
102 | 101 | ||
103 | function isUserAbleToSearchRemoteURI (res: express.Response) { | 102 | function isUserAbleToSearchRemoteURI (res: express.Response) { |
104 | const user: User = res.locals.oauth ? res.locals.oauth.token.User : undefined | 103 | const user: UserModel = res.locals.oauth ? res.locals.oauth.token.User : undefined |
105 | 104 | ||
106 | return CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true || | 105 | return CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true || |
107 | (CONFIG.SEARCH.REMOTE_URI.USERS === true && user !== undefined) | 106 | (CONFIG.SEARCH.REMOTE_URI.USERS === true && user !== undefined) |
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts index 22bc25476..a964abdd4 100644 --- a/server/helpers/ffmpeg-utils.ts +++ b/server/helpers/ffmpeg-utils.ts | |||
@@ -1,6 +1,6 @@ | |||
1 | import * as ffmpeg from 'fluent-ffmpeg' | 1 | import * as ffmpeg from 'fluent-ffmpeg' |
2 | import { join } from 'path' | 2 | import { join } from 'path' |
3 | import { VideoResolution } from '../../shared/models/videos' | 3 | import { VideoResolution, getTargetBitrate } from '../../shared/models/videos' |
4 | import { CONFIG, FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers' | 4 | import { CONFIG, FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers' |
5 | import { processImage } from './image-utils' | 5 | import { processImage } from './image-utils' |
6 | import { logger } from './logger' | 6 | import { logger } from './logger' |
@@ -55,6 +55,16 @@ async function getVideoFileFPS (path: string) { | |||
55 | return 0 | 55 | return 0 |
56 | } | 56 | } |
57 | 57 | ||
58 | async function getVideoFileBitrate (path: string) { | ||
59 | return new Promise<number>((res, rej) => { | ||
60 | ffmpeg.ffprobe(path, (err, metadata) => { | ||
61 | if (err) return rej(err) | ||
62 | |||
63 | return res(metadata.format.bit_rate) | ||
64 | }) | ||
65 | }) | ||
66 | } | ||
67 | |||
58 | function getDurationFromVideoFile (path: string) { | 68 | function getDurationFromVideoFile (path: string) { |
59 | return new Promise<number>((res, rej) => { | 69 | return new Promise<number>((res, rej) => { |
60 | ffmpeg.ffprobe(path, (err, metadata) => { | 70 | ffmpeg.ffprobe(path, (err, metadata) => { |
@@ -138,6 +148,12 @@ function transcode (options: TranscodeOptions) { | |||
138 | command = command.withFPS(fps) | 148 | command = command.withFPS(fps) |
139 | } | 149 | } |
140 | 150 | ||
151 | // Constrained Encoding (VBV) | ||
152 | // https://slhck.info/video/2017/03/01/rate-control.html | ||
153 | // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate | ||
154 | const targetBitrate = getTargetBitrate(options.resolution, fps, VIDEO_TRANSCODING_FPS) | ||
155 | command.outputOptions([`-maxrate ${ targetBitrate }`, `-bufsize ${ targetBitrate * 2 }`]) | ||
156 | |||
141 | command | 157 | command |
142 | .on('error', (err, stdout, stderr) => { | 158 | .on('error', (err, stdout, stderr) => { |
143 | logger.error('Error in transcoding job.', { stdout, stderr }) | 159 | logger.error('Error in transcoding job.', { stdout, stderr }) |
@@ -157,7 +173,8 @@ export { | |||
157 | transcode, | 173 | transcode, |
158 | getVideoFileFPS, | 174 | getVideoFileFPS, |
159 | computeResolutionsToTranscode, | 175 | computeResolutionsToTranscode, |
160 | audio | 176 | audio, |
177 | getVideoFileBitrate | ||
161 | } | 178 | } |
162 | 179 | ||
163 | // --------------------------------------------------------------------------- | 180 | // --------------------------------------------------------------------------- |
diff --git a/server/helpers/utils.ts b/server/helpers/utils.ts index 6228fec04..39afb4e7b 100644 --- a/server/helpers/utils.ts +++ b/server/helpers/utils.ts | |||
@@ -77,6 +77,20 @@ async function getVersion () { | |||
77 | return require('../../../package.json').version | 77 | return require('../../../package.json').version |
78 | } | 78 | } |
79 | 79 | ||
80 | /** | ||
81 | * From a filename like "ede4cba5-742b-46fa-a388-9a6eb3a3aeb3.mp4", returns | ||
82 | * only the "ede4cba5-742b-46fa-a388-9a6eb3a3aeb3" part. If the filename does | ||
83 | * not contain a UUID, returns null. | ||
84 | */ | ||
85 | function getUUIDFromFilename (filename: string) { | ||
86 | const regex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ | ||
87 | const result = filename.match(regex) | ||
88 | |||
89 | if (!result || Array.isArray(result) === false) return null | ||
90 | |||
91 | return result[0] | ||
92 | } | ||
93 | |||
80 | // --------------------------------------------------------------------------- | 94 | // --------------------------------------------------------------------------- |
81 | 95 | ||
82 | export { | 96 | export { |
@@ -86,5 +100,6 @@ export { | |||
86 | getSecureTorrentName, | 100 | getSecureTorrentName, |
87 | getServerActor, | 101 | getServerActor, |
88 | getVersion, | 102 | getVersion, |
89 | generateVideoTmpPath | 103 | generateVideoTmpPath, |
104 | getUUIDFromFilename | ||
90 | } | 105 | } |
diff --git a/server/helpers/video.ts b/server/helpers/video.ts index b1577a6b0..1bd21467d 100644 --- a/server/helpers/video.ts +++ b/server/helpers/video.ts | |||
@@ -2,8 +2,8 @@ import { VideoModel } from '../models/video/video' | |||
2 | 2 | ||
3 | type VideoFetchType = 'all' | 'only-video' | 'id' | 'none' | 3 | type VideoFetchType = 'all' | 'only-video' | 'id' | 'none' |
4 | 4 | ||
5 | function fetchVideo (id: number | string, fetchType: VideoFetchType) { | 5 | function fetchVideo (id: number | string, fetchType: VideoFetchType, userId?: number) { |
6 | if (fetchType === 'all') return VideoModel.loadAndPopulateAccountAndServerAndTags(id) | 6 | if (fetchType === 'all') return VideoModel.loadAndPopulateAccountAndServerAndTags(id, undefined, userId) |
7 | 7 | ||
8 | if (fetchType === 'only-video') return VideoModel.load(id) | 8 | if (fetchType === 'only-video') return VideoModel.load(id) |
9 | 9 | ||
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 1a3b52015..a3e5f5dd2 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts | |||
@@ -3,7 +3,7 @@ import { dirname, join } from 'path' | |||
3 | import { JobType, VideoRateType, VideoState, VideosRedundancy } from '../../shared/models' | 3 | import { JobType, VideoRateType, VideoState, VideosRedundancy } from '../../shared/models' |
4 | import { ActivityPubActorType } from '../../shared/models/activitypub' | 4 | import { ActivityPubActorType } from '../../shared/models/activitypub' |
5 | import { FollowState } from '../../shared/models/actors' | 5 | import { FollowState } from '../../shared/models/actors' |
6 | import { VideoAbuseState, VideoImportState, VideoPrivacy } from '../../shared/models/videos' | 6 | import { VideoAbuseState, VideoImportState, VideoPrivacy, VideoTranscodingFPS } from '../../shared/models/videos' |
7 | // Do not use barrels, remain constants as independent as possible | 7 | // Do not use barrels, remain constants as independent as possible |
8 | import { buildPath, isTestInstance, parseDuration, root, sanitizeHost, sanitizeUrl } from '../helpers/core-utils' | 8 | import { buildPath, isTestInstance, parseDuration, root, sanitizeHost, sanitizeUrl } from '../helpers/core-utils' |
9 | import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type' | 9 | import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type' |
@@ -393,7 +393,7 @@ const RATES_LIMIT = { | |||
393 | } | 393 | } |
394 | 394 | ||
395 | let VIDEO_VIEW_LIFETIME = 60000 * 60 // 1 hour | 395 | let VIDEO_VIEW_LIFETIME = 60000 * 60 // 1 hour |
396 | const VIDEO_TRANSCODING_FPS = { | 396 | const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = { |
397 | MIN: 10, | 397 | MIN: 10, |
398 | AVERAGE: 30, | 398 | AVERAGE: 30, |
399 | MAX: 60, | 399 | MAX: 60, |
diff --git a/server/initializers/database.ts b/server/initializers/database.ts index 4d57bf8aa..482c03b31 100644 --- a/server/initializers/database.ts +++ b/server/initializers/database.ts | |||
@@ -28,6 +28,7 @@ import { VideoImportModel } from '../models/video/video-import' | |||
28 | import { VideoViewModel } from '../models/video/video-views' | 28 | import { VideoViewModel } from '../models/video/video-views' |
29 | import { VideoChangeOwnershipModel } from '../models/video/video-change-ownership' | 29 | import { VideoChangeOwnershipModel } from '../models/video/video-change-ownership' |
30 | import { VideoRedundancyModel } from '../models/redundancy/video-redundancy' | 30 | import { VideoRedundancyModel } from '../models/redundancy/video-redundancy' |
31 | import { UserVideoHistoryModel } from '../models/account/user-video-history' | ||
31 | 32 | ||
32 | require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string | 33 | require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string |
33 | 34 | ||
@@ -89,7 +90,8 @@ async function initDatabaseModels (silent: boolean) { | |||
89 | ScheduleVideoUpdateModel, | 90 | ScheduleVideoUpdateModel, |
90 | VideoImportModel, | 91 | VideoImportModel, |
91 | VideoViewModel, | 92 | VideoViewModel, |
92 | VideoRedundancyModel | 93 | VideoRedundancyModel, |
94 | UserVideoHistoryModel | ||
93 | ]) | 95 | ]) |
94 | 96 | ||
95 | // Check extensions exist in the database | 97 | // Check extensions exist in the database |
diff --git a/server/lib/activitypub/crawl.ts b/server/lib/activitypub/crawl.ts index 55912341c..db9ce3293 100644 --- a/server/lib/activitypub/crawl.ts +++ b/server/lib/activitypub/crawl.ts | |||
@@ -1,7 +1,7 @@ | |||
1 | import { ACTIVITY_PUB, JOB_REQUEST_TIMEOUT } from '../../initializers' | 1 | import { ACTIVITY_PUB, JOB_REQUEST_TIMEOUT } from '../../initializers' |
2 | import { doRequest } from '../../helpers/requests' | 2 | import { doRequest } from '../../helpers/requests' |
3 | import { logger } from '../../helpers/logger' | 3 | import { logger } from '../../helpers/logger' |
4 | import Bluebird = require('bluebird') | 4 | import * as Bluebird from 'bluebird' |
5 | 5 | ||
6 | async function crawlCollectionPage <T> (uri: string, handler: (items: T[]) => Promise<any> | Bluebird<any>) { | 6 | async function crawlCollectionPage <T> (uri: string, handler: (items: T[]) => Promise<any> | Bluebird<any>) { |
7 | logger.info('Crawling ActivityPub data on %s.', uri) | 7 | logger.info('Crawling ActivityPub data on %s.', uri) |
diff --git a/server/lib/job-queue/handlers/video-file.ts b/server/lib/job-queue/handlers/video-file.ts index 1463c93fc..adc0a2a15 100644 --- a/server/lib/job-queue/handlers/video-file.ts +++ b/server/lib/job-queue/handlers/video-file.ts | |||
@@ -8,7 +8,7 @@ import { retryTransactionWrapper } from '../../../helpers/database-utils' | |||
8 | import { sequelizeTypescript } from '../../../initializers' | 8 | import { sequelizeTypescript } from '../../../initializers' |
9 | import * as Bluebird from 'bluebird' | 9 | import * as Bluebird from 'bluebird' |
10 | import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils' | 10 | import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils' |
11 | import { importVideoFile, transcodeOriginalVideofile, optimizeOriginalVideofile } from '../../video-transcoding' | 11 | import { importVideoFile, transcodeOriginalVideofile, optimizeVideofile } from '../../video-transcoding' |
12 | 12 | ||
13 | export type VideoFilePayload = { | 13 | export type VideoFilePayload = { |
14 | videoUUID: string | 14 | videoUUID: string |
@@ -56,7 +56,7 @@ async function processVideoFile (job: Bull.Job) { | |||
56 | 56 | ||
57 | await retryTransactionWrapper(onVideoFileTranscoderOrImportSuccess, video) | 57 | await retryTransactionWrapper(onVideoFileTranscoderOrImportSuccess, video) |
58 | } else { | 58 | } else { |
59 | await optimizeOriginalVideofile(video) | 59 | await optimizeVideofile(video) |
60 | 60 | ||
61 | await retryTransactionWrapper(onVideoFileOptimizerSuccess, video, payload.isNewVideo) | 61 | await retryTransactionWrapper(onVideoFileOptimizerSuccess, video, payload.isNewVideo) |
62 | } | 62 | } |
diff --git a/server/lib/redis.ts b/server/lib/redis.ts index e4e435659..abd75d512 100644 --- a/server/lib/redis.ts +++ b/server/lib/redis.ts | |||
@@ -48,6 +48,8 @@ class Redis { | |||
48 | ) | 48 | ) |
49 | } | 49 | } |
50 | 50 | ||
51 | /************* Forgot password *************/ | ||
52 | |||
51 | async setResetPasswordVerificationString (userId: number) { | 53 | async setResetPasswordVerificationString (userId: number) { |
52 | const generatedString = await generateRandomString(32) | 54 | const generatedString = await generateRandomString(32) |
53 | 55 | ||
@@ -60,6 +62,8 @@ class Redis { | |||
60 | return this.getValue(this.generateResetPasswordKey(userId)) | 62 | return this.getValue(this.generateResetPasswordKey(userId)) |
61 | } | 63 | } |
62 | 64 | ||
65 | /************* Email verification *************/ | ||
66 | |||
63 | async setVerifyEmailVerificationString (userId: number) { | 67 | async setVerifyEmailVerificationString (userId: number) { |
64 | const generatedString = await generateRandomString(32) | 68 | const generatedString = await generateRandomString(32) |
65 | 69 | ||
@@ -72,16 +76,20 @@ class Redis { | |||
72 | return this.getValue(this.generateVerifyEmailKey(userId)) | 76 | return this.getValue(this.generateVerifyEmailKey(userId)) |
73 | } | 77 | } |
74 | 78 | ||
79 | /************* Views per IP *************/ | ||
80 | |||
75 | setIPVideoView (ip: string, videoUUID: string) { | 81 | setIPVideoView (ip: string, videoUUID: string) { |
76 | return this.setValue(this.buildViewKey(ip, videoUUID), '1', VIDEO_VIEW_LIFETIME) | 82 | return this.setValue(this.generateViewKey(ip, videoUUID), '1', VIDEO_VIEW_LIFETIME) |
77 | } | 83 | } |
78 | 84 | ||
79 | async isVideoIPViewExists (ip: string, videoUUID: string) { | 85 | async isVideoIPViewExists (ip: string, videoUUID: string) { |
80 | return this.exists(this.buildViewKey(ip, videoUUID)) | 86 | return this.exists(this.generateViewKey(ip, videoUUID)) |
81 | } | 87 | } |
82 | 88 | ||
89 | /************* API cache *************/ | ||
90 | |||
83 | async getCachedRoute (req: express.Request) { | 91 | async getCachedRoute (req: express.Request) { |
84 | const cached = await this.getObject(this.buildCachedRouteKey(req)) | 92 | const cached = await this.getObject(this.generateCachedRouteKey(req)) |
85 | 93 | ||
86 | return cached as CachedRoute | 94 | return cached as CachedRoute |
87 | } | 95 | } |
@@ -94,9 +102,11 @@ class Redis { | |||
94 | (statusCode) ? { statusCode: statusCode.toString() } : null | 102 | (statusCode) ? { statusCode: statusCode.toString() } : null |
95 | ) | 103 | ) |
96 | 104 | ||
97 | return this.setObject(this.buildCachedRouteKey(req), cached, lifetime) | 105 | return this.setObject(this.generateCachedRouteKey(req), cached, lifetime) |
98 | } | 106 | } |
99 | 107 | ||
108 | /************* Video views *************/ | ||
109 | |||
100 | addVideoView (videoId: number) { | 110 | addVideoView (videoId: number) { |
101 | const keyIncr = this.generateVideoViewKey(videoId) | 111 | const keyIncr = this.generateVideoViewKey(videoId) |
102 | const keySet = this.generateVideosViewKey() | 112 | const keySet = this.generateVideosViewKey() |
@@ -131,33 +141,37 @@ class Redis { | |||
131 | ]) | 141 | ]) |
132 | } | 142 | } |
133 | 143 | ||
134 | generateVideosViewKey (hour?: number) { | 144 | /************* Keys generation *************/ |
145 | |||
146 | generateCachedRouteKey (req: express.Request) { | ||
147 | return req.method + '-' + req.originalUrl | ||
148 | } | ||
149 | |||
150 | private generateVideosViewKey (hour?: number) { | ||
135 | if (!hour) hour = new Date().getHours() | 151 | if (!hour) hour = new Date().getHours() |
136 | 152 | ||
137 | return `videos-view-h${hour}` | 153 | return `videos-view-h${hour}` |
138 | } | 154 | } |
139 | 155 | ||
140 | generateVideoViewKey (videoId: number, hour?: number) { | 156 | private generateVideoViewKey (videoId: number, hour?: number) { |
141 | if (!hour) hour = new Date().getHours() | 157 | if (!hour) hour = new Date().getHours() |
142 | 158 | ||
143 | return `video-view-${videoId}-h${hour}` | 159 | return `video-view-${videoId}-h${hour}` |
144 | } | 160 | } |
145 | 161 | ||
146 | generateResetPasswordKey (userId: number) { | 162 | private generateResetPasswordKey (userId: number) { |
147 | return 'reset-password-' + userId | 163 | return 'reset-password-' + userId |
148 | } | 164 | } |
149 | 165 | ||
150 | generateVerifyEmailKey (userId: number) { | 166 | private generateVerifyEmailKey (userId: number) { |
151 | return 'verify-email-' + userId | 167 | return 'verify-email-' + userId |
152 | } | 168 | } |
153 | 169 | ||
154 | buildViewKey (ip: string, videoUUID: string) { | 170 | private generateViewKey (ip: string, videoUUID: string) { |
155 | return videoUUID + '-' + ip | 171 | return videoUUID + '-' + ip |
156 | } | 172 | } |
157 | 173 | ||
158 | buildCachedRouteKey (req: express.Request) { | 174 | /************* Redis helpers *************/ |
159 | return req.method + '-' + req.originalUrl | ||
160 | } | ||
161 | 175 | ||
162 | private getValue (key: string) { | 176 | private getValue (key: string) { |
163 | return new Promise<string>((res, rej) => { | 177 | return new Promise<string>((res, rej) => { |
@@ -197,6 +211,12 @@ class Redis { | |||
197 | }) | 211 | }) |
198 | } | 212 | } |
199 | 213 | ||
214 | private deleteFieldInHash (key: string, field: string) { | ||
215 | return new Promise<void>((res, rej) => { | ||
216 | this.client.hdel(this.prefix + key, field, err => err ? rej(err) : res()) | ||
217 | }) | ||
218 | } | ||
219 | |||
200 | private setValue (key: string, value: string, expirationMilliseconds: number) { | 220 | private setValue (key: string, value: string, expirationMilliseconds: number) { |
201 | return new Promise<void>((res, rej) => { | 221 | return new Promise<void>((res, rej) => { |
202 | this.client.set(this.prefix + key, value, 'PX', expirationMilliseconds, (err, ok) => { | 222 | this.client.set(this.prefix + key, value, 'PX', expirationMilliseconds, (err, ok) => { |
@@ -235,6 +255,16 @@ class Redis { | |||
235 | }) | 255 | }) |
236 | } | 256 | } |
237 | 257 | ||
258 | private setValueInHash (key: string, field: string, value: string) { | ||
259 | return new Promise<void>((res, rej) => { | ||
260 | this.client.hset(this.prefix + key, field, value, (err) => { | ||
261 | if (err) return rej(err) | ||
262 | |||
263 | return res() | ||
264 | }) | ||
265 | }) | ||
266 | } | ||
267 | |||
238 | private increment (key: string) { | 268 | private increment (key: string) { |
239 | return new Promise<number>((res, rej) => { | 269 | return new Promise<number>((res, rej) => { |
240 | this.client.incr(this.prefix + key, (err, value) => { | 270 | this.client.incr(this.prefix + key, (err, value) => { |
diff --git a/server/lib/video-transcoding.ts b/server/lib/video-transcoding.ts index bf3ff78c2..a78de61e5 100644 --- a/server/lib/video-transcoding.ts +++ b/server/lib/video-transcoding.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import { CONFIG } from '../initializers' | 1 | import { CONFIG } from '../initializers' |
2 | import { join, extname } from 'path' | 2 | import { extname, join } from 'path' |
3 | import { getVideoFileFPS, getVideoFileResolution, transcode } from '../helpers/ffmpeg-utils' | 3 | import { getVideoFileFPS, getVideoFileResolution, transcode } from '../helpers/ffmpeg-utils' |
4 | import { copy, remove, rename, stat } from 'fs-extra' | 4 | import { copy, remove, rename, stat } from 'fs-extra' |
5 | import { logger } from '../helpers/logger' | 5 | import { logger } from '../helpers/logger' |
@@ -7,10 +7,11 @@ import { VideoResolution } from '../../shared/models/videos' | |||
7 | import { VideoFileModel } from '../models/video/video-file' | 7 | import { VideoFileModel } from '../models/video/video-file' |
8 | import { VideoModel } from '../models/video/video' | 8 | import { VideoModel } from '../models/video/video' |
9 | 9 | ||
10 | async function optimizeOriginalVideofile (video: VideoModel) { | 10 | async function optimizeVideofile (video: VideoModel, inputVideoFileArg?: VideoFileModel) { |
11 | const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR | 11 | const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR |
12 | const newExtname = '.mp4' | 12 | const newExtname = '.mp4' |
13 | const inputVideoFile = video.getOriginalFile() | 13 | |
14 | const inputVideoFile = inputVideoFileArg ? inputVideoFileArg : video.getOriginalFile() | ||
14 | const videoInputPath = join(videosDirectory, video.getVideoFilename(inputVideoFile)) | 15 | const videoInputPath = join(videosDirectory, video.getVideoFilename(inputVideoFile)) |
15 | const videoTranscodedPath = join(videosDirectory, video.id + '-transcoded' + newExtname) | 16 | const videoTranscodedPath = join(videosDirectory, video.id + '-transcoded' + newExtname) |
16 | 17 | ||
@@ -124,7 +125,7 @@ async function importVideoFile (video: VideoModel, inputFilePath: string) { | |||
124 | } | 125 | } |
125 | 126 | ||
126 | export { | 127 | export { |
127 | optimizeOriginalVideofile, | 128 | optimizeVideofile, |
128 | transcodeOriginalVideofile, | 129 | transcodeOriginalVideofile, |
129 | importVideoFile | 130 | importVideoFile |
130 | } | 131 | } |
diff --git a/server/middlewares/cache.ts b/server/middlewares/cache.ts index 1b44957d3..1e00fc731 100644 --- a/server/middlewares/cache.ts +++ b/server/middlewares/cache.ts | |||
@@ -8,7 +8,7 @@ const lock = new AsyncLock({ timeout: 5000 }) | |||
8 | 8 | ||
9 | function cacheRoute (lifetimeArg: string | number) { | 9 | function cacheRoute (lifetimeArg: string | number) { |
10 | return async function (req: express.Request, res: express.Response, next: express.NextFunction) { | 10 | return async function (req: express.Request, res: express.Response, next: express.NextFunction) { |
11 | const redisKey = Redis.Instance.buildCachedRouteKey(req) | 11 | const redisKey = Redis.Instance.generateCachedRouteKey(req) |
12 | 12 | ||
13 | try { | 13 | try { |
14 | await lock.acquire(redisKey, async (done) => { | 14 | await lock.acquire(redisKey, async (done) => { |
diff --git a/server/middlewares/validators/index.ts b/server/middlewares/validators/index.ts index 940547a3e..17226614c 100644 --- a/server/middlewares/validators/index.ts +++ b/server/middlewares/validators/index.ts | |||
@@ -8,9 +8,5 @@ export * from './sort' | |||
8 | export * from './users' | 8 | export * from './users' |
9 | export * from './user-subscriptions' | 9 | export * from './user-subscriptions' |
10 | export * from './videos' | 10 | export * from './videos' |
11 | export * from './video-abuses' | ||
12 | export * from './video-blacklist' | ||
13 | export * from './video-channels' | ||
14 | export * from './webfinger' | 11 | export * from './webfinger' |
15 | export * from './search' | 12 | export * from './search' |
16 | export * from './video-imports' | ||
diff --git a/server/middlewares/validators/search.ts b/server/middlewares/validators/search.ts index 8baf643a5..6a95d6095 100644 --- a/server/middlewares/validators/search.ts +++ b/server/middlewares/validators/search.ts | |||
@@ -2,8 +2,7 @@ import * as express from 'express' | |||
2 | import { areValidationErrors } from './utils' | 2 | import { areValidationErrors } from './utils' |
3 | import { logger } from '../../helpers/logger' | 3 | import { logger } from '../../helpers/logger' |
4 | import { query } from 'express-validator/check' | 4 | import { query } from 'express-validator/check' |
5 | import { isNumberArray, isStringArray, isNSFWQueryValid } from '../../helpers/custom-validators/search' | 5 | import { isDateValid } from '../../helpers/custom-validators/misc' |
6 | import { isBooleanValid, isDateValid, toArray } from '../../helpers/custom-validators/misc' | ||
7 | 6 | ||
8 | const videosSearchValidator = [ | 7 | const videosSearchValidator = [ |
9 | query('search').optional().not().isEmpty().withMessage('Should have a valid search'), | 8 | query('search').optional().not().isEmpty().withMessage('Should have a valid search'), |
@@ -35,44 +34,9 @@ const videoChannelsSearchValidator = [ | |||
35 | } | 34 | } |
36 | ] | 35 | ] |
37 | 36 | ||
38 | const commonVideosFiltersValidator = [ | ||
39 | query('categoryOneOf') | ||
40 | .optional() | ||
41 | .customSanitizer(toArray) | ||
42 | .custom(isNumberArray).withMessage('Should have a valid one of category array'), | ||
43 | query('licenceOneOf') | ||
44 | .optional() | ||
45 | .customSanitizer(toArray) | ||
46 | .custom(isNumberArray).withMessage('Should have a valid one of licence array'), | ||
47 | query('languageOneOf') | ||
48 | .optional() | ||
49 | .customSanitizer(toArray) | ||
50 | .custom(isStringArray).withMessage('Should have a valid one of language array'), | ||
51 | query('tagsOneOf') | ||
52 | .optional() | ||
53 | .customSanitizer(toArray) | ||
54 | .custom(isStringArray).withMessage('Should have a valid one of tags array'), | ||
55 | query('tagsAllOf') | ||
56 | .optional() | ||
57 | .customSanitizer(toArray) | ||
58 | .custom(isStringArray).withMessage('Should have a valid all of tags array'), | ||
59 | query('nsfw') | ||
60 | .optional() | ||
61 | .custom(isNSFWQueryValid).withMessage('Should have a valid NSFW attribute'), | ||
62 | |||
63 | (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
64 | logger.debug('Checking commons video filters query', { parameters: req.query }) | ||
65 | |||
66 | if (areValidationErrors(req, res)) return | ||
67 | |||
68 | return next() | ||
69 | } | ||
70 | ] | ||
71 | |||
72 | // --------------------------------------------------------------------------- | 37 | // --------------------------------------------------------------------------- |
73 | 38 | ||
74 | export { | 39 | export { |
75 | commonVideosFiltersValidator, | ||
76 | videoChannelsSearchValidator, | 40 | videoChannelsSearchValidator, |
77 | videosSearchValidator | 41 | videosSearchValidator |
78 | } | 42 | } |
diff --git a/server/middlewares/validators/videos/index.ts b/server/middlewares/validators/videos/index.ts new file mode 100644 index 000000000..294783d85 --- /dev/null +++ b/server/middlewares/validators/videos/index.ts | |||
@@ -0,0 +1,8 @@ | |||
1 | export * from './video-abuses' | ||
2 | export * from './video-blacklist' | ||
3 | export * from './video-captions' | ||
4 | export * from './video-channels' | ||
5 | export * from './video-comments' | ||
6 | export * from './video-imports' | ||
7 | export * from './video-watch' | ||
8 | export * from './videos' | ||
diff --git a/server/middlewares/validators/video-abuses.ts b/server/middlewares/validators/videos/video-abuses.ts index f15d55a75..be26ca16a 100644 --- a/server/middlewares/validators/video-abuses.ts +++ b/server/middlewares/validators/videos/video-abuses.ts | |||
@@ -1,16 +1,16 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import 'express-validator' | 2 | import 'express-validator' |
3 | import { body, param } from 'express-validator/check' | 3 | import { body, param } from 'express-validator/check' |
4 | import { isIdOrUUIDValid, isIdValid } from '../../helpers/custom-validators/misc' | 4 | import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc' |
5 | import { isVideoExist } from '../../helpers/custom-validators/videos' | 5 | import { isVideoExist } from '../../../helpers/custom-validators/videos' |
6 | import { logger } from '../../helpers/logger' | 6 | import { logger } from '../../../helpers/logger' |
7 | import { areValidationErrors } from './utils' | 7 | import { areValidationErrors } from '../utils' |
8 | import { | 8 | import { |
9 | isVideoAbuseExist, | 9 | isVideoAbuseExist, |
10 | isVideoAbuseModerationCommentValid, | 10 | isVideoAbuseModerationCommentValid, |
11 | isVideoAbuseReasonValid, | 11 | isVideoAbuseReasonValid, |
12 | isVideoAbuseStateValid | 12 | isVideoAbuseStateValid |
13 | } from '../../helpers/custom-validators/video-abuses' | 13 | } from '../../../helpers/custom-validators/video-abuses' |
14 | 14 | ||
15 | const videoAbuseReportValidator = [ | 15 | const videoAbuseReportValidator = [ |
16 | param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), | 16 | param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), |
diff --git a/server/middlewares/validators/video-blacklist.ts b/server/middlewares/validators/videos/video-blacklist.ts index 95a2b9f17..13da7acff 100644 --- a/server/middlewares/validators/video-blacklist.ts +++ b/server/middlewares/validators/videos/video-blacklist.ts | |||
@@ -1,10 +1,10 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { body, param } from 'express-validator/check' | 2 | import { body, param } from 'express-validator/check' |
3 | import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' | 3 | import { isIdOrUUIDValid } from '../../../helpers/custom-validators/misc' |
4 | import { isVideoExist } from '../../helpers/custom-validators/videos' | 4 | import { isVideoExist } from '../../../helpers/custom-validators/videos' |
5 | import { logger } from '../../helpers/logger' | 5 | import { logger } from '../../../helpers/logger' |
6 | import { areValidationErrors } from './utils' | 6 | import { areValidationErrors } from '../utils' |
7 | import { isVideoBlacklistExist, isVideoBlacklistReasonValid } from '../../helpers/custom-validators/video-blacklist' | 7 | import { isVideoBlacklistExist, isVideoBlacklistReasonValid } from '../../../helpers/custom-validators/video-blacklist' |
8 | 8 | ||
9 | const videosBlacklistRemoveValidator = [ | 9 | const videosBlacklistRemoveValidator = [ |
10 | param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), | 10 | param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), |
diff --git a/server/middlewares/validators/video-captions.ts b/server/middlewares/validators/videos/video-captions.ts index 51ffd7f3c..63d84fbec 100644 --- a/server/middlewares/validators/video-captions.ts +++ b/server/middlewares/validators/videos/video-captions.ts | |||
@@ -1,13 +1,13 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { areValidationErrors } from './utils' | 2 | import { areValidationErrors } from '../utils' |
3 | import { checkUserCanManageVideo, isVideoExist } from '../../helpers/custom-validators/videos' | 3 | import { checkUserCanManageVideo, isVideoExist } from '../../../helpers/custom-validators/videos' |
4 | import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' | 4 | import { isIdOrUUIDValid } from '../../../helpers/custom-validators/misc' |
5 | import { body, param } from 'express-validator/check' | 5 | import { body, param } from 'express-validator/check' |
6 | import { CONSTRAINTS_FIELDS } from '../../initializers' | 6 | import { CONSTRAINTS_FIELDS } from '../../../initializers' |
7 | import { UserRight } from '../../../shared' | 7 | import { UserRight } from '../../../../shared' |
8 | import { logger } from '../../helpers/logger' | 8 | import { logger } from '../../../helpers/logger' |
9 | import { isVideoCaptionExist, isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions' | 9 | import { isVideoCaptionExist, isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../../helpers/custom-validators/video-captions' |
10 | import { cleanUpReqFiles } from '../../helpers/express-utils' | 10 | import { cleanUpReqFiles } from '../../../helpers/express-utils' |
11 | 11 | ||
12 | const addVideoCaptionValidator = [ | 12 | const addVideoCaptionValidator = [ |
13 | param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'), | 13 | param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'), |
diff --git a/server/middlewares/validators/video-channels.ts b/server/middlewares/validators/videos/video-channels.ts index 56a347b39..f039794e0 100644 --- a/server/middlewares/validators/video-channels.ts +++ b/server/middlewares/validators/videos/video-channels.ts | |||
@@ -1,20 +1,20 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { body, param } from 'express-validator/check' | 2 | import { body, param } from 'express-validator/check' |
3 | import { UserRight } from '../../../shared' | 3 | import { UserRight } from '../../../../shared' |
4 | import { isAccountNameWithHostExist } from '../../helpers/custom-validators/accounts' | 4 | import { isAccountNameWithHostExist } from '../../../helpers/custom-validators/accounts' |
5 | import { | 5 | import { |
6 | isLocalVideoChannelNameExist, | 6 | isLocalVideoChannelNameExist, |
7 | isVideoChannelDescriptionValid, | 7 | isVideoChannelDescriptionValid, |
8 | isVideoChannelNameValid, | 8 | isVideoChannelNameValid, |
9 | isVideoChannelNameWithHostExist, | 9 | isVideoChannelNameWithHostExist, |
10 | isVideoChannelSupportValid | 10 | isVideoChannelSupportValid |
11 | } from '../../helpers/custom-validators/video-channels' | 11 | } from '../../../helpers/custom-validators/video-channels' |
12 | import { logger } from '../../helpers/logger' | 12 | import { logger } from '../../../helpers/logger' |
13 | import { UserModel } from '../../models/account/user' | 13 | import { UserModel } from '../../../models/account/user' |
14 | import { VideoChannelModel } from '../../models/video/video-channel' | 14 | import { VideoChannelModel } from '../../../models/video/video-channel' |
15 | import { areValidationErrors } from './utils' | 15 | import { areValidationErrors } from '../utils' |
16 | import { isActorPreferredUsernameValid } from '../../helpers/custom-validators/activitypub/actor' | 16 | import { isActorPreferredUsernameValid } from '../../../helpers/custom-validators/activitypub/actor' |
17 | import { ActorModel } from '../../models/activitypub/actor' | 17 | import { ActorModel } from '../../../models/activitypub/actor' |
18 | 18 | ||
19 | const listVideoAccountChannelsValidator = [ | 19 | const listVideoAccountChannelsValidator = [ |
20 | param('accountName').exists().withMessage('Should have a valid account name'), | 20 | param('accountName').exists().withMessage('Should have a valid account name'), |
diff --git a/server/middlewares/validators/video-comments.ts b/server/middlewares/validators/videos/video-comments.ts index 693852499..348d33082 100644 --- a/server/middlewares/validators/video-comments.ts +++ b/server/middlewares/validators/videos/video-comments.ts | |||
@@ -1,14 +1,14 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { body, param } from 'express-validator/check' | 2 | import { body, param } from 'express-validator/check' |
3 | import { UserRight } from '../../../shared' | 3 | import { UserRight } from '../../../../shared' |
4 | import { isIdOrUUIDValid, isIdValid } from '../../helpers/custom-validators/misc' | 4 | import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc' |
5 | import { isValidVideoCommentText } from '../../helpers/custom-validators/video-comments' | 5 | import { isValidVideoCommentText } from '../../../helpers/custom-validators/video-comments' |
6 | import { isVideoExist } from '../../helpers/custom-validators/videos' | 6 | import { isVideoExist } from '../../../helpers/custom-validators/videos' |
7 | import { logger } from '../../helpers/logger' | 7 | import { logger } from '../../../helpers/logger' |
8 | import { UserModel } from '../../models/account/user' | 8 | import { UserModel } from '../../../models/account/user' |
9 | import { VideoModel } from '../../models/video/video' | 9 | import { VideoModel } from '../../../models/video/video' |
10 | import { VideoCommentModel } from '../../models/video/video-comment' | 10 | import { VideoCommentModel } from '../../../models/video/video-comment' |
11 | import { areValidationErrors } from './utils' | 11 | import { areValidationErrors } from '../utils' |
12 | 12 | ||
13 | const listVideoCommentThreadsValidator = [ | 13 | const listVideoCommentThreadsValidator = [ |
14 | param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), | 14 | param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), |
diff --git a/server/middlewares/validators/video-imports.ts b/server/middlewares/validators/videos/video-imports.ts index b2063b8da..48d20f904 100644 --- a/server/middlewares/validators/video-imports.ts +++ b/server/middlewares/validators/videos/video-imports.ts | |||
@@ -1,14 +1,14 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { body } from 'express-validator/check' | 2 | import { body } from 'express-validator/check' |
3 | import { isIdValid } from '../../helpers/custom-validators/misc' | 3 | import { isIdValid } from '../../../helpers/custom-validators/misc' |
4 | import { logger } from '../../helpers/logger' | 4 | import { logger } from '../../../helpers/logger' |
5 | import { areValidationErrors } from './utils' | 5 | import { areValidationErrors } from '../utils' |
6 | import { getCommonVideoAttributes } from './videos' | 6 | import { getCommonVideoAttributes } from './videos' |
7 | import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../helpers/custom-validators/video-imports' | 7 | import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports' |
8 | import { cleanUpReqFiles } from '../../helpers/express-utils' | 8 | import { cleanUpReqFiles } from '../../../helpers/express-utils' |
9 | import { isVideoChannelOfAccountExist, isVideoMagnetUriValid, isVideoNameValid } from '../../helpers/custom-validators/videos' | 9 | import { isVideoChannelOfAccountExist, isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos' |
10 | import { CONFIG } from '../../initializers/constants' | 10 | import { CONFIG } from '../../../initializers/constants' |
11 | import { CONSTRAINTS_FIELDS } from '../../initializers' | 11 | import { CONSTRAINTS_FIELDS } from '../../../initializers' |
12 | 12 | ||
13 | const videoImportAddValidator = getCommonVideoAttributes().concat([ | 13 | const videoImportAddValidator = getCommonVideoAttributes().concat([ |
14 | body('channelId') | 14 | body('channelId') |
diff --git a/server/middlewares/validators/videos/video-watch.ts b/server/middlewares/validators/videos/video-watch.ts new file mode 100644 index 000000000..bca64662f --- /dev/null +++ b/server/middlewares/validators/videos/video-watch.ts | |||
@@ -0,0 +1,28 @@ | |||
1 | import { body, param } from 'express-validator/check' | ||
2 | import * as express from 'express' | ||
3 | import { isIdOrUUIDValid } from '../../../helpers/custom-validators/misc' | ||
4 | import { isVideoExist } from '../../../helpers/custom-validators/videos' | ||
5 | import { areValidationErrors } from '../utils' | ||
6 | import { logger } from '../../../helpers/logger' | ||
7 | |||
8 | const videoWatchingValidator = [ | ||
9 | param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), | ||
10 | body('currentTime') | ||
11 | .toInt() | ||
12 | .isInt().withMessage('Should have correct current time'), | ||
13 | |||
14 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
15 | logger.debug('Checking videoWatching parameters', { parameters: req.body }) | ||
16 | |||
17 | if (areValidationErrors(req, res)) return | ||
18 | if (!await isVideoExist(req.params.videoId, res, 'id')) return | ||
19 | |||
20 | return next() | ||
21 | } | ||
22 | ] | ||
23 | |||
24 | // --------------------------------------------------------------------------- | ||
25 | |||
26 | export { | ||
27 | videoWatchingValidator | ||
28 | } | ||
diff --git a/server/middlewares/validators/videos.ts b/server/middlewares/validators/videos/videos.ts index 67eabe468..9dc52a134 100644 --- a/server/middlewares/validators/videos.ts +++ b/server/middlewares/validators/videos/videos.ts | |||
@@ -1,16 +1,17 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import 'express-validator' | 2 | import 'express-validator' |
3 | import { body, param, ValidationChain } from 'express-validator/check' | 3 | import { body, param, query, ValidationChain } from 'express-validator/check' |
4 | import { UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../shared' | 4 | import { UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared' |
5 | import { | 5 | import { |
6 | isBooleanValid, | 6 | isBooleanValid, |
7 | isDateValid, | 7 | isDateValid, |
8 | isIdOrUUIDValid, | 8 | isIdOrUUIDValid, |
9 | isIdValid, | 9 | isIdValid, |
10 | isUUIDValid, | 10 | isUUIDValid, |
11 | toArray, | ||
11 | toIntOrNull, | 12 | toIntOrNull, |
12 | toValueOrNull | 13 | toValueOrNull |
13 | } from '../../helpers/custom-validators/misc' | 14 | } from '../../../helpers/custom-validators/misc' |
14 | import { | 15 | import { |
15 | checkUserCanManageVideo, | 16 | checkUserCanManageVideo, |
16 | isScheduleVideoUpdatePrivacyValid, | 17 | isScheduleVideoUpdatePrivacyValid, |
@@ -19,6 +20,7 @@ import { | |||
19 | isVideoDescriptionValid, | 20 | isVideoDescriptionValid, |
20 | isVideoExist, | 21 | isVideoExist, |
21 | isVideoFile, | 22 | isVideoFile, |
23 | isVideoFilterValid, | ||
22 | isVideoImage, | 24 | isVideoImage, |
23 | isVideoLanguageValid, | 25 | isVideoLanguageValid, |
24 | isVideoLicenceValid, | 26 | isVideoLicenceValid, |
@@ -27,21 +29,22 @@ import { | |||
27 | isVideoRatingTypeValid, | 29 | isVideoRatingTypeValid, |
28 | isVideoSupportValid, | 30 | isVideoSupportValid, |
29 | isVideoTagsValid | 31 | isVideoTagsValid |
30 | } from '../../helpers/custom-validators/videos' | 32 | } from '../../../helpers/custom-validators/videos' |
31 | import { getDurationFromVideoFile } from '../../helpers/ffmpeg-utils' | 33 | import { getDurationFromVideoFile } from '../../../helpers/ffmpeg-utils' |
32 | import { logger } from '../../helpers/logger' | 34 | import { logger } from '../../../helpers/logger' |
33 | import { CONSTRAINTS_FIELDS } from '../../initializers' | 35 | import { CONSTRAINTS_FIELDS } from '../../../initializers' |
34 | import { VideoShareModel } from '../../models/video/video-share' | 36 | import { VideoShareModel } from '../../../models/video/video-share' |
35 | import { authenticate } from '../oauth' | 37 | import { authenticate } from '../../oauth' |
36 | import { areValidationErrors } from './utils' | 38 | import { areValidationErrors } from '../utils' |
37 | import { cleanUpReqFiles } from '../../helpers/express-utils' | 39 | import { cleanUpReqFiles } from '../../../helpers/express-utils' |
38 | import { VideoModel } from '../../models/video/video' | 40 | import { VideoModel } from '../../../models/video/video' |
39 | import { UserModel } from '../../models/account/user' | 41 | import { UserModel } from '../../../models/account/user' |
40 | import { checkUserCanTerminateOwnershipChange, doesChangeVideoOwnershipExist } from '../../helpers/custom-validators/video-ownership' | 42 | import { checkUserCanTerminateOwnershipChange, doesChangeVideoOwnershipExist } from '../../../helpers/custom-validators/video-ownership' |
41 | import { VideoChangeOwnershipAccept } from '../../../shared/models/videos/video-change-ownership-accept.model' | 43 | import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model' |
42 | import { VideoChangeOwnershipModel } from '../../models/video/video-change-ownership' | 44 | import { VideoChangeOwnershipModel } from '../../../models/video/video-change-ownership' |
43 | import { AccountModel } from '../../models/account/account' | 45 | import { AccountModel } from '../../../models/account/account' |
44 | import { VideoFetchType } from '../../helpers/video' | 46 | import { VideoFetchType } from '../../../helpers/video' |
47 | import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search' | ||
45 | 48 | ||
46 | const videosAddValidator = getCommonVideoAttributes().concat([ | 49 | const videosAddValidator = getCommonVideoAttributes().concat([ |
47 | body('videofile') | 50 | body('videofile') |
@@ -69,7 +72,6 @@ const videosAddValidator = getCommonVideoAttributes().concat([ | |||
69 | if (isAble === false) { | 72 | if (isAble === false) { |
70 | res.status(403) | 73 | res.status(403) |
71 | .json({ error: 'The user video quota is exceeded with this video.' }) | 74 | .json({ error: 'The user video quota is exceeded with this video.' }) |
72 | .end() | ||
73 | 75 | ||
74 | return cleanUpReqFiles(req) | 76 | return cleanUpReqFiles(req) |
75 | } | 77 | } |
@@ -82,7 +84,6 @@ const videosAddValidator = getCommonVideoAttributes().concat([ | |||
82 | logger.error('Invalid input file in videosAddValidator.', { err }) | 84 | logger.error('Invalid input file in videosAddValidator.', { err }) |
83 | res.status(400) | 85 | res.status(400) |
84 | .json({ error: 'Invalid input file.' }) | 86 | .json({ error: 'Invalid input file.' }) |
85 | .end() | ||
86 | 87 | ||
87 | return cleanUpReqFiles(req) | 88 | return cleanUpReqFiles(req) |
88 | } | 89 | } |
@@ -120,7 +121,6 @@ const videosUpdateValidator = getCommonVideoAttributes().concat([ | |||
120 | cleanUpReqFiles(req) | 121 | cleanUpReqFiles(req) |
121 | return res.status(409) | 122 | return res.status(409) |
122 | .json({ error: 'Cannot set "private" a video that was not private.' }) | 123 | .json({ error: 'Cannot set "private" a video that was not private.' }) |
123 | .end() | ||
124 | } | 124 | } |
125 | 125 | ||
126 | if (req.body.channelId && !await isVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) | 126 | if (req.body.channelId && !await isVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) |
@@ -150,7 +150,6 @@ const videosCustomGetValidator = (fetchType: VideoFetchType) => { | |||
150 | if (video.VideoChannel.Account.userId !== user.id && !user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) { | 150 | if (video.VideoChannel.Account.userId !== user.id && !user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) { |
151 | return res.status(403) | 151 | return res.status(403) |
152 | .json({ error: 'Cannot get this private or blacklisted video.' }) | 152 | .json({ error: 'Cannot get this private or blacklisted video.' }) |
153 | .end() | ||
154 | } | 153 | } |
155 | 154 | ||
156 | return next() | 155 | return next() |
@@ -239,8 +238,8 @@ const videosChangeOwnershipValidator = [ | |||
239 | const nextOwner = await AccountModel.loadLocalByName(req.body.username) | 238 | const nextOwner = await AccountModel.loadLocalByName(req.body.username) |
240 | if (!nextOwner) { | 239 | if (!nextOwner) { |
241 | res.status(400) | 240 | res.status(400) |
242 | .type('json') | 241 | .json({ error: 'Changing video ownership to a remote account is not supported yet' }) |
243 | .end() | 242 | |
244 | return | 243 | return |
245 | } | 244 | } |
246 | res.locals.nextOwner = nextOwner | 245 | res.locals.nextOwner = nextOwner |
@@ -271,7 +270,7 @@ const videosTerminateChangeOwnershipValidator = [ | |||
271 | } else { | 270 | } else { |
272 | res.status(403) | 271 | res.status(403) |
273 | .json({ error: 'Ownership already accepted or refused' }) | 272 | .json({ error: 'Ownership already accepted or refused' }) |
274 | .end() | 273 | |
275 | return | 274 | return |
276 | } | 275 | } |
277 | } | 276 | } |
@@ -288,7 +287,7 @@ const videosAcceptChangeOwnershipValidator = [ | |||
288 | if (isAble === false) { | 287 | if (isAble === false) { |
289 | res.status(403) | 288 | res.status(403) |
290 | .json({ error: 'The user video quota is exceeded with this video.' }) | 289 | .json({ error: 'The user video quota is exceeded with this video.' }) |
291 | .end() | 290 | |
292 | return | 291 | return |
293 | } | 292 | } |
294 | 293 | ||
@@ -363,6 +362,51 @@ function getCommonVideoAttributes () { | |||
363 | ] as (ValidationChain | express.Handler)[] | 362 | ] as (ValidationChain | express.Handler)[] |
364 | } | 363 | } |
365 | 364 | ||
365 | const commonVideosFiltersValidator = [ | ||
366 | query('categoryOneOf') | ||
367 | .optional() | ||
368 | .customSanitizer(toArray) | ||
369 | .custom(isNumberArray).withMessage('Should have a valid one of category array'), | ||
370 | query('licenceOneOf') | ||
371 | .optional() | ||
372 | .customSanitizer(toArray) | ||
373 | .custom(isNumberArray).withMessage('Should have a valid one of licence array'), | ||
374 | query('languageOneOf') | ||
375 | .optional() | ||
376 | .customSanitizer(toArray) | ||
377 | .custom(isStringArray).withMessage('Should have a valid one of language array'), | ||
378 | query('tagsOneOf') | ||
379 | .optional() | ||
380 | .customSanitizer(toArray) | ||
381 | .custom(isStringArray).withMessage('Should have a valid one of tags array'), | ||
382 | query('tagsAllOf') | ||
383 | .optional() | ||
384 | .customSanitizer(toArray) | ||
385 | .custom(isStringArray).withMessage('Should have a valid all of tags array'), | ||
386 | query('nsfw') | ||
387 | .optional() | ||
388 | .custom(isNSFWQueryValid).withMessage('Should have a valid NSFW attribute'), | ||
389 | query('filter') | ||
390 | .optional() | ||
391 | .custom(isVideoFilterValid).withMessage('Should have a valid filter attribute'), | ||
392 | |||
393 | (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
394 | logger.debug('Checking commons video filters query', { parameters: req.query }) | ||
395 | |||
396 | if (areValidationErrors(req, res)) return | ||
397 | |||
398 | const user: UserModel = res.locals.oauth ? res.locals.oauth.token.User : undefined | ||
399 | if (req.query.filter === 'all-local' && (!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) === false)) { | ||
400 | res.status(401) | ||
401 | .json({ error: 'You are not allowed to see all local videos.' }) | ||
402 | |||
403 | return | ||
404 | } | ||
405 | |||
406 | return next() | ||
407 | } | ||
408 | ] | ||
409 | |||
366 | // --------------------------------------------------------------------------- | 410 | // --------------------------------------------------------------------------- |
367 | 411 | ||
368 | export { | 412 | export { |
@@ -379,7 +423,9 @@ export { | |||
379 | videosTerminateChangeOwnershipValidator, | 423 | videosTerminateChangeOwnershipValidator, |
380 | videosAcceptChangeOwnershipValidator, | 424 | videosAcceptChangeOwnershipValidator, |
381 | 425 | ||
382 | getCommonVideoAttributes | 426 | getCommonVideoAttributes, |
427 | |||
428 | commonVideosFiltersValidator | ||
383 | } | 429 | } |
384 | 430 | ||
385 | // --------------------------------------------------------------------------- | 431 | // --------------------------------------------------------------------------- |
@@ -389,7 +435,6 @@ function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) | |||
389 | if (!req.body.scheduleUpdate.updateAt) { | 435 | if (!req.body.scheduleUpdate.updateAt) { |
390 | res.status(400) | 436 | res.status(400) |
391 | .json({ error: 'Schedule update at is mandatory.' }) | 437 | .json({ error: 'Schedule update at is mandatory.' }) |
392 | .end() | ||
393 | 438 | ||
394 | return true | 439 | return true |
395 | } | 440 | } |
diff --git a/server/models/account/account.ts b/server/models/account/account.ts index 27c75d886..5a237d733 100644 --- a/server/models/account/account.ts +++ b/server/models/account/account.ts | |||
@@ -248,7 +248,8 @@ export class AccountModel extends Model<AccountModel> { | |||
248 | displayName: this.getDisplayName(), | 248 | displayName: this.getDisplayName(), |
249 | description: this.description, | 249 | description: this.description, |
250 | createdAt: this.createdAt, | 250 | createdAt: this.createdAt, |
251 | updatedAt: this.updatedAt | 251 | updatedAt: this.updatedAt, |
252 | userId: this.userId ? this.userId : undefined | ||
252 | } | 253 | } |
253 | 254 | ||
254 | return Object.assign(actor, account) | 255 | return Object.assign(actor, account) |
diff --git a/server/models/account/user-video-history.ts b/server/models/account/user-video-history.ts new file mode 100644 index 000000000..0476cad9d --- /dev/null +++ b/server/models/account/user-video-history.ts | |||
@@ -0,0 +1,55 @@ | |||
1 | import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, IsInt, Min, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
2 | import { VideoModel } from '../video/video' | ||
3 | import { UserModel } from './user' | ||
4 | |||
5 | @Table({ | ||
6 | tableName: 'userVideoHistory', | ||
7 | indexes: [ | ||
8 | { | ||
9 | fields: [ 'userId', 'videoId' ], | ||
10 | unique: true | ||
11 | }, | ||
12 | { | ||
13 | fields: [ 'userId' ] | ||
14 | }, | ||
15 | { | ||
16 | fields: [ 'videoId' ] | ||
17 | } | ||
18 | ] | ||
19 | }) | ||
20 | export class UserVideoHistoryModel extends Model<UserVideoHistoryModel> { | ||
21 | @CreatedAt | ||
22 | createdAt: Date | ||
23 | |||
24 | @UpdatedAt | ||
25 | updatedAt: Date | ||
26 | |||
27 | @AllowNull(false) | ||
28 | @IsInt | ||
29 | @Column | ||
30 | currentTime: number | ||
31 | |||
32 | @ForeignKey(() => VideoModel) | ||
33 | @Column | ||
34 | videoId: number | ||
35 | |||
36 | @BelongsTo(() => VideoModel, { | ||
37 | foreignKey: { | ||
38 | allowNull: false | ||
39 | }, | ||
40 | onDelete: 'CASCADE' | ||
41 | }) | ||
42 | Video: VideoModel | ||
43 | |||
44 | @ForeignKey(() => UserModel) | ||
45 | @Column | ||
46 | userId: number | ||
47 | |||
48 | @BelongsTo(() => UserModel, { | ||
49 | foreignKey: { | ||
50 | allowNull: false | ||
51 | }, | ||
52 | onDelete: 'CASCADE' | ||
53 | }) | ||
54 | User: UserModel | ||
55 | } | ||
diff --git a/server/models/account/user.ts b/server/models/account/user.ts index e56b0bf40..39654cfcf 100644 --- a/server/models/account/user.ts +++ b/server/models/account/user.ts | |||
@@ -181,7 +181,25 @@ export class UserModel extends Model<UserModel> { | |||
181 | return this.count() | 181 | return this.count() |
182 | } | 182 | } |
183 | 183 | ||
184 | static listForApi (start: number, count: number, sort: string) { | 184 | static listForApi (start: number, count: number, sort: string, search?: string) { |
185 | let where = undefined | ||
186 | if (search) { | ||
187 | where = { | ||
188 | [Sequelize.Op.or]: [ | ||
189 | { | ||
190 | email: { | ||
191 | [Sequelize.Op.iLike]: '%' + search + '%' | ||
192 | } | ||
193 | }, | ||
194 | { | ||
195 | username: { | ||
196 | [ Sequelize.Op.iLike ]: '%' + search + '%' | ||
197 | } | ||
198 | } | ||
199 | ] | ||
200 | } | ||
201 | } | ||
202 | |||
185 | const query = { | 203 | const query = { |
186 | attributes: { | 204 | attributes: { |
187 | include: [ | 205 | include: [ |
@@ -204,7 +222,8 @@ export class UserModel extends Model<UserModel> { | |||
204 | }, | 222 | }, |
205 | offset: start, | 223 | offset: start, |
206 | limit: count, | 224 | limit: count, |
207 | order: getSort(sort) | 225 | order: getSort(sort), |
226 | where | ||
208 | } | 227 | } |
209 | 228 | ||
210 | return UserModel.findAndCountAll(query) | 229 | return UserModel.findAndCountAll(query) |
diff --git a/server/models/activitypub/actor-follow.ts b/server/models/activitypub/actor-follow.ts index 27bb43dae..3373355ef 100644 --- a/server/models/activitypub/actor-follow.ts +++ b/server/models/activitypub/actor-follow.ts | |||
@@ -280,7 +280,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> { | |||
280 | return ActorFollowModel.findAll(query) | 280 | return ActorFollowModel.findAll(query) |
281 | } | 281 | } |
282 | 282 | ||
283 | static listFollowingForApi (id: number, start: number, count: number, sort: string) { | 283 | static listFollowingForApi (id: number, start: number, count: number, sort: string, search?: string) { |
284 | const query = { | 284 | const query = { |
285 | distinct: true, | 285 | distinct: true, |
286 | offset: start, | 286 | offset: start, |
@@ -299,7 +299,17 @@ export class ActorFollowModel extends Model<ActorFollowModel> { | |||
299 | model: ActorModel, | 299 | model: ActorModel, |
300 | as: 'ActorFollowing', | 300 | as: 'ActorFollowing', |
301 | required: true, | 301 | required: true, |
302 | include: [ ServerModel ] | 302 | include: [ |
303 | { | ||
304 | model: ServerModel, | ||
305 | required: true, | ||
306 | where: search ? { | ||
307 | host: { | ||
308 | [Sequelize.Op.iLike]: '%' + search + '%' | ||
309 | } | ||
310 | } : undefined | ||
311 | } | ||
312 | ] | ||
303 | } | 313 | } |
304 | ] | 314 | ] |
305 | } | 315 | } |
@@ -313,6 +323,49 @@ export class ActorFollowModel extends Model<ActorFollowModel> { | |||
313 | }) | 323 | }) |
314 | } | 324 | } |
315 | 325 | ||
326 | static listFollowersForApi (id: number, start: number, count: number, sort: string, search?: string) { | ||
327 | const query = { | ||
328 | distinct: true, | ||
329 | offset: start, | ||
330 | limit: count, | ||
331 | order: getSort(sort), | ||
332 | include: [ | ||
333 | { | ||
334 | model: ActorModel, | ||
335 | required: true, | ||
336 | as: 'ActorFollower', | ||
337 | include: [ | ||
338 | { | ||
339 | model: ServerModel, | ||
340 | required: true, | ||
341 | where: search ? { | ||
342 | host: { | ||
343 | [ Sequelize.Op.iLike ]: '%' + search + '%' | ||
344 | } | ||
345 | } : undefined | ||
346 | } | ||
347 | ] | ||
348 | }, | ||
349 | { | ||
350 | model: ActorModel, | ||
351 | as: 'ActorFollowing', | ||
352 | required: true, | ||
353 | where: { | ||
354 | id | ||
355 | } | ||
356 | } | ||
357 | ] | ||
358 | } | ||
359 | |||
360 | return ActorFollowModel.findAndCountAll(query) | ||
361 | .then(({ rows, count }) => { | ||
362 | return { | ||
363 | data: rows, | ||
364 | total: count | ||
365 | } | ||
366 | }) | ||
367 | } | ||
368 | |||
316 | static listSubscriptionsForApi (id: number, start: number, count: number, sort: string) { | 369 | static listSubscriptionsForApi (id: number, start: number, count: number, sort: string) { |
317 | const query = { | 370 | const query = { |
318 | attributes: [], | 371 | attributes: [], |
@@ -370,39 +423,6 @@ export class ActorFollowModel extends Model<ActorFollowModel> { | |||
370 | }) | 423 | }) |
371 | } | 424 | } |
372 | 425 | ||
373 | static listFollowersForApi (id: number, start: number, count: number, sort: string) { | ||
374 | const query = { | ||
375 | distinct: true, | ||
376 | offset: start, | ||
377 | limit: count, | ||
378 | order: getSort(sort), | ||
379 | include: [ | ||
380 | { | ||
381 | model: ActorModel, | ||
382 | required: true, | ||
383 | as: 'ActorFollower', | ||
384 | include: [ ServerModel ] | ||
385 | }, | ||
386 | { | ||
387 | model: ActorModel, | ||
388 | as: 'ActorFollowing', | ||
389 | required: true, | ||
390 | where: { | ||
391 | id | ||
392 | } | ||
393 | } | ||
394 | ] | ||
395 | } | ||
396 | |||
397 | return ActorFollowModel.findAndCountAll(query) | ||
398 | .then(({ rows, count }) => { | ||
399 | return { | ||
400 | data: rows, | ||
401 | total: count | ||
402 | } | ||
403 | }) | ||
404 | } | ||
405 | |||
406 | static listAcceptedFollowerUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) { | 426 | static listAcceptedFollowerUrlsForApi (actorIds: number[], t: Sequelize.Transaction, start?: number, count?: number) { |
407 | return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count) | 427 | return ActorFollowModel.createListAcceptedFollowForApiQuery('followers', actorIds, t, start, count) |
408 | } | 428 | } |
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts index f23dde9b8..905e84449 100644 --- a/server/models/video/video-format-utils.ts +++ b/server/models/video/video-format-utils.ts | |||
@@ -10,6 +10,7 @@ import { | |||
10 | getVideoLikesActivityPubUrl, | 10 | getVideoLikesActivityPubUrl, |
11 | getVideoSharesActivityPubUrl | 11 | getVideoSharesActivityPubUrl |
12 | } from '../../lib/activitypub' | 12 | } from '../../lib/activitypub' |
13 | import { isArray } from '../../helpers/custom-validators/misc' | ||
13 | 14 | ||
14 | export type VideoFormattingJSONOptions = { | 15 | export type VideoFormattingJSONOptions = { |
15 | completeDescription?: boolean | 16 | completeDescription?: boolean |
@@ -24,6 +25,8 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting | |||
24 | const formattedAccount = video.VideoChannel.Account.toFormattedJSON() | 25 | const formattedAccount = video.VideoChannel.Account.toFormattedJSON() |
25 | const formattedVideoChannel = video.VideoChannel.toFormattedJSON() | 26 | const formattedVideoChannel = video.VideoChannel.toFormattedJSON() |
26 | 27 | ||
28 | const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined | ||
29 | |||
27 | const videoObject: Video = { | 30 | const videoObject: Video = { |
28 | id: video.id, | 31 | id: video.id, |
29 | uuid: video.uuid, | 32 | uuid: video.uuid, |
@@ -74,7 +77,11 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting | |||
74 | url: formattedVideoChannel.url, | 77 | url: formattedVideoChannel.url, |
75 | host: formattedVideoChannel.host, | 78 | host: formattedVideoChannel.host, |
76 | avatar: formattedVideoChannel.avatar | 79 | avatar: formattedVideoChannel.avatar |
77 | } | 80 | }, |
81 | |||
82 | userHistory: userHistory ? { | ||
83 | currentTime: userHistory.currentTime | ||
84 | } : undefined | ||
78 | } | 85 | } |
79 | 86 | ||
80 | if (options) { | 87 | if (options) { |
diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 6c89c16bf..4f3f75613 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts | |||
@@ -92,6 +92,7 @@ import { | |||
92 | videoModelToFormattedJSON | 92 | videoModelToFormattedJSON |
93 | } from './video-format-utils' | 93 | } from './video-format-utils' |
94 | import * as validator from 'validator' | 94 | import * as validator from 'validator' |
95 | import { UserVideoHistoryModel } from '../account/user-video-history' | ||
95 | 96 | ||
96 | // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation | 97 | // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation |
97 | const indexes: Sequelize.DefineIndexesOptions[] = [ | 98 | const indexes: Sequelize.DefineIndexesOptions[] = [ |
@@ -127,7 +128,8 @@ export enum ScopeNames { | |||
127 | WITH_TAGS = 'WITH_TAGS', | 128 | WITH_TAGS = 'WITH_TAGS', |
128 | WITH_FILES = 'WITH_FILES', | 129 | WITH_FILES = 'WITH_FILES', |
129 | WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', | 130 | WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', |
130 | WITH_BLACKLISTED = 'WITH_BLACKLISTED' | 131 | WITH_BLACKLISTED = 'WITH_BLACKLISTED', |
132 | WITH_USER_HISTORY = 'WITH_USER_HISTORY' | ||
131 | } | 133 | } |
132 | 134 | ||
133 | type ForAPIOptions = { | 135 | type ForAPIOptions = { |
@@ -233,7 +235,14 @@ type AvailableForListIDsOptions = { | |||
233 | ) | 235 | ) |
234 | } | 236 | } |
235 | ] | 237 | ] |
236 | }, | 238 | } |
239 | }, | ||
240 | include: [] | ||
241 | } | ||
242 | |||
243 | // Only list public/published videos | ||
244 | if (!options.filter || options.filter !== 'all-local') { | ||
245 | const privacyWhere = { | ||
237 | // Always list public videos | 246 | // Always list public videos |
238 | privacy: VideoPrivacy.PUBLIC, | 247 | privacy: VideoPrivacy.PUBLIC, |
239 | // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding | 248 | // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding |
@@ -248,8 +257,9 @@ type AvailableForListIDsOptions = { | |||
248 | } | 257 | } |
249 | } | 258 | } |
250 | ] | 259 | ] |
251 | }, | 260 | } |
252 | include: [] | 261 | |
262 | Object.assign(query.where, privacyWhere) | ||
253 | } | 263 | } |
254 | 264 | ||
255 | if (options.filter || options.accountId || options.videoChannelId) { | 265 | if (options.filter || options.accountId || options.videoChannelId) { |
@@ -464,6 +474,8 @@ type AvailableForListIDsOptions = { | |||
464 | include: [ | 474 | include: [ |
465 | { | 475 | { |
466 | model: () => VideoFileModel.unscoped(), | 476 | model: () => VideoFileModel.unscoped(), |
477 | // FIXME: typings | ||
478 | [ 'separate' as any ]: true, // We may have multiple files, having multiple redundancies so let's separate this join | ||
467 | required: false, | 479 | required: false, |
468 | include: [ | 480 | include: [ |
469 | { | 481 | { |
@@ -482,6 +494,20 @@ type AvailableForListIDsOptions = { | |||
482 | required: false | 494 | required: false |
483 | } | 495 | } |
484 | ] | 496 | ] |
497 | }, | ||
498 | [ ScopeNames.WITH_USER_HISTORY ]: (userId: number) => { | ||
499 | return { | ||
500 | include: [ | ||
501 | { | ||
502 | attributes: [ 'currentTime' ], | ||
503 | model: UserVideoHistoryModel.unscoped(), | ||
504 | required: false, | ||
505 | where: { | ||
506 | userId | ||
507 | } | ||
508 | } | ||
509 | ] | ||
510 | } | ||
485 | } | 511 | } |
486 | }) | 512 | }) |
487 | @Table({ | 513 | @Table({ |
@@ -672,11 +698,19 @@ export class VideoModel extends Model<VideoModel> { | |||
672 | name: 'videoId', | 698 | name: 'videoId', |
673 | allowNull: false | 699 | allowNull: false |
674 | }, | 700 | }, |
675 | onDelete: 'cascade', | 701 | onDelete: 'cascade' |
676 | hooks: true | ||
677 | }) | 702 | }) |
678 | VideoViews: VideoViewModel[] | 703 | VideoViews: VideoViewModel[] |
679 | 704 | ||
705 | @HasMany(() => UserVideoHistoryModel, { | ||
706 | foreignKey: { | ||
707 | name: 'videoId', | ||
708 | allowNull: false | ||
709 | }, | ||
710 | onDelete: 'cascade' | ||
711 | }) | ||
712 | UserVideoHistories: UserVideoHistoryModel[] | ||
713 | |||
680 | @HasOne(() => ScheduleVideoUpdateModel, { | 714 | @HasOne(() => ScheduleVideoUpdateModel, { |
681 | foreignKey: { | 715 | foreignKey: { |
682 | name: 'videoId', | 716 | name: 'videoId', |
@@ -762,6 +796,16 @@ export class VideoModel extends Model<VideoModel> { | |||
762 | return VideoModel.scope(ScopeNames.WITH_FILES).findAll() | 796 | return VideoModel.scope(ScopeNames.WITH_FILES).findAll() |
763 | } | 797 | } |
764 | 798 | ||
799 | static listLocal () { | ||
800 | const query = { | ||
801 | where: { | ||
802 | remote: false | ||
803 | } | ||
804 | } | ||
805 | |||
806 | return VideoModel.scope(ScopeNames.WITH_FILES).findAll(query) | ||
807 | } | ||
808 | |||
765 | static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) { | 809 | static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) { |
766 | function getRawQuery (select: string) { | 810 | function getRawQuery (select: string) { |
767 | const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' + | 811 | const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' + |
@@ -930,8 +974,13 @@ export class VideoModel extends Model<VideoModel> { | |||
930 | accountId?: number, | 974 | accountId?: number, |
931 | videoChannelId?: number, | 975 | videoChannelId?: number, |
932 | actorId?: number | 976 | actorId?: number |
933 | trendingDays?: number | 977 | trendingDays?: number, |
978 | userId?: number | ||
934 | }, countVideos = true) { | 979 | }, countVideos = true) { |
980 | if (options.filter && options.filter === 'all-local' && !options.userId) { | ||
981 | throw new Error('Try to filter all-local but no userId is provided') | ||
982 | } | ||
983 | |||
935 | const query: IFindOptions<VideoModel> = { | 984 | const query: IFindOptions<VideoModel> = { |
936 | offset: options.start, | 985 | offset: options.start, |
937 | limit: options.count, | 986 | limit: options.count, |
@@ -961,6 +1010,7 @@ export class VideoModel extends Model<VideoModel> { | |||
961 | accountId: options.accountId, | 1010 | accountId: options.accountId, |
962 | videoChannelId: options.videoChannelId, | 1011 | videoChannelId: options.videoChannelId, |
963 | includeLocalVideos: options.includeLocalVideos, | 1012 | includeLocalVideos: options.includeLocalVideos, |
1013 | userId: options.userId, | ||
964 | trendingDays | 1014 | trendingDays |
965 | } | 1015 | } |
966 | 1016 | ||
@@ -983,6 +1033,8 @@ export class VideoModel extends Model<VideoModel> { | |||
983 | tagsAllOf?: string[] | 1033 | tagsAllOf?: string[] |
984 | durationMin?: number // seconds | 1034 | durationMin?: number // seconds |
985 | durationMax?: number // seconds | 1035 | durationMax?: number // seconds |
1036 | userId?: number, | ||
1037 | filter?: VideoFilter | ||
986 | }) { | 1038 | }) { |
987 | const whereAnd = [] | 1039 | const whereAnd = [] |
988 | 1040 | ||
@@ -1058,7 +1110,9 @@ export class VideoModel extends Model<VideoModel> { | |||
1058 | licenceOneOf: options.licenceOneOf, | 1110 | licenceOneOf: options.licenceOneOf, |
1059 | languageOneOf: options.languageOneOf, | 1111 | languageOneOf: options.languageOneOf, |
1060 | tagsOneOf: options.tagsOneOf, | 1112 | tagsOneOf: options.tagsOneOf, |
1061 | tagsAllOf: options.tagsAllOf | 1113 | tagsAllOf: options.tagsAllOf, |
1114 | userId: options.userId, | ||
1115 | filter: options.filter | ||
1062 | } | 1116 | } |
1063 | 1117 | ||
1064 | return VideoModel.getAvailableForApi(query, queryOptions) | 1118 | return VideoModel.getAvailableForApi(query, queryOptions) |
@@ -1125,7 +1179,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1125 | return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query) | 1179 | return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query) |
1126 | } | 1180 | } |
1127 | 1181 | ||
1128 | static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction) { | 1182 | static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) { |
1129 | const where = VideoModel.buildWhereIdOrUUID(id) | 1183 | const where = VideoModel.buildWhereIdOrUUID(id) |
1130 | 1184 | ||
1131 | const options = { | 1185 | const options = { |
@@ -1134,14 +1188,20 @@ export class VideoModel extends Model<VideoModel> { | |||
1134 | transaction: t | 1188 | transaction: t |
1135 | } | 1189 | } |
1136 | 1190 | ||
1191 | const scopes = [ | ||
1192 | ScopeNames.WITH_TAGS, | ||
1193 | ScopeNames.WITH_BLACKLISTED, | ||
1194 | ScopeNames.WITH_FILES, | ||
1195 | ScopeNames.WITH_ACCOUNT_DETAILS, | ||
1196 | ScopeNames.WITH_SCHEDULED_UPDATE | ||
1197 | ] | ||
1198 | |||
1199 | if (userId) { | ||
1200 | scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] } as any) // FIXME: typings | ||
1201 | } | ||
1202 | |||
1137 | return VideoModel | 1203 | return VideoModel |
1138 | .scope([ | 1204 | .scope(scopes) |
1139 | ScopeNames.WITH_TAGS, | ||
1140 | ScopeNames.WITH_BLACKLISTED, | ||
1141 | ScopeNames.WITH_FILES, | ||
1142 | ScopeNames.WITH_ACCOUNT_DETAILS, | ||
1143 | ScopeNames.WITH_SCHEDULED_UPDATE | ||
1144 | ]) | ||
1145 | .findOne(options) | 1205 | .findOne(options) |
1146 | } | 1206 | } |
1147 | 1207 | ||
@@ -1216,7 +1276,7 @@ export class VideoModel extends Model<VideoModel> { | |||
1216 | } | 1276 | } |
1217 | 1277 | ||
1218 | private static buildActorWhereWithFilter (filter?: VideoFilter) { | 1278 | private static buildActorWhereWithFilter (filter?: VideoFilter) { |
1219 | if (filter && filter === 'local') { | 1279 | if (filter && (filter === 'local' || filter === 'all-local')) { |
1220 | return { | 1280 | return { |
1221 | serverId: null | 1281 | serverId: null |
1222 | } | 1282 | } |
@@ -1225,7 +1285,11 @@ export class VideoModel extends Model<VideoModel> { | |||
1225 | return {} | 1285 | return {} |
1226 | } | 1286 | } |
1227 | 1287 | ||
1228 | private static async getAvailableForApi (query: IFindOptions<VideoModel>, options: AvailableForListIDsOptions, countVideos = true) { | 1288 | private static async getAvailableForApi ( |
1289 | query: IFindOptions<VideoModel>, | ||
1290 | options: AvailableForListIDsOptions & { userId?: number}, | ||
1291 | countVideos = true | ||
1292 | ) { | ||
1229 | const idsScope = { | 1293 | const idsScope = { |
1230 | method: [ | 1294 | method: [ |
1231 | ScopeNames.AVAILABLE_FOR_LIST_IDS, options | 1295 | ScopeNames.AVAILABLE_FOR_LIST_IDS, options |
@@ -1249,8 +1313,15 @@ export class VideoModel extends Model<VideoModel> { | |||
1249 | 1313 | ||
1250 | if (ids.length === 0) return { data: [], total: count } | 1314 | if (ids.length === 0) return { data: [], total: count } |
1251 | 1315 | ||
1252 | const apiScope = { | 1316 | // FIXME: typings |
1253 | method: [ ScopeNames.FOR_API, { ids, withFiles: options.withFiles } as ForAPIOptions ] | 1317 | const apiScope: any[] = [ |
1318 | { | ||
1319 | method: [ ScopeNames.FOR_API, { ids, withFiles: options.withFiles } as ForAPIOptions ] | ||
1320 | } | ||
1321 | ] | ||
1322 | |||
1323 | if (options.userId) { | ||
1324 | apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.userId ] }) | ||
1254 | } | 1325 | } |
1255 | 1326 | ||
1256 | const secondQuery = { | 1327 | const secondQuery = { |
diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts index 44460a167..bfc550ae5 100644 --- a/server/tests/api/check-params/index.ts +++ b/server/tests/api/check-params/index.ts | |||
@@ -15,3 +15,5 @@ import './video-channels' | |||
15 | import './video-comments' | 15 | import './video-comments' |
16 | import './video-imports' | 16 | import './video-imports' |
17 | import './videos' | 17 | import './videos' |
18 | import './videos-filter' | ||
19 | import './videos-history' | ||
diff --git a/server/tests/api/check-params/videos-filter.ts b/server/tests/api/check-params/videos-filter.ts new file mode 100644 index 000000000..784cd8ba1 --- /dev/null +++ b/server/tests/api/check-params/videos-filter.ts | |||
@@ -0,0 +1,127 @@ | |||
1 | /* tslint:disable:no-unused-expression */ | ||
2 | |||
3 | import * as chai from 'chai' | ||
4 | import 'mocha' | ||
5 | import { | ||
6 | createUser, | ||
7 | flushTests, | ||
8 | killallServers, | ||
9 | makeGetRequest, | ||
10 | runServer, | ||
11 | ServerInfo, | ||
12 | setAccessTokensToServers, | ||
13 | userLogin | ||
14 | } from '../../utils' | ||
15 | import { UserRole } from '../../../../shared/models/users' | ||
16 | |||
17 | const expect = chai.expect | ||
18 | |||
19 | async function testEndpoints (server: ServerInfo, token: string, filter: string, statusCodeExpected: number) { | ||
20 | const paths = [ | ||
21 | '/api/v1/video-channels/root_channel/videos', | ||
22 | '/api/v1/accounts/root/videos', | ||
23 | '/api/v1/videos', | ||
24 | '/api/v1/search/videos' | ||
25 | ] | ||
26 | |||
27 | for (const path of paths) { | ||
28 | await makeGetRequest({ | ||
29 | url: server.url, | ||
30 | path, | ||
31 | token, | ||
32 | query: { | ||
33 | filter | ||
34 | }, | ||
35 | statusCodeExpected | ||
36 | }) | ||
37 | } | ||
38 | } | ||
39 | |||
40 | describe('Test videos filters', function () { | ||
41 | let server: ServerInfo | ||
42 | let userAccessToken: string | ||
43 | let moderatorAccessToken: string | ||
44 | |||
45 | // --------------------------------------------------------------- | ||
46 | |||
47 | before(async function () { | ||
48 | this.timeout(30000) | ||
49 | |||
50 | await flushTests() | ||
51 | |||
52 | server = await runServer(1) | ||
53 | |||
54 | await setAccessTokensToServers([ server ]) | ||
55 | |||
56 | const user = { username: 'user1', password: 'my super password' } | ||
57 | await createUser(server.url, server.accessToken, user.username, user.password) | ||
58 | userAccessToken = await userLogin(server, user) | ||
59 | |||
60 | const moderator = { username: 'moderator', password: 'my super password' } | ||
61 | await createUser( | ||
62 | server.url, | ||
63 | server.accessToken, | ||
64 | moderator.username, | ||
65 | moderator.password, | ||
66 | undefined, | ||
67 | undefined, | ||
68 | UserRole.MODERATOR | ||
69 | ) | ||
70 | moderatorAccessToken = await userLogin(server, moderator) | ||
71 | }) | ||
72 | |||
73 | describe('When setting a video filter', function () { | ||
74 | |||
75 | it('Should fail with a bad filter', async function () { | ||
76 | await testEndpoints(server, server.accessToken, 'bad-filter', 400) | ||
77 | }) | ||
78 | |||
79 | it('Should succeed with a good filter', async function () { | ||
80 | await testEndpoints(server, server.accessToken,'local', 200) | ||
81 | }) | ||
82 | |||
83 | it('Should fail to list all-local with a simple user', async function () { | ||
84 | await testEndpoints(server, userAccessToken, 'all-local', 401) | ||
85 | }) | ||
86 | |||
87 | it('Should succeed to list all-local with a moderator', async function () { | ||
88 | await testEndpoints(server, moderatorAccessToken, 'all-local', 200) | ||
89 | }) | ||
90 | |||
91 | it('Should succeed to list all-local with an admin', async function () { | ||
92 | await testEndpoints(server, server.accessToken, 'all-local', 200) | ||
93 | }) | ||
94 | |||
95 | // Because we cannot authenticate the user on the RSS endpoint | ||
96 | it('Should fail on the feeds endpoint with the all-local filter', async function () { | ||
97 | await makeGetRequest({ | ||
98 | url: server.url, | ||
99 | path: '/feeds/videos.json', | ||
100 | statusCodeExpected: 401, | ||
101 | query: { | ||
102 | filter: 'all-local' | ||
103 | } | ||
104 | }) | ||
105 | }) | ||
106 | |||
107 | it('Should succed on the feeds endpoint with the local filter', async function () { | ||
108 | await makeGetRequest({ | ||
109 | url: server.url, | ||
110 | path: '/feeds/videos.json', | ||
111 | statusCodeExpected: 200, | ||
112 | query: { | ||
113 | filter: 'local' | ||
114 | } | ||
115 | }) | ||
116 | }) | ||
117 | }) | ||
118 | |||
119 | after(async function () { | ||
120 | killallServers([ server ]) | ||
121 | |||
122 | // Keep the logs if the test failed | ||
123 | if (this['ok']) { | ||
124 | await flushTests() | ||
125 | } | ||
126 | }) | ||
127 | }) | ||
diff --git a/server/tests/api/check-params/videos-history.ts b/server/tests/api/check-params/videos-history.ts new file mode 100644 index 000000000..808c3b616 --- /dev/null +++ b/server/tests/api/check-params/videos-history.ts | |||
@@ -0,0 +1,79 @@ | |||
1 | /* tslint:disable:no-unused-expression */ | ||
2 | |||
3 | import * as chai from 'chai' | ||
4 | import 'mocha' | ||
5 | import { | ||
6 | flushTests, | ||
7 | killallServers, | ||
8 | makePostBodyRequest, | ||
9 | makePutBodyRequest, | ||
10 | runServer, | ||
11 | ServerInfo, | ||
12 | setAccessTokensToServers, | ||
13 | uploadVideo | ||
14 | } from '../../utils' | ||
15 | |||
16 | const expect = chai.expect | ||
17 | |||
18 | describe('Test videos history API validator', function () { | ||
19 | let path: string | ||
20 | let server: ServerInfo | ||
21 | |||
22 | // --------------------------------------------------------------- | ||
23 | |||
24 | before(async function () { | ||
25 | this.timeout(30000) | ||
26 | |||
27 | await flushTests() | ||
28 | |||
29 | server = await runServer(1) | ||
30 | |||
31 | await setAccessTokensToServers([ server ]) | ||
32 | |||
33 | const res = await uploadVideo(server.url, server.accessToken, {}) | ||
34 | const videoUUID = res.body.video.uuid | ||
35 | |||
36 | path = '/api/v1/videos/' + videoUUID + '/watching' | ||
37 | }) | ||
38 | |||
39 | describe('When notifying a user is watching a video', function () { | ||
40 | |||
41 | it('Should fail with an unauthenticated user', async function () { | ||
42 | const fields = { currentTime: 5 } | ||
43 | await makePutBodyRequest({ url: server.url, path, fields, statusCodeExpected: 401 }) | ||
44 | }) | ||
45 | |||
46 | it('Should fail with an incorrect video id', async function () { | ||
47 | const fields = { currentTime: 5 } | ||
48 | const path = '/api/v1/videos/blabla/watching' | ||
49 | await makePutBodyRequest({ url: server.url, path, fields, token: server.accessToken, statusCodeExpected: 400 }) | ||
50 | }) | ||
51 | |||
52 | it('Should fail with an unknown video', async function () { | ||
53 | const fields = { currentTime: 5 } | ||
54 | const path = '/api/v1/videos/d91fff41-c24d-4508-8e13-3bd5902c3b02/watching' | ||
55 | |||
56 | await makePutBodyRequest({ url: server.url, path, fields, token: server.accessToken, statusCodeExpected: 404 }) | ||
57 | }) | ||
58 | |||
59 | it('Should fail with a bad current time', async function () { | ||
60 | const fields = { currentTime: 'hello' } | ||
61 | await makePutBodyRequest({ url: server.url, path, fields, token: server.accessToken, statusCodeExpected: 400 }) | ||
62 | }) | ||
63 | |||
64 | it('Should succeed with the correct parameters', async function () { | ||
65 | const fields = { currentTime: 5 } | ||
66 | |||
67 | await makePutBodyRequest({ url: server.url, path, fields, token: server.accessToken, statusCodeExpected: 204 }) | ||
68 | }) | ||
69 | }) | ||
70 | |||
71 | after(async function () { | ||
72 | killallServers([ server ]) | ||
73 | |||
74 | // Keep the logs if the test failed | ||
75 | if (this['ok']) { | ||
76 | await flushTests() | ||
77 | } | ||
78 | }) | ||
79 | }) | ||
diff --git a/server/tests/api/server/follows.ts b/server/tests/api/server/follows.ts index 310c291bf..e80e93e7f 100644 --- a/server/tests/api/server/follows.ts +++ b/server/tests/api/server/follows.ts | |||
@@ -93,7 +93,26 @@ describe('Test follows', function () { | |||
93 | expect(server3Follow.state).to.equal('accepted') | 93 | expect(server3Follow.state).to.equal('accepted') |
94 | }) | 94 | }) |
95 | 95 | ||
96 | it('Should have 0 followings on server 1 and 2', async function () { | 96 | it('Should search followings on server 1', async function () { |
97 | { | ||
98 | const res = await getFollowingListPaginationAndSort(servers[ 0 ].url, 0, 1, 'createdAt', ':9002') | ||
99 | const follows = res.body.data | ||
100 | |||
101 | expect(res.body.total).to.equal(1) | ||
102 | expect(follows.length).to.equal(1) | ||
103 | expect(follows[ 0 ].following.host).to.equal('localhost:9002') | ||
104 | } | ||
105 | |||
106 | { | ||
107 | const res = await getFollowingListPaginationAndSort(servers[ 0 ].url, 0, 1, 'createdAt', 'bla') | ||
108 | const follows = res.body.data | ||
109 | |||
110 | expect(res.body.total).to.equal(0) | ||
111 | expect(follows.length).to.equal(0) | ||
112 | } | ||
113 | }) | ||
114 | |||
115 | it('Should have 0 followings on server 2 and 3', async function () { | ||
97 | for (const server of [ servers[1], servers[2] ]) { | 116 | for (const server of [ servers[1], servers[2] ]) { |
98 | const res = await getFollowingListPaginationAndSort(server.url, 0, 5, 'createdAt') | 117 | const res = await getFollowingListPaginationAndSort(server.url, 0, 5, 'createdAt') |
99 | const follows = res.body.data | 118 | const follows = res.body.data |
@@ -116,6 +135,25 @@ describe('Test follows', function () { | |||
116 | } | 135 | } |
117 | }) | 136 | }) |
118 | 137 | ||
138 | it('Should search followers on server 2', async function () { | ||
139 | { | ||
140 | const res = await getFollowersListPaginationAndSort(servers[ 2 ].url, 0, 5, 'createdAt', '9001') | ||
141 | const follows = res.body.data | ||
142 | |||
143 | expect(res.body.total).to.equal(1) | ||
144 | expect(follows.length).to.equal(1) | ||
145 | expect(follows[ 0 ].following.host).to.equal('localhost:9003') | ||
146 | } | ||
147 | |||
148 | { | ||
149 | const res = await getFollowersListPaginationAndSort(servers[ 2 ].url, 0, 5, 'createdAt', 'bla') | ||
150 | const follows = res.body.data | ||
151 | |||
152 | expect(res.body.total).to.equal(0) | ||
153 | expect(follows.length).to.equal(0) | ||
154 | } | ||
155 | }) | ||
156 | |||
119 | it('Should have 0 followers on server 1', async function () { | 157 | it('Should have 0 followers on server 1', async function () { |
120 | const res = await getFollowersListPaginationAndSort(servers[0].url, 0, 5, 'createdAt') | 158 | const res = await getFollowersListPaginationAndSort(servers[0].url, 0, 5, 'createdAt') |
121 | const follows = res.body.data | 159 | const follows = res.body.data |
diff --git a/server/tests/api/users/users-multiple-servers.ts b/server/tests/api/users/users-multiple-servers.ts index b67072851..d8699db17 100644 --- a/server/tests/api/users/users-multiple-servers.ts +++ b/server/tests/api/users/users-multiple-servers.ts | |||
@@ -148,6 +148,12 @@ describe('Test users with multiple servers', function () { | |||
148 | expect(rootServer1Get.displayName).to.equal('my super display name') | 148 | expect(rootServer1Get.displayName).to.equal('my super display name') |
149 | expect(rootServer1Get.description).to.equal('my super description updated') | 149 | expect(rootServer1Get.description).to.equal('my super description updated') |
150 | 150 | ||
151 | if (server.serverNumber === 1) { | ||
152 | expect(rootServer1Get.userId).to.be.a('number') | ||
153 | } else { | ||
154 | expect(rootServer1Get.userId).to.be.undefined | ||
155 | } | ||
156 | |||
151 | await testImage(server.url, 'avatar2-resized', rootServer1Get.avatar.path, '.png') | 157 | await testImage(server.url, 'avatar2-resized', rootServer1Get.avatar.path, '.png') |
152 | } | 158 | } |
153 | }) | 159 | }) |
diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts index 8b9c6b455..513bca8a0 100644 --- a/server/tests/api/users/users.ts +++ b/server/tests/api/users/users.ts | |||
@@ -180,7 +180,7 @@ describe('Test users', function () { | |||
180 | it('Should be able to upload a video again') | 180 | it('Should be able to upload a video again') |
181 | 181 | ||
182 | it('Should be able to create a new user', async function () { | 182 | it('Should be able to create a new user', async function () { |
183 | await createUser(server.url, accessToken, user.username,user.password, 2 * 1024 * 1024) | 183 | await createUser(server.url, accessToken, user.username, user.password, 2 * 1024 * 1024) |
184 | }) | 184 | }) |
185 | 185 | ||
186 | it('Should be able to login with this user', async function () { | 186 | it('Should be able to login with this user', async function () { |
@@ -322,6 +322,40 @@ describe('Test users', function () { | |||
322 | expect(users[ 1 ].nsfwPolicy).to.equal('display') | 322 | expect(users[ 1 ].nsfwPolicy).to.equal('display') |
323 | }) | 323 | }) |
324 | 324 | ||
325 | it('Should search user by username', async function () { | ||
326 | const res = await getUsersListPaginationAndSort(server.url, server.accessToken, 0, 2, 'createdAt', 'oot') | ||
327 | const users = res.body.data as User[] | ||
328 | |||
329 | expect(res.body.total).to.equal(1) | ||
330 | expect(users.length).to.equal(1) | ||
331 | |||
332 | expect(users[ 0 ].username).to.equal('root') | ||
333 | }) | ||
334 | |||
335 | it('Should search user by email', async function () { | ||
336 | { | ||
337 | const res = await getUsersListPaginationAndSort(server.url, server.accessToken, 0, 2, 'createdAt', 'r_1@exam') | ||
338 | const users = res.body.data as User[] | ||
339 | |||
340 | expect(res.body.total).to.equal(1) | ||
341 | expect(users.length).to.equal(1) | ||
342 | |||
343 | expect(users[ 0 ].username).to.equal('user_1') | ||
344 | expect(users[ 0 ].email).to.equal('user_1@example.com') | ||
345 | } | ||
346 | |||
347 | { | ||
348 | const res = await getUsersListPaginationAndSort(server.url, server.accessToken, 0, 2, 'createdAt', 'example') | ||
349 | const users = res.body.data as User[] | ||
350 | |||
351 | expect(res.body.total).to.equal(2) | ||
352 | expect(users.length).to.equal(2) | ||
353 | |||
354 | expect(users[ 0 ].username).to.equal('root') | ||
355 | expect(users[ 1 ].username).to.equal('user_1') | ||
356 | } | ||
357 | }) | ||
358 | |||
325 | it('Should update my password', async function () { | 359 | it('Should update my password', async function () { |
326 | await updateMyUser({ | 360 | await updateMyUser({ |
327 | url: server.url, | 361 | url: server.url, |
diff --git a/server/tests/api/videos/index.ts b/server/tests/api/videos/index.ts index bf58f9c79..9bdb78491 100644 --- a/server/tests/api/videos/index.ts +++ b/server/tests/api/videos/index.ts | |||
@@ -14,4 +14,6 @@ import './video-nsfw' | |||
14 | import './video-privacy' | 14 | import './video-privacy' |
15 | import './video-schedule-update' | 15 | import './video-schedule-update' |
16 | import './video-transcoder' | 16 | import './video-transcoder' |
17 | import './videos-filter' | ||
18 | import './videos-history' | ||
17 | import './videos-overview' | 19 | import './videos-overview' |
diff --git a/server/tests/api/videos/video-transcoder.ts b/server/tests/api/videos/video-transcoder.ts index 0f83d4d57..0ce5197ea 100644 --- a/server/tests/api/videos/video-transcoder.ts +++ b/server/tests/api/videos/video-transcoder.ts | |||
@@ -4,8 +4,8 @@ import * as chai from 'chai' | |||
4 | import 'mocha' | 4 | import 'mocha' |
5 | import { omit } from 'lodash' | 5 | import { omit } from 'lodash' |
6 | import * as ffmpeg from 'fluent-ffmpeg' | 6 | import * as ffmpeg from 'fluent-ffmpeg' |
7 | import { VideoDetails, VideoState } from '../../../../shared/models/videos' | 7 | import { getMaxBitrate, VideoDetails, VideoResolution, VideoState } from '../../../../shared/models/videos' |
8 | import { getVideoFileFPS, audio } from '../../../helpers/ffmpeg-utils' | 8 | import { audio, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils' |
9 | import { | 9 | import { |
10 | buildAbsoluteFixturePath, | 10 | buildAbsoluteFixturePath, |
11 | doubleFollow, | 11 | doubleFollow, |
@@ -22,6 +22,8 @@ import { | |||
22 | } from '../../utils' | 22 | } from '../../utils' |
23 | import { join } from 'path' | 23 | import { join } from 'path' |
24 | import { waitJobs } from '../../utils/server/jobs' | 24 | import { waitJobs } from '../../utils/server/jobs' |
25 | import { pathExists } from 'fs-extra' | ||
26 | import { VIDEO_TRANSCODING_FPS } from '../../../../server/initializers/constants' | ||
25 | 27 | ||
26 | const expect = chai.expect | 28 | const expect = chai.expect |
27 | 29 | ||
@@ -228,7 +230,7 @@ describe('Test video transcoding', function () { | |||
228 | } | 230 | } |
229 | }) | 231 | }) |
230 | 232 | ||
231 | it('Should wait transcoding before publishing the video', async function () { | 233 | it('Should wait for transcoding before publishing the video', async function () { |
232 | this.timeout(80000) | 234 | this.timeout(80000) |
233 | 235 | ||
234 | { | 236 | { |
@@ -281,6 +283,61 @@ describe('Test video transcoding', function () { | |||
281 | } | 283 | } |
282 | }) | 284 | }) |
283 | 285 | ||
286 | const tempFixturePath = buildAbsoluteFixturePath('video_high_bitrate_1080p.mp4', true) | ||
287 | it('Should respect maximum bitrate values', async function () { | ||
288 | this.timeout(160000) | ||
289 | |||
290 | { | ||
291 | const exists = await pathExists(tempFixturePath) | ||
292 | if (!exists) { | ||
293 | |||
294 | // Generate a random, high bitrate video on the fly, so we don't have to include | ||
295 | // a large file in the repo. The video needs to have a certain minimum length so | ||
296 | // that FFmpeg properly applies bitrate limits. | ||
297 | // https://stackoverflow.com/a/15795112 | ||
298 | await new Promise<void>(async (res, rej) => { | ||
299 | ffmpeg() | ||
300 | .outputOptions([ '-f rawvideo', '-video_size 1920x1080', '-i /dev/urandom' ]) | ||
301 | .outputOptions([ '-ac 2', '-f s16le', '-i /dev/urandom', '-t 10' ]) | ||
302 | .outputOptions([ '-maxrate 10M', '-bufsize 10M' ]) | ||
303 | .output(tempFixturePath) | ||
304 | .on('error', rej) | ||
305 | .on('end', res) | ||
306 | .run() | ||
307 | }) | ||
308 | } | ||
309 | |||
310 | const bitrate = await getVideoFileBitrate(tempFixturePath) | ||
311 | expect(bitrate).to.be.above(getMaxBitrate(VideoResolution.H_1080P, 60, VIDEO_TRANSCODING_FPS)) | ||
312 | } | ||
313 | |||
314 | const videoAttributes = { | ||
315 | name: 'high bitrate video', | ||
316 | description: 'high bitrate video', | ||
317 | fixture: tempFixturePath | ||
318 | } | ||
319 | |||
320 | await uploadVideo(servers[1].url, servers[1].accessToken, videoAttributes) | ||
321 | |||
322 | await waitJobs(servers) | ||
323 | |||
324 | for (const server of servers) { | ||
325 | const res = await getVideosList(server.url) | ||
326 | |||
327 | const video = res.body.data.find(v => v.name === videoAttributes.name) | ||
328 | |||
329 | for (const resolution of ['240', '360', '480', '720', '1080']) { | ||
330 | const path = join(root(), 'test2', 'videos', video.uuid + '-' + resolution + '.mp4') | ||
331 | const bitrate = await getVideoFileBitrate(path) | ||
332 | const fps = await getVideoFileFPS(path) | ||
333 | const resolution2 = await getVideoFileResolution(path) | ||
334 | |||
335 | expect(resolution2.videoFileResolution.toString()).to.equal(resolution) | ||
336 | expect(bitrate).to.be.below(getMaxBitrate(resolution2.videoFileResolution, fps, VIDEO_TRANSCODING_FPS)) | ||
337 | } | ||
338 | } | ||
339 | }) | ||
340 | |||
284 | after(async function () { | 341 | after(async function () { |
285 | killallServers(servers) | 342 | killallServers(servers) |
286 | }) | 343 | }) |
diff --git a/server/tests/api/videos/videos-filter.ts b/server/tests/api/videos/videos-filter.ts new file mode 100644 index 000000000..a7588129f --- /dev/null +++ b/server/tests/api/videos/videos-filter.ts | |||
@@ -0,0 +1,130 @@ | |||
1 | /* tslint:disable:no-unused-expression */ | ||
2 | |||
3 | import * as chai from 'chai' | ||
4 | import 'mocha' | ||
5 | import { | ||
6 | createUser, | ||
7 | doubleFollow, | ||
8 | flushAndRunMultipleServers, | ||
9 | flushTests, | ||
10 | killallServers, | ||
11 | makeGetRequest, | ||
12 | ServerInfo, | ||
13 | setAccessTokensToServers, | ||
14 | uploadVideo, | ||
15 | userLogin | ||
16 | } from '../../utils' | ||
17 | import { Video, VideoPrivacy } from '../../../../shared/models/videos' | ||
18 | import { UserRole } from '../../../../shared/models/users' | ||
19 | |||
20 | const expect = chai.expect | ||
21 | |||
22 | async function getVideosNames (server: ServerInfo, token: string, filter: string, statusCodeExpected = 200) { | ||
23 | const paths = [ | ||
24 | '/api/v1/video-channels/root_channel/videos', | ||
25 | '/api/v1/accounts/root/videos', | ||
26 | '/api/v1/videos', | ||
27 | '/api/v1/search/videos' | ||
28 | ] | ||
29 | |||
30 | const videosResults: Video[][] = [] | ||
31 | |||
32 | for (const path of paths) { | ||
33 | const res = await makeGetRequest({ | ||
34 | url: server.url, | ||
35 | path, | ||
36 | token, | ||
37 | query: { | ||
38 | sort: 'createdAt', | ||
39 | filter | ||
40 | }, | ||
41 | statusCodeExpected | ||
42 | }) | ||
43 | |||
44 | videosResults.push(res.body.data.map(v => v.name)) | ||
45 | } | ||
46 | |||
47 | return videosResults | ||
48 | } | ||
49 | |||
50 | describe('Test videos filter validator', function () { | ||
51 | let servers: ServerInfo[] | ||
52 | |||
53 | // --------------------------------------------------------------- | ||
54 | |||
55 | before(async function () { | ||
56 | this.timeout(120000) | ||
57 | |||
58 | await flushTests() | ||
59 | |||
60 | servers = await flushAndRunMultipleServers(2) | ||
61 | |||
62 | await setAccessTokensToServers(servers) | ||
63 | |||
64 | for (const server of servers) { | ||
65 | const moderator = { username: 'moderator', password: 'my super password' } | ||
66 | await createUser( | ||
67 | server.url, | ||
68 | server.accessToken, | ||
69 | moderator.username, | ||
70 | moderator.password, | ||
71 | undefined, | ||
72 | undefined, | ||
73 | UserRole.MODERATOR | ||
74 | ) | ||
75 | server['moderatorAccessToken'] = await userLogin(server, moderator) | ||
76 | |||
77 | await uploadVideo(server.url, server.accessToken, { name: 'public ' + server.serverNumber }) | ||
78 | |||
79 | { | ||
80 | const attributes = { name: 'unlisted ' + server.serverNumber, privacy: VideoPrivacy.UNLISTED } | ||
81 | await uploadVideo(server.url, server.accessToken, attributes) | ||
82 | } | ||
83 | |||
84 | { | ||
85 | const attributes = { name: 'private ' + server.serverNumber, privacy: VideoPrivacy.PRIVATE } | ||
86 | await uploadVideo(server.url, server.accessToken, attributes) | ||
87 | } | ||
88 | } | ||
89 | |||
90 | await doubleFollow(servers[0], servers[1]) | ||
91 | }) | ||
92 | |||
93 | describe('Check videos filter', function () { | ||
94 | |||
95 | it('Should display local videos', async function () { | ||
96 | for (const server of servers) { | ||
97 | const namesResults = await getVideosNames(server, server.accessToken, 'local') | ||
98 | for (const names of namesResults) { | ||
99 | expect(names).to.have.lengthOf(1) | ||
100 | expect(names[ 0 ]).to.equal('public ' + server.serverNumber) | ||
101 | } | ||
102 | } | ||
103 | }) | ||
104 | |||
105 | it('Should display all local videos by the admin or the moderator', async function () { | ||
106 | for (const server of servers) { | ||
107 | for (const token of [ server.accessToken, server['moderatorAccessToken'] ]) { | ||
108 | |||
109 | const namesResults = await getVideosNames(server, token, 'all-local') | ||
110 | for (const names of namesResults) { | ||
111 | expect(names).to.have.lengthOf(3) | ||
112 | |||
113 | expect(names[ 0 ]).to.equal('public ' + server.serverNumber) | ||
114 | expect(names[ 1 ]).to.equal('unlisted ' + server.serverNumber) | ||
115 | expect(names[ 2 ]).to.equal('private ' + server.serverNumber) | ||
116 | } | ||
117 | } | ||
118 | } | ||
119 | }) | ||
120 | }) | ||
121 | |||
122 | after(async function () { | ||
123 | killallServers(servers) | ||
124 | |||
125 | // Keep the logs if the test failed | ||
126 | if (this['ok']) { | ||
127 | await flushTests() | ||
128 | } | ||
129 | }) | ||
130 | }) | ||
diff --git a/server/tests/api/videos/videos-history.ts b/server/tests/api/videos/videos-history.ts new file mode 100644 index 000000000..6d289b288 --- /dev/null +++ b/server/tests/api/videos/videos-history.ts | |||
@@ -0,0 +1,128 @@ | |||
1 | /* tslint:disable:no-unused-expression */ | ||
2 | |||
3 | import * as chai from 'chai' | ||
4 | import 'mocha' | ||
5 | import { | ||
6 | flushTests, | ||
7 | getVideosListWithToken, | ||
8 | getVideoWithToken, | ||
9 | killallServers, makePutBodyRequest, | ||
10 | runServer, searchVideoWithToken, | ||
11 | ServerInfo, | ||
12 | setAccessTokensToServers, | ||
13 | uploadVideo | ||
14 | } from '../../utils' | ||
15 | import { Video, VideoDetails } from '../../../../shared/models/videos' | ||
16 | import { userWatchVideo } from '../../utils/videos/video-history' | ||
17 | |||
18 | const expect = chai.expect | ||
19 | |||
20 | describe('Test videos history', function () { | ||
21 | let server: ServerInfo = null | ||
22 | let video1UUID: string | ||
23 | let video2UUID: string | ||
24 | let video3UUID: string | ||
25 | |||
26 | before(async function () { | ||
27 | this.timeout(30000) | ||
28 | |||
29 | await flushTests() | ||
30 | |||
31 | server = await runServer(1) | ||
32 | |||
33 | await setAccessTokensToServers([ server ]) | ||
34 | |||
35 | { | ||
36 | const res = await uploadVideo(server.url, server.accessToken, { name: 'video 1' }) | ||
37 | video1UUID = res.body.video.uuid | ||
38 | } | ||
39 | |||
40 | { | ||
41 | const res = await uploadVideo(server.url, server.accessToken, { name: 'video 2' }) | ||
42 | video2UUID = res.body.video.uuid | ||
43 | } | ||
44 | |||
45 | { | ||
46 | const res = await uploadVideo(server.url, server.accessToken, { name: 'video 3' }) | ||
47 | video3UUID = res.body.video.uuid | ||
48 | } | ||
49 | }) | ||
50 | |||
51 | it('Should get videos, without watching history', async function () { | ||
52 | const res = await getVideosListWithToken(server.url, server.accessToken) | ||
53 | const videos: Video[] = res.body.data | ||
54 | |||
55 | for (const video of videos) { | ||
56 | const resDetail = await getVideoWithToken(server.url, server.accessToken, video.id) | ||
57 | const videoDetails: VideoDetails = resDetail.body | ||
58 | |||
59 | expect(video.userHistory).to.be.undefined | ||
60 | expect(videoDetails.userHistory).to.be.undefined | ||
61 | } | ||
62 | }) | ||
63 | |||
64 | it('Should watch the first and second video', async function () { | ||
65 | await userWatchVideo(server.url, server.accessToken, video1UUID, 3) | ||
66 | await userWatchVideo(server.url, server.accessToken, video2UUID, 8) | ||
67 | }) | ||
68 | |||
69 | it('Should return the correct history when listing, searching and getting videos', async function () { | ||
70 | const videosOfVideos: Video[][] = [] | ||
71 | |||
72 | { | ||
73 | const res = await getVideosListWithToken(server.url, server.accessToken) | ||
74 | videosOfVideos.push(res.body.data) | ||
75 | } | ||
76 | |||
77 | { | ||
78 | const res = await searchVideoWithToken(server.url, 'video', server.accessToken) | ||
79 | videosOfVideos.push(res.body.data) | ||
80 | } | ||
81 | |||
82 | for (const videos of videosOfVideos) { | ||
83 | const video1 = videos.find(v => v.uuid === video1UUID) | ||
84 | const video2 = videos.find(v => v.uuid === video2UUID) | ||
85 | const video3 = videos.find(v => v.uuid === video3UUID) | ||
86 | |||
87 | expect(video1.userHistory).to.not.be.undefined | ||
88 | expect(video1.userHistory.currentTime).to.equal(3) | ||
89 | |||
90 | expect(video2.userHistory).to.not.be.undefined | ||
91 | expect(video2.userHistory.currentTime).to.equal(8) | ||
92 | |||
93 | expect(video3.userHistory).to.be.undefined | ||
94 | } | ||
95 | |||
96 | { | ||
97 | const resDetail = await getVideoWithToken(server.url, server.accessToken, video1UUID) | ||
98 | const videoDetails: VideoDetails = resDetail.body | ||
99 | |||
100 | expect(videoDetails.userHistory).to.not.be.undefined | ||
101 | expect(videoDetails.userHistory.currentTime).to.equal(3) | ||
102 | } | ||
103 | |||
104 | { | ||
105 | const resDetail = await getVideoWithToken(server.url, server.accessToken, video2UUID) | ||
106 | const videoDetails: VideoDetails = resDetail.body | ||
107 | |||
108 | expect(videoDetails.userHistory).to.not.be.undefined | ||
109 | expect(videoDetails.userHistory.currentTime).to.equal(8) | ||
110 | } | ||
111 | |||
112 | { | ||
113 | const resDetail = await getVideoWithToken(server.url, server.accessToken, video3UUID) | ||
114 | const videoDetails: VideoDetails = resDetail.body | ||
115 | |||
116 | expect(videoDetails.userHistory).to.be.undefined | ||
117 | } | ||
118 | }) | ||
119 | |||
120 | after(async function () { | ||
121 | killallServers([ server ]) | ||
122 | |||
123 | // Keep the logs if the test failed | ||
124 | if (this['ok']) { | ||
125 | await flushTests() | ||
126 | } | ||
127 | }) | ||
128 | }) | ||
diff --git a/server/tests/utils/miscs/miscs.ts b/server/tests/utils/miscs/miscs.ts index b2f80e9b1..d20fa96b8 100644 --- a/server/tests/utils/miscs/miscs.ts +++ b/server/tests/utils/miscs/miscs.ts | |||
@@ -51,11 +51,13 @@ async function testImage (url: string, imageName: string, imagePath: string, ext | |||
51 | expect(data.length).to.be.below(maxLength) | 51 | expect(data.length).to.be.below(maxLength) |
52 | } | 52 | } |
53 | 53 | ||
54 | function buildAbsoluteFixturePath (path: string) { | 54 | function buildAbsoluteFixturePath (path: string, customTravisPath = false) { |
55 | if (isAbsolute(path)) { | 55 | if (isAbsolute(path)) { |
56 | return path | 56 | return path |
57 | } | 57 | } |
58 | 58 | ||
59 | if (customTravisPath && process.env.TRAVIS) return join(process.env.HOME, 'fixtures', path) | ||
60 | |||
59 | return join(__dirname, '..', '..', 'fixtures', path) | 61 | return join(__dirname, '..', '..', 'fixtures', path) |
60 | } | 62 | } |
61 | 63 | ||
diff --git a/server/tests/utils/server/follows.ts b/server/tests/utils/server/follows.ts index 8a65a958b..7741757a6 100644 --- a/server/tests/utils/server/follows.ts +++ b/server/tests/utils/server/follows.ts | |||
@@ -2,7 +2,7 @@ import * as request from 'supertest' | |||
2 | import { ServerInfo } from './servers' | 2 | import { ServerInfo } from './servers' |
3 | import { waitJobs } from './jobs' | 3 | import { waitJobs } from './jobs' |
4 | 4 | ||
5 | function getFollowersListPaginationAndSort (url: string, start: number, count: number, sort: string) { | 5 | function getFollowersListPaginationAndSort (url: string, start: number, count: number, sort: string, search?: string) { |
6 | const path = '/api/v1/server/followers' | 6 | const path = '/api/v1/server/followers' |
7 | 7 | ||
8 | return request(url) | 8 | return request(url) |
@@ -10,12 +10,13 @@ function getFollowersListPaginationAndSort (url: string, start: number, count: n | |||
10 | .query({ start }) | 10 | .query({ start }) |
11 | .query({ count }) | 11 | .query({ count }) |
12 | .query({ sort }) | 12 | .query({ sort }) |
13 | .query({ search }) | ||
13 | .set('Accept', 'application/json') | 14 | .set('Accept', 'application/json') |
14 | .expect(200) | 15 | .expect(200) |
15 | .expect('Content-Type', /json/) | 16 | .expect('Content-Type', /json/) |
16 | } | 17 | } |
17 | 18 | ||
18 | function getFollowingListPaginationAndSort (url: string, start: number, count: number, sort: string) { | 19 | function getFollowingListPaginationAndSort (url: string, start: number, count: number, sort: string, search?: string) { |
19 | const path = '/api/v1/server/following' | 20 | const path = '/api/v1/server/following' |
20 | 21 | ||
21 | return request(url) | 22 | return request(url) |
@@ -23,6 +24,7 @@ function getFollowingListPaginationAndSort (url: string, start: number, count: n | |||
23 | .query({ start }) | 24 | .query({ start }) |
24 | .query({ count }) | 25 | .query({ count }) |
25 | .query({ sort }) | 26 | .query({ sort }) |
27 | .query({ search }) | ||
26 | .set('Accept', 'application/json') | 28 | .set('Accept', 'application/json') |
27 | .expect(200) | 29 | .expect(200) |
28 | .expect('Content-Type', /json/) | 30 | .expect('Content-Type', /json/) |
diff --git a/server/tests/utils/users/users.ts b/server/tests/utils/users/users.ts index 41d8ce265..d77233d62 100644 --- a/server/tests/utils/users/users.ts +++ b/server/tests/utils/users/users.ts | |||
@@ -112,7 +112,7 @@ function getUsersList (url: string, accessToken: string) { | |||
112 | .expect('Content-Type', /json/) | 112 | .expect('Content-Type', /json/) |
113 | } | 113 | } |
114 | 114 | ||
115 | function getUsersListPaginationAndSort (url: string, accessToken: string, start: number, count: number, sort: string) { | 115 | function getUsersListPaginationAndSort (url: string, accessToken: string, start: number, count: number, sort: string, search?: string) { |
116 | const path = '/api/v1/users' | 116 | const path = '/api/v1/users' |
117 | 117 | ||
118 | return request(url) | 118 | return request(url) |
@@ -120,6 +120,7 @@ function getUsersListPaginationAndSort (url: string, accessToken: string, start: | |||
120 | .query({ start }) | 120 | .query({ start }) |
121 | .query({ count }) | 121 | .query({ count }) |
122 | .query({ sort }) | 122 | .query({ sort }) |
123 | .query({ search }) | ||
123 | .set('Accept', 'application/json') | 124 | .set('Accept', 'application/json') |
124 | .set('Authorization', 'Bearer ' + accessToken) | 125 | .set('Authorization', 'Bearer ' + accessToken) |
125 | .expect(200) | 126 | .expect(200) |
diff --git a/server/tests/utils/videos/video-history.ts b/server/tests/utils/videos/video-history.ts new file mode 100644 index 000000000..7635478f7 --- /dev/null +++ b/server/tests/utils/videos/video-history.ts | |||
@@ -0,0 +1,14 @@ | |||
1 | import { makePutBodyRequest } from '../requests/requests' | ||
2 | |||
3 | function userWatchVideo (url: string, token: string, videoId: number | string, currentTime: number) { | ||
4 | const path = '/api/v1/videos/' + videoId + '/watching' | ||
5 | const fields = { currentTime } | ||
6 | |||
7 | return makePutBodyRequest({ url, path, token, fields, statusCodeExpected: 204 }) | ||
8 | } | ||
9 | |||
10 | // --------------------------------------------------------------------------- | ||
11 | |||
12 | export { | ||
13 | userWatchVideo | ||
14 | } | ||
diff --git a/shared/models/actors/account.model.ts b/shared/models/actors/account.model.ts index e1117486d..7f1dbbc37 100644 --- a/shared/models/actors/account.model.ts +++ b/shared/models/actors/account.model.ts | |||
@@ -3,4 +3,6 @@ import { Actor } from './actor.model' | |||
3 | export interface Account extends Actor { | 3 | export interface Account extends Actor { |
4 | displayName: string | 4 | displayName: string |
5 | description: string | 5 | description: string |
6 | |||
7 | userId?: number | ||
6 | } | 8 | } |
diff --git a/shared/models/search/videos-search-query.model.ts b/shared/models/search/videos-search-query.model.ts index 29aa5c100..0db220758 100644 --- a/shared/models/search/videos-search-query.model.ts +++ b/shared/models/search/videos-search-query.model.ts | |||
@@ -1,4 +1,5 @@ | |||
1 | import { NSFWQuery } from './nsfw-query.model' | 1 | import { NSFWQuery } from './nsfw-query.model' |
2 | import { VideoFilter } from '../videos' | ||
2 | 3 | ||
3 | export interface VideosSearchQuery { | 4 | export interface VideosSearchQuery { |
4 | search?: string | 5 | search?: string |
@@ -23,4 +24,6 @@ export interface VideosSearchQuery { | |||
23 | 24 | ||
24 | durationMin?: number // seconds | 25 | durationMin?: number // seconds |
25 | durationMax?: number // seconds | 26 | durationMax?: number // seconds |
27 | |||
28 | filter?: VideoFilter | ||
26 | } | 29 | } |
diff --git a/shared/models/users/index.ts b/shared/models/users/index.ts index 15c2f99c2..7114741e0 100644 --- a/shared/models/users/index.ts +++ b/shared/models/users/index.ts | |||
@@ -7,3 +7,4 @@ export * from './user-update-me.model' | |||
7 | export * from './user-right.enum' | 7 | export * from './user-right.enum' |
8 | export * from './user-role' | 8 | export * from './user-role' |
9 | export * from './user-video-quota.model' | 9 | export * from './user-video-quota.model' |
10 | export * from './user-watching-video.model' | ||
diff --git a/shared/models/users/user-right.enum.ts b/shared/models/users/user-right.enum.ts index c4ccd632f..ed2c536ce 100644 --- a/shared/models/users/user-right.enum.ts +++ b/shared/models/users/user-right.enum.ts | |||
@@ -14,5 +14,6 @@ export enum UserRight { | |||
14 | REMOVE_ANY_VIDEO_CHANNEL, | 14 | REMOVE_ANY_VIDEO_CHANNEL, |
15 | REMOVE_ANY_VIDEO_COMMENT, | 15 | REMOVE_ANY_VIDEO_COMMENT, |
16 | UPDATE_ANY_VIDEO, | 16 | UPDATE_ANY_VIDEO, |
17 | SEE_ALL_VIDEOS, | ||
17 | CHANGE_VIDEO_OWNERSHIP | 18 | CHANGE_VIDEO_OWNERSHIP |
18 | } | 19 | } |
diff --git a/shared/models/users/user-role.ts b/shared/models/users/user-role.ts index 552aad999..d7020c0f2 100644 --- a/shared/models/users/user-role.ts +++ b/shared/models/users/user-role.ts | |||
@@ -26,7 +26,8 @@ const userRoleRights: { [ id: number ]: UserRight[] } = { | |||
26 | UserRight.REMOVE_ANY_VIDEO, | 26 | UserRight.REMOVE_ANY_VIDEO, |
27 | UserRight.REMOVE_ANY_VIDEO_CHANNEL, | 27 | UserRight.REMOVE_ANY_VIDEO_CHANNEL, |
28 | UserRight.REMOVE_ANY_VIDEO_COMMENT, | 28 | UserRight.REMOVE_ANY_VIDEO_COMMENT, |
29 | UserRight.UPDATE_ANY_VIDEO | 29 | UserRight.UPDATE_ANY_VIDEO, |
30 | UserRight.SEE_ALL_VIDEOS | ||
30 | ], | 31 | ], |
31 | 32 | ||
32 | [UserRole.USER]: [] | 33 | [UserRole.USER]: [] |
diff --git a/shared/models/users/user-watching-video.model.ts b/shared/models/users/user-watching-video.model.ts new file mode 100644 index 000000000..c22480595 --- /dev/null +++ b/shared/models/users/user-watching-video.model.ts | |||
@@ -0,0 +1,3 @@ | |||
1 | export interface UserWatchingVideo { | ||
2 | currentTime: number | ||
3 | } | ||
diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts index 90a0e3053..056ae06da 100644 --- a/shared/models/videos/index.ts +++ b/shared/models/videos/index.ts | |||
@@ -21,6 +21,7 @@ export * from './video-update.model' | |||
21 | export * from './video.model' | 21 | export * from './video.model' |
22 | export * from './video-query.type' | 22 | export * from './video-query.type' |
23 | export * from './video-state.enum' | 23 | export * from './video-state.enum' |
24 | export * from './video-transcoding-fps.model' | ||
24 | export * from './caption/video-caption.model' | 25 | export * from './caption/video-caption.model' |
25 | export * from './caption/video-caption-update.model' | 26 | export * from './caption/video-caption-update.model' |
26 | export * from './import/video-import-create.model' | 27 | export * from './import/video-import-create.model' |
diff --git a/shared/models/videos/video-query.type.ts b/shared/models/videos/video-query.type.ts index ff0f527f3..f76a91aad 100644 --- a/shared/models/videos/video-query.type.ts +++ b/shared/models/videos/video-query.type.ts | |||
@@ -1 +1 @@ | |||
export type VideoFilter = 'local' | export type VideoFilter = 'local' | 'all-local' | ||
diff --git a/shared/models/videos/video-resolution.enum.ts b/shared/models/videos/video-resolution.enum.ts index 100fc0e6e..e40e5b58b 100644 --- a/shared/models/videos/video-resolution.enum.ts +++ b/shared/models/videos/video-resolution.enum.ts | |||
@@ -1,3 +1,5 @@ | |||
1 | import { VideoTranscodingFPS } from './video-transcoding-fps.model' | ||
2 | |||
1 | export enum VideoResolution { | 3 | export enum VideoResolution { |
2 | H_240P = 240, | 4 | H_240P = 240, |
3 | H_360P = 360, | 5 | H_360P = 360, |
@@ -5,3 +7,56 @@ export enum VideoResolution { | |||
5 | H_720P = 720, | 7 | H_720P = 720, |
6 | H_1080P = 1080 | 8 | H_1080P = 1080 |
7 | } | 9 | } |
10 | |||
11 | /** | ||
12 | * Bitrate targets for different resolutions and frame rates, in bytes per second. | ||
13 | * Sources for individual quality levels: | ||
14 | * Google Live Encoder: https://support.google.com/youtube/answer/2853702?hl=en | ||
15 | * YouTube Video Info (tested with random music video): https://www.h3xed.com/blogmedia/youtube-info.php | ||
16 | */ | ||
17 | export function getTargetBitrate (resolution: VideoResolution, fps: number, | ||
18 | fpsTranscodingConstants: VideoTranscodingFPS) { | ||
19 | switch (resolution) { | ||
20 | case VideoResolution.H_240P: | ||
21 | // quality according to Google Live Encoder: 300 - 700 Kbps | ||
22 | // Quality according to YouTube Video Info: 186 Kbps | ||
23 | return 250 * 1000 | ||
24 | case VideoResolution.H_360P: | ||
25 | // quality according to Google Live Encoder: 400 - 1,000 Kbps | ||
26 | // Quality according to YouTube Video Info: 480 Kbps | ||
27 | return 500 * 1000 | ||
28 | case VideoResolution.H_480P: | ||
29 | // quality according to Google Live Encoder: 500 - 2,000 Kbps | ||
30 | // Quality according to YouTube Video Info: 879 Kbps | ||
31 | return 900 * 1000 | ||
32 | case VideoResolution.H_720P: | ||
33 | if (fps === fpsTranscodingConstants.MAX) { | ||
34 | // quality according to Google Live Encoder: 2,250 - 6,000 Kbps | ||
35 | // Quality according to YouTube Video Info: 2634 Kbps | ||
36 | return 2600 * 1000 | ||
37 | } | ||
38 | |||
39 | // quality according to Google Live Encoder: 1,500 - 4,000 Kbps | ||
40 | // Quality according to YouTube Video Info: 1752 Kbps | ||
41 | return 1750 * 1000 | ||
42 | case VideoResolution.H_1080P: // fallthrough | ||
43 | default: | ||
44 | if (fps === fpsTranscodingConstants.MAX) { | ||
45 | // quality according to Google Live Encoder: 3000 - 6000 Kbps | ||
46 | // Quality according to YouTube Video Info: 4387 Kbps | ||
47 | return 4400 * 1000 | ||
48 | } | ||
49 | |||
50 | // quality according to Google Live Encoder: 3000 - 6000 Kbps | ||
51 | // Quality according to YouTube Video Info: 3277 Kbps | ||
52 | return 3300 * 1000 | ||
53 | } | ||
54 | } | ||
55 | |||
56 | /** | ||
57 | * The maximum bitrate we expect to see on a transcoded video in bytes per second. | ||
58 | */ | ||
59 | export function getMaxBitrate (resolution: VideoResolution, fps: number, | ||
60 | fpsTranscodingConstants: VideoTranscodingFPS) { | ||
61 | return getTargetBitrate(resolution, fps, fpsTranscodingConstants) * 2 | ||
62 | } | ||
diff --git a/shared/models/videos/video-transcoding-fps.model.ts b/shared/models/videos/video-transcoding-fps.model.ts new file mode 100644 index 000000000..82022d2f1 --- /dev/null +++ b/shared/models/videos/video-transcoding-fps.model.ts | |||
@@ -0,0 +1,6 @@ | |||
1 | export type VideoTranscodingFPS = { | ||
2 | MIN: number, | ||
3 | AVERAGE: number, | ||
4 | MAX: number, | ||
5 | KEEP_ORIGIN_FPS_RESOLUTION_MIN: number | ||
6 | } | ||
diff --git a/shared/models/videos/video.model.ts b/shared/models/videos/video.model.ts index b47ab1ab8..4a9fa58b1 100644 --- a/shared/models/videos/video.model.ts +++ b/shared/models/videos/video.model.ts | |||
@@ -68,6 +68,10 @@ export interface Video { | |||
68 | 68 | ||
69 | account: AccountAttribute | 69 | account: AccountAttribute |
70 | channel: VideoChannelAttribute | 70 | channel: VideoChannelAttribute |
71 | |||
72 | userHistory?: { | ||
73 | currentTime: number | ||
74 | } | ||
71 | } | 75 | } |
72 | 76 | ||
73 | export interface VideoDetails extends Video { | 77 | export interface VideoDetails extends Video { |
diff --git a/support/doc/tools.md b/support/doc/tools.md index 1db29edc0..8efb0c13d 100644 --- a/support/doc/tools.md +++ b/support/doc/tools.md | |||
@@ -187,6 +187,17 @@ To delete them (a confirmation will be demanded first): | |||
187 | $ sudo -u peertube NODE_CONFIG_DIR=/var/www/peertube/config NODE_ENV=production npm run prune-storage | 187 | $ sudo -u peertube NODE_CONFIG_DIR=/var/www/peertube/config NODE_ENV=production npm run prune-storage |
188 | ``` | 188 | ``` |
189 | 189 | ||
190 | ### optimize-old-videos.js | ||
191 | |||
192 | Before version v1.0.0-beta.16, Peertube did not specify a bitrate for the transcoding of uploaded videos. | ||
193 | This means that videos might be encoded into very large files that are too large for streaming. This script | ||
194 | re-transcodes these videos so that they can be watched properly, even on slow connections. | ||
195 | |||
196 | ``` | ||
197 | $ sudo -u peertube NODE_CONFIG_DIR=/var/www/peertube/config NODE_ENV=production npm run optimize-old-videos | ||
198 | ``` | ||
199 | |||
200 | |||
190 | ### update-host.js | 201 | ### update-host.js |
191 | 202 | ||
192 | If you started PeerTube with a domain, and then changed it you will have invalid torrent files and invalid URLs in your database. | 203 | If you started PeerTube with a domain, and then changed it you will have invalid torrent files and invalid URLs in your database. |
diff --git a/support/docker/dev/Dockerfile b/support/docker/dev/Dockerfile index 2b4f2b215..aa4a8a3d6 100644 --- a/support/docker/dev/Dockerfile +++ b/support/docker/dev/Dockerfile | |||
@@ -1,32 +1,48 @@ | |||
1 | FROM janitortechnology/ubuntu-dev | 1 | FROM ubuntu:bionic |
2 | |||
3 | # Avoid tzdata interactive dialog | ||
4 | ENV DEBIAN_FRONTEND=noninteractive | ||
2 | 5 | ||
3 | # Install PeerTube's dependencies. | 6 | # Install PeerTube's dependencies. |
4 | # Packages are from https://github.com/Chocobozzz/PeerTube#dependencies | 7 | # Packages are from https://github.com/Chocobozzz/PeerTube#dependencies |
5 | RUN sudo apt-get update -q && sudo apt-get install -qy \ | 8 | RUN apt-get update -q && apt-get install -qy \ |
9 | curl \ | ||
10 | nano \ | ||
6 | ffmpeg \ | 11 | ffmpeg \ |
7 | postgresql \ | 12 | postgresql \ |
8 | openssl | 13 | postgresql-contrib \ |
14 | openssl \ | ||
15 | g++ \ | ||
16 | make \ | ||
17 | redis-server \ | ||
18 | git \ | ||
19 | gnupg | ||
20 | |||
21 | # Install NodeJS 8.x | ||
22 | RUN curl -sL https://deb.nodesource.com/setup_8.x | bash - && \ | ||
23 | apt-get install -y nodejs | ||
24 | |||
25 | # Install Yarn | ||
26 | RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \ | ||
27 | echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \ | ||
28 | apt-get update && apt-get install yarn | ||
9 | 29 | ||
10 | # Download PeerTube's source code. | 30 | # Download PeerTube's source code. |
11 | RUN git clone -b develop https://github.com/Chocobozzz/PeerTube /home/user/PeerTube | 31 | RUN git clone -b develop https://github.com/Chocobozzz/PeerTube /home/user/PeerTube |
12 | WORKDIR /home/user/PeerTube | 32 | WORKDIR /home/user/PeerTube |
13 | 33 | ||
14 | # Configure the IDEs to use Janitor's source directory as workspace. | ||
15 | ENV WORKSPACE /home/user/PeerTube/ | ||
16 | |||
17 | # Install dependencies. | 34 | # Install dependencies. |
18 | RUN yarn install --pure-lockfile | 35 | RUN yarn install --pure-lockfile |
19 | 36 | ||
20 | # Configure Janitor for PeerTube. | 37 | # Configure and run PeerTube. |
21 | COPY --chown=user:user janitor.json /home/user/ | 38 | COPY setup_postgres.sql /tmp/ |
39 | RUN service postgresql start \ | ||
40 | && su postgres -c "psql --file=/tmp/setup_postgres.sql" | ||
22 | 41 | ||
23 | # Configure and build PeerTube. | 42 | # Expose PeerTube sources as a volume |
24 | COPY create_user.sql /tmp/ | 43 | VOLUME /home/user/PeerTube |
25 | RUN sudo service postgresql start \ | ||
26 | && sudo -u postgres psql --file=/tmp/create_user.sql \ | ||
27 | && npm run build | ||
28 | |||
29 | COPY --chown=user:user supervisord.conf /tmp/supervisord-extra.conf | ||
30 | RUN cat /tmp/supervisord-extra.conf | sudo tee -a /etc/supervisord.conf | ||
31 | 44 | ||
32 | EXPOSE 3000 9000 | 45 | EXPOSE 3000 9000 |
46 | |||
47 | # Start PostgreSQL and Redis | ||
48 | CMD service postgresql start && redis-server | ||
diff --git a/support/docker/dev/setup_postgres.sql b/support/docker/dev/setup_postgres.sql new file mode 100644 index 000000000..0937f9d19 --- /dev/null +++ b/support/docker/dev/setup_postgres.sql | |||
@@ -0,0 +1,6 @@ | |||
1 | create database peertube_dev; | ||
2 | create user peertube password 'peertube'; | ||
3 | grant all privileges on database peertube_dev to peertube; | ||
4 | \c peertube_dev | ||
5 | CREATE EXTENSION pg_trgm; | ||
6 | CREATE EXTENSION unaccent; | ||
diff --git a/support/docker/dev/usage.md b/support/docker/dev/usage.md new file mode 100644 index 000000000..319d7db30 --- /dev/null +++ b/support/docker/dev/usage.md | |||
@@ -0,0 +1,20 @@ | |||
1 | ### Usage | ||
2 | 1. Build the image: | ||
3 | ``` | ||
4 | docker build -t my_peertube_dev . | ||
5 | ``` | ||
6 | 1. Start the container: | ||
7 | ``` | ||
8 | docker run -d -i -p 3000:3000 -p 9000:9000 --name peertube my_peertube_dev | ||
9 | ``` | ||
10 | This will create a new Docker volume containing PeerTube sources. | ||
11 | |||
12 | 1. Start PeerTube inside the container: | ||
13 | ``` | ||
14 | docker exec -it peertube npm run dev | ||
15 | ``` | ||
16 | 1. In another window, find the path to the Docker volume | ||
17 | ``` | ||
18 | docker inspect peertube | less +/Mounts | ||
19 | ``` | ||
20 | You can now make changes to the files. They should be automatically recompiled. | ||
diff --git a/support/docker/janitor/Dockerfile b/support/docker/janitor/Dockerfile new file mode 100644 index 000000000..2b4f2b215 --- /dev/null +++ b/support/docker/janitor/Dockerfile | |||
@@ -0,0 +1,32 @@ | |||
1 | FROM janitortechnology/ubuntu-dev | ||
2 | |||
3 | # Install PeerTube's dependencies. | ||
4 | # Packages are from https://github.com/Chocobozzz/PeerTube#dependencies | ||
5 | RUN sudo apt-get update -q && sudo apt-get install -qy \ | ||
6 | ffmpeg \ | ||
7 | postgresql \ | ||
8 | openssl | ||
9 | |||
10 | # Download PeerTube's source code. | ||
11 | RUN git clone -b develop https://github.com/Chocobozzz/PeerTube /home/user/PeerTube | ||
12 | WORKDIR /home/user/PeerTube | ||
13 | |||
14 | # Configure the IDEs to use Janitor's source directory as workspace. | ||
15 | ENV WORKSPACE /home/user/PeerTube/ | ||
16 | |||
17 | # Install dependencies. | ||
18 | RUN yarn install --pure-lockfile | ||
19 | |||
20 | # Configure Janitor for PeerTube. | ||
21 | COPY --chown=user:user janitor.json /home/user/ | ||
22 | |||
23 | # Configure and build PeerTube. | ||
24 | COPY create_user.sql /tmp/ | ||
25 | RUN sudo service postgresql start \ | ||
26 | && sudo -u postgres psql --file=/tmp/create_user.sql \ | ||
27 | && npm run build | ||
28 | |||
29 | COPY --chown=user:user supervisord.conf /tmp/supervisord-extra.conf | ||
30 | RUN cat /tmp/supervisord-extra.conf | sudo tee -a /etc/supervisord.conf | ||
31 | |||
32 | EXPOSE 3000 9000 | ||
diff --git a/support/docker/dev/create_user.sql b/support/docker/janitor/create_user.sql index c2fbcf27e..c2fbcf27e 100644 --- a/support/docker/dev/create_user.sql +++ b/support/docker/janitor/create_user.sql | |||
diff --git a/support/docker/dev/janitor.json b/support/docker/janitor/janitor.json index 5acdf3060..5acdf3060 100644 --- a/support/docker/dev/janitor.json +++ b/support/docker/janitor/janitor.json | |||
diff --git a/support/docker/dev/supervisord.conf b/support/docker/janitor/supervisord.conf index b2e1682df..b2e1682df 100644 --- a/support/docker/dev/supervisord.conf +++ b/support/docker/janitor/supervisord.conf | |||
diff --git a/support/docker/production/config/custom-environment-variables.yaml b/support/docker/production/config/custom-environment-variables.yaml index daf885813..1f7fbf849 100644 --- a/support/docker/production/config/custom-environment-variables.yaml +++ b/support/docker/production/config/custom-environment-variables.yaml | |||
@@ -56,6 +56,26 @@ signup: | |||
56 | __name: "PEERTUBE_SIGNUP_LIMIT" | 56 | __name: "PEERTUBE_SIGNUP_LIMIT" |
57 | __format: "json" | 57 | __format: "json" |
58 | 58 | ||
59 | search: | ||
60 | remote_uri: | ||
61 | users: | ||
62 | __name: "PEERTUBE_SEARCH_REMOTEURI_USERS" | ||
63 | __format: "json" | ||
64 | anonymous: | ||
65 | __name: "PEERTUBE_SEARCH_REMOTEURI_ANONYMOUS" | ||
66 | __format: "json" | ||
67 | |||
68 | import: | ||
69 | videos: | ||
70 | http: | ||
71 | enabled: | ||
72 | __name: "PEERTUBE_IMPORT_VIDEOS_HTTP" | ||
73 | __format: "json" | ||
74 | torrent: | ||
75 | enabled: | ||
76 | __name: "PEERTUBE_IMPORT_VIDEOS_TORRENT" | ||
77 | __format: "json" | ||
78 | |||
59 | transcoding: | 79 | transcoding: |
60 | enabled: | 80 | enabled: |
61 | __name: "PEERTUBE_TRANSCODING_ENABLED" | 81 | __name: "PEERTUBE_TRANSCODING_ENABLED" |