Refactor: Separated "Other Videos" section into a dedicated component/service (#969)
authorBrad Johnson <bradsk88@gmail.com>
Fri, 31 Aug 2018 15:19:21 +0000 (09:19 -0600)
committerChocobozzz <me@florianbigard.com>
Fri, 31 Aug 2018 15:19:21 +0000 (17:19 +0200)
* 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:
.gitignore
.travis.yml
client/package.json
client/src/app/shared/video/video.service.ts
client/src/app/videos/+video-watch/video-watch.component.html
client/src/app/videos/+video-watch/video-watch.component.ts
client/src/app/videos/+video-watch/video-watch.module.ts
client/src/app/videos/recommendations/recent-videos-recommendation.service.spec.ts [new file with mode: 0644]
client/src/app/videos/recommendations/recent-videos-recommendation.service.ts [new file with mode: 0644]
client/src/app/videos/recommendations/recommendations.module.ts [new file with mode: 0644]
client/src/app/videos/recommendations/recommendations.service.ts [new file with mode: 0644]
client/src/app/videos/recommendations/recommended-videos.component.html [new file with mode: 0644]
client/src/app/videos/recommendations/recommended-videos.component.ts [new file with mode: 0644]
client/src/app/videos/recommendations/recommended-videos.store.spec.ts [new file with mode: 0644]
client/src/app/videos/recommendations/recommended-videos.store.ts [new file with mode: 0644]
scripts/travis.sh

index 75a8a27864dac866e118cce81dea203ea6e2f371..22478c44413426f7cd2e3753e9facf9d777aafac 100644 (file)
@@ -25,7 +25,7 @@
 # IDE
 /*.sublime-project
 /*.sublime-workspace
-/.idea
+/**/.idea
 /dist
 /PeerTube.iml
 
index 78e25cf4548870a14b36492b9f174865cc05a491..9fd54447ce033b00814a5a28853b224a59367207 100644 (file)
@@ -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"
index 26583faba97e6bc8f2b60feb4b98528ceeb252ed..12da38175bb777be9b27bd9d998306f97d14720b 100644 (file)
@@ -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",
     "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",
@@ -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",
     "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",
index 7cc98c77a1764f207187f2ea87262bc36f921d3a..5c0674e583763c5d13c30ed550cccdb5317f2f55 100644 (file)
@@ -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.'
 
index 2c8305777add6de277ab06b80e2cdb80257d17cc..16d657a65e24d281c232116ed88c4cf211a93890 100644 (file)
           </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>
 
 
index d838ebe79c916d689d2efe6e195121af80ef1adc..25643cfded64a9b0fd28ac0eeca563fc632d9686 100644 (file)
@@ -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)
 
index 7920147b2016aa3f497cf03a032bf231e3083e58..5582ab40f170b6199f14319f4fb2dfda6fcc7ba4 100644 (file)
@@ -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 (file)
index 0000000..f9055b8
--- /dev/null
@@ -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<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()
+    })
+  })
+})
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 (file)
index 0000000..708d676
--- /dev/null
@@ -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<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)
+      )
+  }
+
+}
diff --git a/client/src/app/videos/recommendations/recommendations.module.ts b/client/src/app/videos/recommendations/recommendations.module.ts
new file mode 100644 (file)
index 0000000..5a46ea7
--- /dev/null
@@ -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 (file)
index 0000000..44cbda9
--- /dev/null
@@ -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<Video[]>
+}
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 (file)
index 0000000..7cfaffe
--- /dev/null
@@ -0,0 +1,11 @@
+<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>
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 (file)
index 0000000..aa4dd0e
--- /dev/null
@@ -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<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)
+    }
+  }
+
+}
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 (file)
index 0000000..e12a3f5
--- /dev/null
@@ -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 (file)
index 0000000..689adeb
--- /dev/null
@@ -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<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)
+  }
+}
index c459daf0becbb621e5660a87c50ce7b549300792..1d7ebf3403d890115aaeb22e91806a70108a459e 100755 (executable)
@@ -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