diff options
author | Brad Johnson <bradsk88@gmail.com> | 2018-08-31 09:19:21 -0600 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2018-08-31 17:19:21 +0200 |
commit | 7f5f4152a4cd4fc328d6ae177d281ebe7e792dd3 (patch) | |
tree | fd0401bd9c43e1adbbedbd2042c93bd4fef46632 | |
parent | 1a4710914432b44115b185cec1883fdf409aef1d (diff) | |
download | PeerTube-7f5f4152a4cd4fc328d6ae177d281ebe7e792dd3.tar.gz PeerTube-7f5f4152a4cd4fc328d6ae177d281ebe7e792dd3.tar.zst PeerTube-7f5f4152a4cd4fc328d6ae177d281ebe7e792dd3.zip |
Refactor: Separated "Other Videos" section into a dedicated component/service (#969)
* Separated "Other Videos" section into a dedicated component/service
I'm currently working on some proof-of-concepts for recommendation
providers that could work with PeerTube to provide useful video
suggestions to the user.
As a first step, I want to have great clarity about how PeerTube,
itself, will surface these videos to the user.
With this branch, I'm refactoring the "recommendations" to make it
easier to swap out different recommender implementations quickly.
Stop recommender from including the video that's being watched.
Ensure always 5 recommendations
* Treat recommendations as a stream of values, rather than a single async value.
* Prioritize readability over HTTP response size early-optimization.
* Simplify pipe
16 files changed, 278 insertions, 38 deletions
diff --git a/.gitignore b/.gitignore index 75a8a2786..22478c444 100644 --- a/.gitignore +++ b/.gitignore | |||
@@ -25,7 +25,7 @@ | |||
25 | # IDE | 25 | # IDE |
26 | /*.sublime-project | 26 | /*.sublime-project |
27 | /*.sublime-workspace | 27 | /*.sublime-workspace |
28 | /.idea | 28 | /**/.idea |
29 | /dist | 29 | /dist |
30 | /PeerTube.iml | 30 | /PeerTube.iml |
31 | 31 | ||
diff --git a/.travis.yml b/.travis.yml index 78e25cf45..9fd54447c 100644 --- a/.travis.yml +++ b/.travis.yml | |||
@@ -41,6 +41,7 @@ matrix: | |||
41 | - env: TEST_SUITE=api-3 | 41 | - env: TEST_SUITE=api-3 |
42 | - env: TEST_SUITE=cli | 42 | - env: TEST_SUITE=cli |
43 | - env: TEST_SUITE=lint | 43 | - env: TEST_SUITE=lint |
44 | - env: TEST_SUITE=jest | ||
44 | 45 | ||
45 | script: | 46 | script: |
46 | - travis_retry npm run travis -- "$TEST_SUITE" | 47 | - travis_retry npm run travis -- "$TEST_SUITE" |
diff --git a/client/package.json b/client/package.json index 26583faba..12da38175 100644 --- a/client/package.json +++ b/client/package.json | |||
@@ -20,7 +20,8 @@ | |||
20 | "postinstall": "npm rebuild node-sass", | 20 | "postinstall": "npm rebuild node-sass", |
21 | "webpack-bundle-analyzer": "webpack-bundle-analyzer", | 21 | "webpack-bundle-analyzer": "webpack-bundle-analyzer", |
22 | "webdriver-manager": "webdriver-manager", | 22 | "webdriver-manager": "webdriver-manager", |
23 | "ngx-extractor": "ngx-extractor" | 23 | "ngx-extractor": "ngx-extractor", |
24 | "test": "jest" | ||
24 | }, | 25 | }, |
25 | "license": "GPLv3", | 26 | "license": "GPLv3", |
26 | "typings": "*.d.ts", | 27 | "typings": "*.d.ts", |
@@ -29,6 +30,23 @@ | |||
29 | "webtorrent/create-torrent/junk": "^1", | 30 | "webtorrent/create-torrent/junk": "^1", |
30 | "simple-get": "^2.8.1" | 31 | "simple-get": "^2.8.1" |
31 | }, | 32 | }, |
33 | "jest": { | ||
34 | "transform": { | ||
35 | "^.+\\.tsx?$": "ts-jest" | ||
36 | }, | ||
37 | "moduleNameMapper": { | ||
38 | "^@app/(.*)": "<rootDir>/src/app/$1" | ||
39 | }, | ||
40 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", | ||
41 | "moduleFileExtensions": [ | ||
42 | "ts", | ||
43 | "tsx", | ||
44 | "js", | ||
45 | "jsx", | ||
46 | "json", | ||
47 | "node" | ||
48 | ] | ||
49 | }, | ||
32 | "devDependencies": { | 50 | "devDependencies": { |
33 | "@angular-devkit/build-angular": "^0.7.5", | 51 | "@angular-devkit/build-angular": "^0.7.5", |
34 | "@angular/animations": "~6.1.4", | 52 | "@angular/animations": "~6.1.4", |
@@ -55,6 +73,7 @@ | |||
55 | "@types/core-js": "^2.5.0", | 73 | "@types/core-js": "^2.5.0", |
56 | "@types/jasmine": "^2.8.7", | 74 | "@types/jasmine": "^2.8.7", |
57 | "@types/jasminewd2": "^2.0.3", | 75 | "@types/jasminewd2": "^2.0.3", |
76 | "@types/jest": "^23.3.1", | ||
58 | "@types/jschannel": "^1.0.0", | 77 | "@types/jschannel": "^1.0.0", |
59 | "@types/lodash-es": "^4.17.0", | 78 | "@types/lodash-es": "^4.17.0", |
60 | "@types/markdown-it": "^0.0.5", | 79 | "@types/markdown-it": "^0.0.5", |
@@ -79,6 +98,7 @@ | |||
79 | "https-browserify": "^1.0.0", | 98 | "https-browserify": "^1.0.0", |
80 | "jasmine-core": "^3.1.0", | 99 | "jasmine-core": "^3.1.0", |
81 | "jasmine-spec-reporter": "^4.2.1", | 100 | "jasmine-spec-reporter": "^4.2.1", |
101 | "jest": "^23.5.0", | ||
82 | "jschannel": "^1.0.2", | 102 | "jschannel": "^1.0.2", |
83 | "karma": "^3.0.0", | 103 | "karma": "^3.0.0", |
84 | "karma-chrome-launcher": "^2.2.0", | 104 | "karma-chrome-launcher": "^2.2.0", |
@@ -108,6 +128,7 @@ | |||
108 | "sass-loader": "^7.1.0", | 128 | "sass-loader": "^7.1.0", |
109 | "sass-resources-loader": "^1.2.1", | 129 | "sass-resources-loader": "^1.2.1", |
110 | "stream-http": "^2.8.3", | 130 | "stream-http": "^2.8.3", |
131 | "ts-jest": "^23.1.4", | ||
111 | "tslint": "^5.7.0", | 132 | "tslint": "^5.7.0", |
112 | "tslint-config-standard": "^7.0.0", | 133 | "tslint-config-standard": "^7.0.0", |
113 | "typescript": "2.9", | 134 | "typescript": "2.9", |
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts index 7cc98c77a..5c0674e58 100644 --- a/client/src/app/shared/video/video.service.ts +++ b/client/src/app/shared/video/video.service.ts | |||
@@ -23,8 +23,17 @@ import { ServerService } from '@app/core' | |||
23 | import { UserSubscriptionService } from '@app/shared/user-subscription' | 23 | import { UserSubscriptionService } from '@app/shared/user-subscription' |
24 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | 24 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' |
25 | 25 | ||
26 | export interface VideosProvider { | ||
27 | getVideos ( | ||
28 | videoPagination: ComponentPagination, | ||
29 | sort: VideoSortField, | ||
30 | filter?: VideoFilter, | ||
31 | categoryOneOf?: number | ||
32 | ): Observable<{ videos: Video[], totalVideos: number }> | ||
33 | } | ||
34 | |||
26 | @Injectable() | 35 | @Injectable() |
27 | export class VideoService { | 36 | export class VideoService implements VideosProvider { |
28 | static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' | 37 | static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' |
29 | static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.' | 38 | static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.' |
30 | 39 | ||
diff --git a/client/src/app/videos/+video-watch/video-watch.component.html b/client/src/app/videos/+video-watch/video-watch.component.html index 2c8305777..16d657a65 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.html +++ b/client/src/app/videos/+video-watch/video-watch.component.html | |||
@@ -197,19 +197,10 @@ | |||
197 | </div> | 197 | </div> |
198 | </div> | 198 | </div> |
199 | 199 | ||
200 | <my-video-comments [video]="video" [user]="user"></my-video-comments> | 200 | <my-video-comments [video]="video" [user]="user"></my-video-comments> |
201 | </div> | ||
202 | |||
203 | <div class="ml-3 ml-sm-0 col-12 col-md-3 other-videos"> | ||
204 | <div i18n class="title-page title-page-single"> | ||
205 | Other videos | ||
206 | </div> | ||
207 | |||
208 | <div *ngFor="let video of otherVideosDisplayed"> | ||
209 | <my-video-miniature [video]="video" [user]="user"></my-video-miniature> | ||
210 | </div> | ||
211 | </div> | ||
212 | </div> | 201 | </div> |
202 | <my-recommended-videos class="ml-3 ml-sm-0 col-12 col-md-3" | ||
203 | [inputVideo]="video" [user]="user"></my-recommended-videos> | ||
213 | </div> | 204 | </div> |
214 | 205 | ||
215 | 206 | ||
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 d838ebe79..25643cfde 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.ts +++ b/client/src/app/videos/+video-watch/video-watch.component.ts | |||
@@ -15,7 +15,6 @@ import '../../../assets/player/peertube-videojs-plugin' | |||
15 | import { AuthService, ConfirmService } from '../../core' | 15 | import { AuthService, ConfirmService } from '../../core' |
16 | import { RestExtractor, VideoBlacklistService } from '../../shared' | 16 | import { RestExtractor, VideoBlacklistService } from '../../shared' |
17 | import { VideoDetails } from '../../shared/video/video-details.model' | 17 | import { VideoDetails } from '../../shared/video/video-details.model' |
18 | import { Video } from '../../shared/video/video.model' | ||
19 | import { VideoService } from '../../shared/video/video.service' | 18 | import { VideoService } from '../../shared/video/video.service' |
20 | import { MarkdownService } from '../shared' | 19 | import { MarkdownService } from '../shared' |
21 | import { VideoDownloadComponent } from './modal/video-download.component' | 20 | import { VideoDownloadComponent } from './modal/video-download.component' |
@@ -43,8 +42,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
43 | @ViewChild('videoSupportModal') videoSupportModal: VideoSupportComponent | 42 | @ViewChild('videoSupportModal') videoSupportModal: VideoSupportComponent |
44 | @ViewChild('videoBlacklistModal') videoBlacklistModal: VideoBlacklistComponent | 43 | @ViewChild('videoBlacklistModal') videoBlacklistModal: VideoBlacklistComponent |
45 | 44 | ||
46 | otherVideosDisplayed: Video[] = [] | ||
47 | |||
48 | player: videojs.Player | 45 | player: videojs.Player |
49 | playerElement: HTMLVideoElement | 46 | playerElement: HTMLVideoElement |
50 | userRating: UserVideoRateType = null | 47 | userRating: UserVideoRateType = null |
@@ -60,7 +57,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
60 | remoteServerDown = false | 57 | remoteServerDown = false |
61 | 58 | ||
62 | private videojsLocaleLoaded = false | 59 | private videojsLocaleLoaded = false |
63 | private otherVideos: Video[] = [] | ||
64 | private paramsSub: Subscription | 60 | private paramsSub: Subscription |
65 | 61 | ||
66 | constructor ( | 62 | constructor ( |
@@ -96,16 +92,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
96 | this.hasAlreadyAcceptedPrivacyConcern = true | 92 | this.hasAlreadyAcceptedPrivacyConcern = true |
97 | } | 93 | } |
98 | 94 | ||
99 | this.videoService.getVideos({ currentPage: 1, itemsPerPage: 5 }, '-createdAt') | ||
100 | .subscribe( | ||
101 | data => { | ||
102 | this.otherVideos = data.videos | ||
103 | this.updateOtherVideosDisplayed() | ||
104 | }, | ||
105 | |||
106 | err => console.error(err) | ||
107 | ) | ||
108 | |||
109 | this.paramsSub = this.route.params.subscribe(routeParams => { | 95 | this.paramsSub = this.route.params.subscribe(routeParams => { |
110 | const uuid = routeParams[ 'uuid' ] | 96 | const uuid = routeParams[ 'uuid' ] |
111 | 97 | ||
@@ -365,8 +351,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
365 | this.completeDescriptionShown = false | 351 | this.completeDescriptionShown = false |
366 | this.remoteServerDown = false | 352 | this.remoteServerDown = false |
367 | 353 | ||
368 | this.updateOtherVideosDisplayed() | ||
369 | |||
370 | if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) { | 354 | if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) { |
371 | const res = await this.confirmService.confirm( | 355 | const res = await this.confirmService.confirm( |
372 | this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'), | 356 | this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'), |
@@ -474,12 +458,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
474 | this.setVideoLikesBarTooltipText() | 458 | this.setVideoLikesBarTooltipText() |
475 | } | 459 | } |
476 | 460 | ||
477 | private updateOtherVideosDisplayed () { | ||
478 | if (this.video && this.otherVideos && this.otherVideos.length > 0) { | ||
479 | this.otherVideosDisplayed = this.otherVideos.filter(v => v.uuid !== this.video.uuid) | ||
480 | } | ||
481 | } | ||
482 | |||
483 | private setOpenGraphTags () { | 461 | private setOpenGraphTags () { |
484 | this.metaService.setTitle(this.video.name) | 462 | this.metaService.setTitle(this.video.name) |
485 | 463 | ||
diff --git a/client/src/app/videos/+video-watch/video-watch.module.ts b/client/src/app/videos/+video-watch/video-watch.module.ts index 7920147b2..5582ab40f 100644 --- a/client/src/app/videos/+video-watch/video-watch.module.ts +++ b/client/src/app/videos/+video-watch/video-watch.module.ts | |||
@@ -16,6 +16,7 @@ import { VideoWatchComponent } from './video-watch.component' | |||
16 | import { NgxQRCodeModule } from 'ngx-qrcode2' | 16 | import { NgxQRCodeModule } from 'ngx-qrcode2' |
17 | import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' | 17 | import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' |
18 | import { VideoBlacklistComponent } from '@app/videos/+video-watch/modal/video-blacklist.component' | 18 | import { VideoBlacklistComponent } from '@app/videos/+video-watch/modal/video-blacklist.component' |
19 | import { RecommendationsModule } from '@app/videos/recommendations/recommendations.module' | ||
19 | import { TextareaAutosizeModule } from 'ngx-textarea-autosize' | 20 | import { TextareaAutosizeModule } from 'ngx-textarea-autosize' |
20 | 21 | ||
21 | @NgModule({ | 22 | @NgModule({ |
@@ -25,7 +26,8 @@ import { TextareaAutosizeModule } from 'ngx-textarea-autosize' | |||
25 | ClipboardModule, | 26 | ClipboardModule, |
26 | NgbTooltipModule, | 27 | NgbTooltipModule, |
27 | NgxQRCodeModule, | 28 | NgxQRCodeModule, |
28 | TextareaAutosizeModule | 29 | TextareaAutosizeModule, |
30 | RecommendationsModule | ||
29 | ], | 31 | ], |
30 | 32 | ||
31 | declarations: [ | 33 | declarations: [ |
diff --git a/client/src/app/videos/recommendations/recent-videos-recommendation.service.spec.ts b/client/src/app/videos/recommendations/recent-videos-recommendation.service.spec.ts new file mode 100644 index 000000000..f9055b82c --- /dev/null +++ b/client/src/app/videos/recommendations/recent-videos-recommendation.service.spec.ts | |||
@@ -0,0 +1,66 @@ | |||
1 | import { RecentVideosRecommendationService } from '@app/videos/recommendations/recent-videos-recommendation.service' | ||
2 | import { VideosProvider } from '@app/shared/video/video.service' | ||
3 | import { EMPTY, of } from 'rxjs' | ||
4 | import Mock = jest.Mock | ||
5 | |||
6 | describe('"Recent Videos" Recommender', () => { | ||
7 | describe('getRecommendations', () => { | ||
8 | let videosService: VideosProvider | ||
9 | let service: RecentVideosRecommendationService | ||
10 | let getVideosMock: Mock<any> | ||
11 | beforeEach(() => { | ||
12 | getVideosMock = jest.fn(() => EMPTY) | ||
13 | videosService = { | ||
14 | getVideos: getVideosMock | ||
15 | } | ||
16 | service = new RecentVideosRecommendationService(videosService) | ||
17 | }) | ||
18 | it('should filter out the given UUID from the results', async (done) => { | ||
19 | const vids = [ | ||
20 | { uuid: 'uuid1' }, | ||
21 | { uuid: 'uuid2' } | ||
22 | ] | ||
23 | getVideosMock.mockReturnValueOnce(of({ videos: vids })) | ||
24 | const result = await service.getRecommendations('uuid1').toPromise() | ||
25 | const uuids = result.map(v => v.uuid) | ||
26 | expect(uuids).toEqual(['uuid2']) | ||
27 | done() | ||
28 | }) | ||
29 | it('should return 5 results when the given UUID is NOT in the first 5 results', async (done) => { | ||
30 | const vids = [ | ||
31 | { uuid: 'uuid2' }, | ||
32 | { uuid: 'uuid3' }, | ||
33 | { uuid: 'uuid4' }, | ||
34 | { uuid: 'uuid5' }, | ||
35 | { uuid: 'uuid6' }, | ||
36 | { uuid: 'uuid7' } | ||
37 | ] | ||
38 | getVideosMock.mockReturnValueOnce(of({ videos: vids })) | ||
39 | const result = await service.getRecommendations('uuid1').toPromise() | ||
40 | expect(result.length).toEqual(5) | ||
41 | done() | ||
42 | }) | ||
43 | it('should return 5 results when the given UUID IS PRESENT in the first 5 results', async (done) => { | ||
44 | const vids = [ | ||
45 | { uuid: 'uuid1' }, | ||
46 | { uuid: 'uuid2' }, | ||
47 | { uuid: 'uuid3' }, | ||
48 | { uuid: 'uuid4' }, | ||
49 | { uuid: 'uuid5' }, | ||
50 | { uuid: 'uuid6' } | ||
51 | ] | ||
52 | getVideosMock | ||
53 | .mockReturnValueOnce(of({ videos: vids })) | ||
54 | const result = await service.getRecommendations('uuid1').toPromise() | ||
55 | expect(result.length).toEqual(5) | ||
56 | done() | ||
57 | }) | ||
58 | it('should fetch an extra result in case the given UUID is in the list', async (done) => { | ||
59 | await service.getRecommendations('uuid1').toPromise() | ||
60 | let expectedSize = service.pageSize + 1 | ||
61 | let params = { currentPage: jasmine.anything(), itemsPerPage: expectedSize } | ||
62 | expect(getVideosMock).toHaveBeenCalledWith(params, jasmine.anything()) | ||
63 | done() | ||
64 | }) | ||
65 | }) | ||
66 | }) | ||
diff --git a/client/src/app/videos/recommendations/recent-videos-recommendation.service.ts b/client/src/app/videos/recommendations/recent-videos-recommendation.service.ts new file mode 100644 index 000000000..708d67699 --- /dev/null +++ b/client/src/app/videos/recommendations/recent-videos-recommendation.service.ts | |||
@@ -0,0 +1,39 @@ | |||
1 | import { Inject, Injectable } from '@angular/core' | ||
2 | import { RecommendationService } from '@app/videos/recommendations/recommendations.service' | ||
3 | import { Video } from '@app/shared/video/video.model' | ||
4 | import { VideoService, VideosProvider } from '@app/shared/video/video.service' | ||
5 | import { map } from 'rxjs/operators' | ||
6 | import { Observable } from 'rxjs' | ||
7 | |||
8 | /** | ||
9 | * Provides "recommendations" by providing the most recently uploaded videos. | ||
10 | */ | ||
11 | @Injectable() | ||
12 | export class RecentVideosRecommendationService implements RecommendationService { | ||
13 | |||
14 | readonly pageSize = 5 | ||
15 | |||
16 | constructor ( | ||
17 | @Inject(VideoService) private videos: VideosProvider | ||
18 | ) { | ||
19 | } | ||
20 | |||
21 | getRecommendations (uuid: string): Observable<Video[]> { | ||
22 | return this.fetchPage(1) | ||
23 | .pipe( | ||
24 | map(vids => { | ||
25 | const otherVideos = vids.filter(v => v.uuid !== uuid) | ||
26 | return otherVideos.slice(0, this.pageSize) | ||
27 | }) | ||
28 | ) | ||
29 | } | ||
30 | |||
31 | private fetchPage (page: number): Observable<Video[]> { | ||
32 | let pagination = { currentPage: page, itemsPerPage: this.pageSize + 1 } | ||
33 | return this.videos.getVideos(pagination, '-createdAt') | ||
34 | .pipe( | ||
35 | map(v => v.videos) | ||
36 | ) | ||
37 | } | ||
38 | |||
39 | } | ||
diff --git a/client/src/app/videos/recommendations/recommendations.module.ts b/client/src/app/videos/recommendations/recommendations.module.ts new file mode 100644 index 000000000..5a46ea739 --- /dev/null +++ b/client/src/app/videos/recommendations/recommendations.module.ts | |||
@@ -0,0 +1,25 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { RecommendedVideosComponent } from '@app/videos/recommendations/recommended-videos.component' | ||
3 | import { RecommendedVideosStore } from '@app/videos/recommendations/recommended-videos.store' | ||
4 | import { CommonModule } from '@angular/common' | ||
5 | import { SharedModule } from '@app/shared' | ||
6 | import { RecentVideosRecommendationService } from '@app/videos/recommendations/recent-videos-recommendation.service' | ||
7 | |||
8 | @NgModule({ | ||
9 | imports: [ | ||
10 | SharedModule, | ||
11 | CommonModule | ||
12 | ], | ||
13 | declarations: [ | ||
14 | RecommendedVideosComponent | ||
15 | ], | ||
16 | exports: [ | ||
17 | RecommendedVideosComponent | ||
18 | ], | ||
19 | providers: [ | ||
20 | RecommendedVideosStore, | ||
21 | RecentVideosRecommendationService | ||
22 | ] | ||
23 | }) | ||
24 | export class RecommendationsModule { | ||
25 | } | ||
diff --git a/client/src/app/videos/recommendations/recommendations.service.ts b/client/src/app/videos/recommendations/recommendations.service.ts new file mode 100644 index 000000000..44cbda9b7 --- /dev/null +++ b/client/src/app/videos/recommendations/recommendations.service.ts | |||
@@ -0,0 +1,8 @@ | |||
1 | import { Video } from '@app/shared/video/video.model' | ||
2 | import { Observable } from 'rxjs' | ||
3 | |||
4 | export type UUID = string | ||
5 | |||
6 | export interface RecommendationService { | ||
7 | getRecommendations (uuid: UUID): Observable<Video[]> | ||
8 | } | ||
diff --git a/client/src/app/videos/recommendations/recommended-videos.component.html b/client/src/app/videos/recommendations/recommended-videos.component.html new file mode 100644 index 000000000..7cfaffec2 --- /dev/null +++ b/client/src/app/videos/recommendations/recommended-videos.component.html | |||
@@ -0,0 +1,11 @@ | |||
1 | <div class="other-videos"> | ||
2 | <div i18n class="title-page title-page-single"> | ||
3 | Other videos | ||
4 | </div> | ||
5 | |||
6 | <ng-container *ngIf="hasVideos$ | async"> | ||
7 | <div *ngFor="let video of (videos$ | async)"> | ||
8 | <my-video-miniature [video]="video" [user]="user"></my-video-miniature> | ||
9 | </div> | ||
10 | </ng-container> | ||
11 | </div> | ||
diff --git a/client/src/app/videos/recommendations/recommended-videos.component.ts b/client/src/app/videos/recommendations/recommended-videos.component.ts new file mode 100644 index 000000000..aa4dd0ee2 --- /dev/null +++ b/client/src/app/videos/recommendations/recommended-videos.component.ts | |||
@@ -0,0 +1,31 @@ | |||
1 | import { Component, Input, OnChanges } from '@angular/core' | ||
2 | import { Observable } from 'rxjs' | ||
3 | import { Video } from '@app/shared/video/video.model' | ||
4 | import { RecommendedVideosStore } from '@app/videos/recommendations/recommended-videos.store' | ||
5 | import { User } from '@app/shared' | ||
6 | |||
7 | @Component({ | ||
8 | selector: 'my-recommended-videos', | ||
9 | templateUrl: './recommended-videos.component.html' | ||
10 | }) | ||
11 | export class RecommendedVideosComponent implements OnChanges { | ||
12 | @Input() inputVideo: Video | ||
13 | @Input() user: User | ||
14 | |||
15 | readonly hasVideos$: Observable<boolean> | ||
16 | readonly videos$: Observable<Video[]> | ||
17 | |||
18 | constructor ( | ||
19 | private store: RecommendedVideosStore | ||
20 | ) { | ||
21 | this.videos$ = this.store.recommendations$ | ||
22 | this.hasVideos$ = this.store.hasRecommendations$ | ||
23 | } | ||
24 | |||
25 | public ngOnChanges (): void { | ||
26 | if (this.inputVideo) { | ||
27 | this.store.requestNewRecommendations(this.inputVideo.uuid) | ||
28 | } | ||
29 | } | ||
30 | |||
31 | } | ||
diff --git a/client/src/app/videos/recommendations/recommended-videos.store.spec.ts b/client/src/app/videos/recommendations/recommended-videos.store.spec.ts new file mode 100644 index 000000000..e12a3f520 --- /dev/null +++ b/client/src/app/videos/recommendations/recommended-videos.store.spec.ts | |||
@@ -0,0 +1,22 @@ | |||
1 | import { RecommendedVideosStore } from '@app/videos/recommendations/recommended-videos.store' | ||
2 | import { RecommendationService } from '@app/videos/recommendations/recommendations.service' | ||
3 | |||
4 | describe('RecommendedVideosStore', () => { | ||
5 | describe('requestNewRecommendations', () => { | ||
6 | let store: RecommendedVideosStore | ||
7 | let service: RecommendationService | ||
8 | beforeEach(() => { | ||
9 | service = { | ||
10 | getRecommendations: jest.fn(() => new Promise((r) => r())) | ||
11 | } | ||
12 | store = new RecommendedVideosStore(service) | ||
13 | }) | ||
14 | it('should pull new videos from the service one time when given the same UUID twice', () => { | ||
15 | store.requestNewRecommendations('some-uuid') | ||
16 | store.requestNewRecommendations('some-uuid') | ||
17 | // Requests aren't fulfilled until someone asks for them (ie: subscribes) | ||
18 | store.recommendations$.subscribe() | ||
19 | expect(service.getRecommendations).toHaveBeenCalledTimes(1) | ||
20 | }) | ||
21 | }) | ||
22 | }) | ||
diff --git a/client/src/app/videos/recommendations/recommended-videos.store.ts b/client/src/app/videos/recommendations/recommended-videos.store.ts new file mode 100644 index 000000000..689adeb1f --- /dev/null +++ b/client/src/app/videos/recommendations/recommended-videos.store.ts | |||
@@ -0,0 +1,32 @@ | |||
1 | import { Inject, Injectable } from '@angular/core' | ||
2 | import { Observable, ReplaySubject } from 'rxjs' | ||
3 | import { Video } from '@app/shared/video/video.model' | ||
4 | import { RecentVideosRecommendationService } from '@app/videos/recommendations/recent-videos-recommendation.service' | ||
5 | import { RecommendationService, UUID } from '@app/videos/recommendations/recommendations.service' | ||
6 | import { map, switchMap, take } from 'rxjs/operators' | ||
7 | |||
8 | /** | ||
9 | * This store is intended to provide data for the RecommendedVideosComponent. | ||
10 | */ | ||
11 | @Injectable() | ||
12 | export class RecommendedVideosStore { | ||
13 | public readonly recommendations$: Observable<Video[]> | ||
14 | public readonly hasRecommendations$: Observable<boolean> | ||
15 | private readonly requestsForLoad$$ = new ReplaySubject<UUID>(1) | ||
16 | |||
17 | constructor ( | ||
18 | @Inject(RecentVideosRecommendationService) private recommendations: RecommendationService | ||
19 | ) { | ||
20 | this.recommendations$ = this.requestsForLoad$$.pipe( | ||
21 | switchMap(requestedUUID => recommendations.getRecommendations(requestedUUID) | ||
22 | .pipe(take(1)) | ||
23 | )) | ||
24 | this.hasRecommendations$ = this.recommendations$.pipe( | ||
25 | map(otherVideos => otherVideos.length > 0) | ||
26 | ) | ||
27 | } | ||
28 | |||
29 | requestNewRecommendations (videoUUID: string) { | ||
30 | this.requestsForLoad$$.next(videoUUID) | ||
31 | } | ||
32 | } | ||
diff --git a/scripts/travis.sh b/scripts/travis.sh index c459daf0b..1d7ebf340 100755 --- a/scripts/travis.sh +++ b/scripts/travis.sh | |||
@@ -32,6 +32,10 @@ elif [ "$1" = "lint" ]; then | |||
32 | ( cd client | 32 | ( cd client |
33 | npm run lint | 33 | npm run lint |
34 | ) | 34 | ) |
35 | elif [ "$1" = "jest" ]; then | ||
36 | ( cd client | ||
37 | npm run test | ||
38 | ) | ||
35 | 39 | ||
36 | npm run tslint -- --project ./tsconfig.json -c ./tslint.json server.ts "server/**/*.ts" "shared/**/*.ts" | 40 | npm run tslint -- --project ./tsconfig.json -c ./tslint.json server.ts "server/**/*.ts" "shared/**/*.ts" |
37 | fi | 41 | fi |