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
pull/1000/merge
Brad Johnson 2018-08-31 09:19:21 -06:00 committed by Chocobozzz
parent 1a47109144
commit 7f5f4152a4
16 changed files with 278 additions and 38 deletions

2
.gitignore vendored
View File

@ -25,7 +25,7 @@
# IDE
/*.sublime-project
/*.sublime-workspace
/.idea
/**/.idea
/dist
/PeerTube.iml

View 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"

View 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",
@ -29,6 +30,23 @@
"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",
@ -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",

View 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.'

View File

@ -197,19 +197,10 @@
</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>

View 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)

View 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: [

View File

@ -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()
})
})
})

View File

@ -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)
)
}
}

View File

@ -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 {
}

View File

@ -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[]>
}

View File

@ -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>

View File

@ -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)
}
}
}

View File

@ -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)
})
})
})

View File

@ -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)
}
}

View File

@ -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