# IDE
/*.sublime-project
/*.sublime-workspace
-/.idea
+/**/.idea
/dist
/PeerTube.iml
- 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"
"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",
"webtorrent/create-torrent/junk": "^1",
"simple-get": "^2.8.1"
},
+ "jest": {
+ "transform": {
+ "^.+\\.tsx?$": "ts-jest"
+ },
+ "moduleNameMapper": {
+ "^@app/(.*)": "<rootDir>/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",
"@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",
"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",
"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",
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.'
</div>
</div>
- <my-video-comments [video]="video" [user]="user"></my-video-comments>
- </div>
-
- <div class="ml-3 ml-sm-0 col-12 col-md-3 other-videos">
- <div i18n class="title-page title-page-single">
- Other videos
- </div>
-
- <div *ngFor="let video of otherVideosDisplayed">
- <my-video-miniature [video]="video" [user]="user"></my-video-miniature>
- </div>
- </div>
+ <my-video-comments [video]="video" [user]="user"></my-video-comments>
</div>
+ <my-recommended-videos class="ml-3 ml-sm-0 col-12 col-md-3"
+ [inputVideo]="video" [user]="user"></my-recommended-videos>
</div>
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'
@ViewChild('videoSupportModal') videoSupportModal: VideoSupportComponent
@ViewChild('videoBlacklistModal') videoBlacklistModal: VideoBlacklistComponent
- otherVideosDisplayed: Video[] = []
-
player: videojs.Player
playerElement: HTMLVideoElement
userRating: UserVideoRateType = null
remoteServerDown = false
private videojsLocaleLoaded = false
- private otherVideos: Video[] = []
private paramsSub: Subscription
constructor (
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' ]
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?'),
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)
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({
ClipboardModule,
NgbTooltipModule,
NgxQRCodeModule,
- TextareaAutosizeModule
+ TextareaAutosizeModule,
+ RecommendationsModule
],
declarations: [
--- /dev/null
+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<any>
+ 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()
+ })
+ })
+})
--- /dev/null
+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<Video[]> {
+ 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<Video[]> {
+ let pagination = { currentPage: page, itemsPerPage: this.pageSize + 1 }
+ return this.videos.getVideos(pagination, '-createdAt')
+ .pipe(
+ map(v => v.videos)
+ )
+ }
+
+}
--- /dev/null
+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 {
+}
--- /dev/null
+import { Video } from '@app/shared/video/video.model'
+import { Observable } from 'rxjs'
+
+export type UUID = string
+
+export interface RecommendationService {
+ getRecommendations (uuid: UUID): Observable<Video[]>
+}
--- /dev/null
+<div class="other-videos">
+ <div i18n class="title-page title-page-single">
+ Other videos
+ </div>
+
+ <ng-container *ngIf="hasVideos$ | async">
+ <div *ngFor="let video of (videos$ | async)">
+ <my-video-miniature [video]="video" [user]="user"></my-video-miniature>
+ </div>
+ </ng-container>
+</div>
--- /dev/null
+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<boolean>
+ readonly videos$: Observable<Video[]>
+
+ 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)
+ }
+ }
+
+}
--- /dev/null
+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)
+ })
+ })
+})
--- /dev/null
+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<Video[]>
+ public readonly hasRecommendations$: Observable<boolean>
+ private readonly requestsForLoad$$ = new ReplaySubject<UUID>(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)
+ }
+}
( 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