diff --git a/.gitignore b/.gitignore index 75a8a2786..22478c444 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,7 @@ # IDE /*.sublime-project /*.sublime-workspace -/.idea +/**/.idea /dist /PeerTube.iml diff --git a/.travis.yml b/.travis.yml index 78e25cf45..9fd54447c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -41,6 +41,7 @@ matrix: - env: TEST_SUITE=api-3 - env: TEST_SUITE=cli - env: TEST_SUITE=lint + - env: TEST_SUITE=jest script: - 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 @@ "postinstall": "npm rebuild node-sass", "webpack-bundle-analyzer": "webpack-bundle-analyzer", "webdriver-manager": "webdriver-manager", - "ngx-extractor": "ngx-extractor" + "ngx-extractor": "ngx-extractor", + "test": "jest" }, "license": "GPLv3", "typings": "*.d.ts", @@ -29,6 +30,23 @@ "webtorrent/create-torrent/junk": "^1", "simple-get": "^2.8.1" }, + "jest": { + "transform": { + "^.+\\.tsx?$": "ts-jest" + }, + "moduleNameMapper": { + "^@app/(.*)": "/src/app/$1" + }, + "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", + "moduleFileExtensions": [ + "ts", + "tsx", + "js", + "jsx", + "json", + "node" + ] + }, "devDependencies": { "@angular-devkit/build-angular": "^0.7.5", "@angular/animations": "~6.1.4", @@ -55,6 +73,7 @@ "@types/core-js": "^2.5.0", "@types/jasmine": "^2.8.7", "@types/jasminewd2": "^2.0.3", + "@types/jest": "^23.3.1", "@types/jschannel": "^1.0.0", "@types/lodash-es": "^4.17.0", "@types/markdown-it": "^0.0.5", @@ -79,6 +98,7 @@ "https-browserify": "^1.0.0", "jasmine-core": "^3.1.0", "jasmine-spec-reporter": "^4.2.1", + "jest": "^23.5.0", "jschannel": "^1.0.2", "karma": "^3.0.0", "karma-chrome-launcher": "^2.2.0", @@ -108,6 +128,7 @@ "sass-loader": "^7.1.0", "sass-resources-loader": "^1.2.1", "stream-http": "^2.8.3", + "ts-jest": "^23.1.4", "tslint": "^5.7.0", "tslint-config-standard": "^7.0.0", "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' import { UserSubscriptionService } from '@app/shared/user-subscription' import { VideoChannel } from '@app/shared/video-channel/video-channel.model' +export interface VideosProvider { + getVideos ( + videoPagination: ComponentPagination, + sort: VideoSortField, + filter?: VideoFilter, + categoryOneOf?: number + ): Observable<{ videos: Video[], totalVideos: number }> +} + @Injectable() -export class VideoService { +export class VideoService implements VideosProvider { static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.' 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 @@ - - - -
-
- Other videos -
- -
- -
-
+ + 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' import { AuthService, ConfirmService } from '../../core' import { RestExtractor, VideoBlacklistService } from '../../shared' import { VideoDetails } from '../../shared/video/video-details.model' -import { Video } from '../../shared/video/video.model' import { VideoService } from '../../shared/video/video.service' import { MarkdownService } from '../shared' import { VideoDownloadComponent } from './modal/video-download.component' @@ -43,8 +42,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { @ViewChild('videoSupportModal') videoSupportModal: VideoSupportComponent @ViewChild('videoBlacklistModal') videoBlacklistModal: VideoBlacklistComponent - otherVideosDisplayed: Video[] = [] - player: videojs.Player playerElement: HTMLVideoElement userRating: UserVideoRateType = null @@ -60,7 +57,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { remoteServerDown = false private videojsLocaleLoaded = false - private otherVideos: Video[] = [] private paramsSub: Subscription constructor ( @@ -96,16 +92,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.hasAlreadyAcceptedPrivacyConcern = true } - this.videoService.getVideos({ currentPage: 1, itemsPerPage: 5 }, '-createdAt') - .subscribe( - data => { - this.otherVideos = data.videos - this.updateOtherVideosDisplayed() - }, - - err => console.error(err) - ) - this.paramsSub = this.route.params.subscribe(routeParams => { const uuid = routeParams[ 'uuid' ] @@ -365,8 +351,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.completeDescriptionShown = false this.remoteServerDown = false - this.updateOtherVideosDisplayed() - if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) { const res = await this.confirmService.confirm( 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 { this.setVideoLikesBarTooltipText() } - private updateOtherVideosDisplayed () { - if (this.video && this.otherVideos && this.otherVideos.length > 0) { - this.otherVideosDisplayed = this.otherVideos.filter(v => v.uuid !== this.video.uuid) - } - } - private setOpenGraphTags () { this.metaService.setTitle(this.video.name) 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' import { NgxQRCodeModule } from 'ngx-qrcode2' import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' import { VideoBlacklistComponent } from '@app/videos/+video-watch/modal/video-blacklist.component' +import { RecommendationsModule } from '@app/videos/recommendations/recommendations.module' import { TextareaAutosizeModule } from 'ngx-textarea-autosize' @NgModule({ @@ -25,7 +26,8 @@ import { TextareaAutosizeModule } from 'ngx-textarea-autosize' ClipboardModule, NgbTooltipModule, NgxQRCodeModule, - TextareaAutosizeModule + TextareaAutosizeModule, + RecommendationsModule ], 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 @@ +import { RecentVideosRecommendationService } from '@app/videos/recommendations/recent-videos-recommendation.service' +import { VideosProvider } from '@app/shared/video/video.service' +import { EMPTY, of } from 'rxjs' +import Mock = jest.Mock + +describe('"Recent Videos" Recommender', () => { + describe('getRecommendations', () => { + let videosService: VideosProvider + let service: RecentVideosRecommendationService + let getVideosMock: Mock + beforeEach(() => { + getVideosMock = jest.fn(() => EMPTY) + videosService = { + getVideos: getVideosMock + } + service = new RecentVideosRecommendationService(videosService) + }) + it('should filter out the given UUID from the results', async (done) => { + const vids = [ + { uuid: 'uuid1' }, + { uuid: 'uuid2' } + ] + getVideosMock.mockReturnValueOnce(of({ videos: vids })) + const result = await service.getRecommendations('uuid1').toPromise() + const uuids = result.map(v => v.uuid) + expect(uuids).toEqual(['uuid2']) + done() + }) + it('should return 5 results when the given UUID is NOT in the first 5 results', async (done) => { + const vids = [ + { uuid: 'uuid2' }, + { uuid: 'uuid3' }, + { uuid: 'uuid4' }, + { uuid: 'uuid5' }, + { uuid: 'uuid6' }, + { uuid: 'uuid7' } + ] + getVideosMock.mockReturnValueOnce(of({ videos: vids })) + const result = await service.getRecommendations('uuid1').toPromise() + expect(result.length).toEqual(5) + done() + }) + it('should return 5 results when the given UUID IS PRESENT in the first 5 results', async (done) => { + const vids = [ + { uuid: 'uuid1' }, + { uuid: 'uuid2' }, + { uuid: 'uuid3' }, + { uuid: 'uuid4' }, + { uuid: 'uuid5' }, + { uuid: 'uuid6' } + ] + getVideosMock + .mockReturnValueOnce(of({ videos: vids })) + const result = await service.getRecommendations('uuid1').toPromise() + expect(result.length).toEqual(5) + done() + }) + it('should fetch an extra result in case the given UUID is in the list', async (done) => { + await service.getRecommendations('uuid1').toPromise() + let expectedSize = service.pageSize + 1 + let params = { currentPage: jasmine.anything(), itemsPerPage: expectedSize } + expect(getVideosMock).toHaveBeenCalledWith(params, jasmine.anything()) + done() + }) + }) +}) 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 @@ +import { Inject, Injectable } from '@angular/core' +import { RecommendationService } from '@app/videos/recommendations/recommendations.service' +import { Video } from '@app/shared/video/video.model' +import { VideoService, VideosProvider } from '@app/shared/video/video.service' +import { map } from 'rxjs/operators' +import { Observable } from 'rxjs' + +/** + * Provides "recommendations" by providing the most recently uploaded videos. + */ +@Injectable() +export class RecentVideosRecommendationService implements RecommendationService { + + readonly pageSize = 5 + + constructor ( + @Inject(VideoService) private videos: VideosProvider + ) { + } + + getRecommendations (uuid: string): Observable { + return this.fetchPage(1) + .pipe( + map(vids => { + const otherVideos = vids.filter(v => v.uuid !== uuid) + return otherVideos.slice(0, this.pageSize) + }) + ) + } + + private fetchPage (page: number): Observable { + let pagination = { currentPage: page, itemsPerPage: this.pageSize + 1 } + return this.videos.getVideos(pagination, '-createdAt') + .pipe( + map(v => v.videos) + ) + } + +} 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 @@ +import { NgModule } from '@angular/core' +import { RecommendedVideosComponent } from '@app/videos/recommendations/recommended-videos.component' +import { RecommendedVideosStore } from '@app/videos/recommendations/recommended-videos.store' +import { CommonModule } from '@angular/common' +import { SharedModule } from '@app/shared' +import { RecentVideosRecommendationService } from '@app/videos/recommendations/recent-videos-recommendation.service' + +@NgModule({ + imports: [ + SharedModule, + CommonModule + ], + declarations: [ + RecommendedVideosComponent + ], + exports: [ + RecommendedVideosComponent + ], + providers: [ + RecommendedVideosStore, + RecentVideosRecommendationService + ] +}) +export class RecommendationsModule { +} 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 @@ +import { Video } from '@app/shared/video/video.model' +import { Observable } from 'rxjs' + +export type UUID = string + +export interface RecommendationService { + getRecommendations (uuid: UUID): Observable +} 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 @@ +
+
+ Other videos +
+ + +
+ +
+
+
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 @@ +import { Component, Input, OnChanges } from '@angular/core' +import { Observable } from 'rxjs' +import { Video } from '@app/shared/video/video.model' +import { RecommendedVideosStore } from '@app/videos/recommendations/recommended-videos.store' +import { User } from '@app/shared' + +@Component({ + selector: 'my-recommended-videos', + templateUrl: './recommended-videos.component.html' +}) +export class RecommendedVideosComponent implements OnChanges { + @Input() inputVideo: Video + @Input() user: User + + readonly hasVideos$: Observable + readonly videos$: Observable + + constructor ( + private store: RecommendedVideosStore + ) { + this.videos$ = this.store.recommendations$ + this.hasVideos$ = this.store.hasRecommendations$ + } + + public ngOnChanges (): void { + if (this.inputVideo) { + this.store.requestNewRecommendations(this.inputVideo.uuid) + } + } + +} 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 @@ +import { RecommendedVideosStore } from '@app/videos/recommendations/recommended-videos.store' +import { RecommendationService } from '@app/videos/recommendations/recommendations.service' + +describe('RecommendedVideosStore', () => { + describe('requestNewRecommendations', () => { + let store: RecommendedVideosStore + let service: RecommendationService + beforeEach(() => { + service = { + getRecommendations: jest.fn(() => new Promise((r) => r())) + } + store = new RecommendedVideosStore(service) + }) + it('should pull new videos from the service one time when given the same UUID twice', () => { + store.requestNewRecommendations('some-uuid') + store.requestNewRecommendations('some-uuid') + // Requests aren't fulfilled until someone asks for them (ie: subscribes) + store.recommendations$.subscribe() + expect(service.getRecommendations).toHaveBeenCalledTimes(1) + }) + }) +}) 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 @@ +import { Inject, Injectable } from '@angular/core' +import { Observable, ReplaySubject } from 'rxjs' +import { Video } from '@app/shared/video/video.model' +import { RecentVideosRecommendationService } from '@app/videos/recommendations/recent-videos-recommendation.service' +import { RecommendationService, UUID } from '@app/videos/recommendations/recommendations.service' +import { map, switchMap, take } from 'rxjs/operators' + +/** + * This store is intended to provide data for the RecommendedVideosComponent. + */ +@Injectable() +export class RecommendedVideosStore { + public readonly recommendations$: Observable + public readonly hasRecommendations$: Observable + private readonly requestsForLoad$$ = new ReplaySubject(1) + + constructor ( + @Inject(RecentVideosRecommendationService) private recommendations: RecommendationService + ) { + this.recommendations$ = this.requestsForLoad$$.pipe( + switchMap(requestedUUID => recommendations.getRecommendations(requestedUUID) + .pipe(take(1)) + )) + this.hasRecommendations$ = this.recommendations$.pipe( + map(otherVideos => otherVideos.length > 0) + ) + } + + requestNewRecommendations (videoUUID: string) { + this.requestsForLoad$$.next(videoUUID) + } +} 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 ( cd client npm run lint ) +elif [ "$1" = "jest" ]; then + ( cd client + npm run test + ) npm run tslint -- --project ./tsconfig.json -c ./tslint.json server.ts "server/**/*.ts" "shared/**/*.ts" fi