diff --git a/client/src/app/+videos/+video-watch/routes.ts b/client/src/app/+videos/+video-watch/routes.ts index b700986fc..10b24475a 100644 --- a/client/src/app/+videos/+video-watch/routes.ts +++ b/client/src/app/+videos/+video-watch/routes.ts @@ -9,7 +9,7 @@ import { VideoCommentService } from '@app/shared/shared-video-comment/video-comm import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service' import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service' import { OverviewService } from '../video-list' -import { RecentVideosRecommendationService, RecommendedVideosStore } from './shared' +import { VideoRecommendationService } from './shared' import { VideoWatchComponent } from './video-watch.component' import { BulkService } from '@app/shared/shared-moderation/bulk.service' @@ -24,8 +24,7 @@ export default [ VideoBlockService, LiveVideoService, VideoCommentService, - RecentVideosRecommendationService, - RecommendedVideosStore, + VideoRecommendationService, SearchService, AbuseService, UserAdminService, diff --git a/client/src/app/+videos/+video-watch/shared/recommendations/index.ts b/client/src/app/+videos/+video-watch/shared/recommendations/index.ts index 49b711d70..5cf47bf77 100644 --- a/client/src/app/+videos/+video-watch/shared/recommendations/index.ts +++ b/client/src/app/+videos/+video-watch/shared/recommendations/index.ts @@ -1,4 +1,2 @@ -export * from './recent-videos-recommendation.service' -export * from './recommendation-info.model' +export * from './video-recommendation.service' export * from './recommended-videos.component' -export * from './recommended-videos.store' diff --git a/client/src/app/+videos/+video-watch/shared/recommendations/recommendation-info.model.ts b/client/src/app/+videos/+video-watch/shared/recommendations/recommendation-info.model.ts deleted file mode 100644 index 0233563bb..000000000 --- a/client/src/app/+videos/+video-watch/shared/recommendations/recommendation-info.model.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface RecommendationInfo { - uuid: string - tags?: string[] -} diff --git a/client/src/app/+videos/+video-watch/shared/recommendations/recommendations.service.ts b/client/src/app/+videos/+video-watch/shared/recommendations/recommendations.service.ts deleted file mode 100644 index bef47cb42..000000000 --- a/client/src/app/+videos/+video-watch/shared/recommendations/recommendations.service.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Observable } from 'rxjs' -import { RecommendationInfo } from './recommendation-info.model' -import { Video } from '@app/shared/shared-main/video/video.model' - -export interface RecommendationService { - getRecommendations (recommendation: RecommendationInfo): Observable -} diff --git a/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.html b/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.html index b6850d937..834412645 100644 --- a/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.html +++ b/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.html @@ -1,12 +1,14 @@
- + @if (videos.length !== 0) {

Other videos

-
AUTOPLAY +
- + Next video to be played + - + }
diff --git a/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.ts b/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.ts index 15605f3ba..d52dfbeec 100644 --- a/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.ts +++ b/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.ts @@ -1,15 +1,15 @@ -import { Observable, startWith, Subscription, switchMap } from 'rxjs' +import { AsyncPipe, NgClass, NgFor, NgIf } from '@angular/common' import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output } from '@angular/core' -import { AuthService, Notifier, User, UserService } from '@app/core' -import { Video } from '@app/shared/shared-main/video/video.model' -import { RecommendationInfo } from './recommendation-info.model' -import { RecommendedVideosStore } from './recommended-videos.store' -import { MiniatureDisplayOptions, VideoMiniatureComponent } from '../../../../shared/shared-video-miniature/video-miniature.component' import { FormsModule } from '@angular/forms' -import { InputSwitchComponent } from '../../../../shared/shared-forms/input-switch.component' +import { AuthService, Notifier, User, UserService } from '@app/core' +import { VideoDetails } from '@app/shared/shared-main/video/video-details.model' +import { Video } from '@app/shared/shared-main/video/video.model' import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap' -import { NgClass, NgIf, NgFor, AsyncPipe } from '@angular/common' import { VideoPlaylist } from '@peertube/peertube-models' +import { Subscription, startWith, switchMap } from 'rxjs' +import { InputSwitchComponent } from '../../../../shared/shared-forms/input-switch.component' +import { MiniatureDisplayOptions, VideoMiniatureComponent } from '../../../../shared/shared-video-miniature/video-miniature.component' +import { VideoRecommendationService } from './video-recommendation.service' @Component({ selector: 'my-recommended-videos', @@ -19,12 +19,14 @@ import { VideoPlaylist } from '@peertube/peertube-models' imports: [ NgClass, NgIf, NgbTooltip, InputSwitchComponent, FormsModule, NgFor, VideoMiniatureComponent, AsyncPipe ] }) export class RecommendedVideosComponent implements OnInit, OnChanges, OnDestroy { - @Input() inputRecommendation: RecommendationInfo + @Input() currentVideo: VideoDetails @Input() playlist: VideoPlaylist @Input() displayAsRow: boolean @Output() gotRecommendations = new EventEmitter() + videos: Video[] = [] + autoPlayNextVideo: boolean autoPlayNextVideoTooltip: string @@ -39,19 +41,12 @@ export class RecommendedVideosComponent implements OnInit, OnChanges, OnDestroy private userSub: Subscription - readonly hasVideos$: Observable - readonly videos$: Observable - constructor ( private userService: UserService, private authService: AuthService, private notifier: Notifier, - private store: RecommendedVideosStore + private videoRecommendation: VideoRecommendationService ) { - this.videos$ = this.store.recommendations$ - this.hasVideos$ = this.store.hasRecommendations$ - this.videos$.subscribe(videos => this.gotRecommendations.emit(videos)) - this.autoPlayNextVideoTooltip = $localize`When active, the next video is automatically played after the current one.` } @@ -68,8 +63,8 @@ export class RecommendedVideosComponent implements OnInit, OnChanges, OnDestroy } ngOnChanges () { - if (this.inputRecommendation) { - this.store.requestNewRecommendations(this.inputRecommendation) + if (this.currentVideo) { + this.loadRecommendations() } } @@ -78,7 +73,7 @@ export class RecommendedVideosComponent implements OnInit, OnChanges, OnDestroy } onVideoRemoved () { - this.store.requestNewRecommendations(this.inputRecommendation) + this.loadRecommendations() } switchAutoPlayNextVideo () { @@ -97,4 +92,17 @@ export class RecommendedVideosComponent implements OnInit, OnChanges, OnDestroy this.userService.updateMyAnonymousProfile(details) } } + + private loadRecommendations () { + this.videoRecommendation.getRecommendations(this.currentVideo, this.videoRecommendation.getRecommentationHistory()) + .subscribe({ + next: videos => { + this.videos = videos + + this.gotRecommendations.emit(this.videos) + }, + + error: err => this.notifier.error(err.message) + }) + } } diff --git a/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.store.ts b/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.store.ts deleted file mode 100644 index 3a0412b03..000000000 --- a/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.store.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Observable, ReplaySubject } from 'rxjs' -import { map, shareReplay, switchMap, take } from 'rxjs/operators' -import { Inject, Injectable } from '@angular/core' -import { Video } from '@app/shared/shared-main/video/video.model' -import { RecentVideosRecommendationService } from './recent-videos-recommendation.service' -import { RecommendationInfo } from './recommendation-info.model' -import { RecommendationService } from './recommendations.service' - -/** - * 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(requestedRecommendation => { - return this.recommendations.getRecommendations(requestedRecommendation) - .pipe(take(1)) - }), - shareReplay() - ) - - this.hasRecommendations$ = this.recommendations$.pipe( - map(otherVideos => otherVideos.length > 0) - ) - } - - requestNewRecommendations (recommend: RecommendationInfo) { - this.requestsForLoad$$.next(recommend) - } -} diff --git a/client/src/app/+videos/+video-watch/shared/recommendations/recent-videos-recommendation.service.ts b/client/src/app/+videos/+video-watch/shared/recommendations/video-recommendation.service.ts similarity index 62% rename from client/src/app/+videos/+video-watch/shared/recommendations/recent-videos-recommendation.service.ts rename to client/src/app/+videos/+video-watch/shared/recommendations/video-recommendation.service.ts index 4dbe2207e..7355c38bb 100644 --- a/client/src/app/+videos/+video-watch/shared/recommendations/recent-videos-recommendation.service.ts +++ b/client/src/app/+videos/+video-watch/shared/recommendations/video-recommendation.service.ts @@ -1,24 +1,20 @@ -import { Observable, of } from 'rxjs' -import { map, switchMap } from 'rxjs/operators' import { Injectable } from '@angular/core' import { ServerService, UserService } from '@app/core' -import { HTMLServerConfig } from '@peertube/peertube-models' -import { RecommendationInfo } from './recommendation-info.model' -import { RecommendationService } from './recommendations.service' +import { VideoDetails } from '@app/shared/shared-main/video/video-details.model' import { Video } from '@app/shared/shared-main/video/video.model' import { VideoService } from '@app/shared/shared-main/video/video.service' -import { SearchService } from '@app/shared/shared-search/search.service' import { AdvancedSearch } from '@app/shared/shared-search/advanced-search.model' +import { SearchService } from '@app/shared/shared-search/search.service' +import { HTMLServerConfig } from '@peertube/peertube-models' +import { Observable, of } from 'rxjs' +import { map, switchMap } from 'rxjs/operators' -/** - * Provides "recommendations" by providing the most recently uploaded videos. - */ @Injectable() -export class RecentVideosRecommendationService implements RecommendationService { - readonly pageSize = 5 - +export class VideoRecommendationService { private config: HTMLServerConfig + private readonly videoIdsHistory = new Set() + constructor ( private videos: VideoService, private searchService: SearchService, @@ -28,19 +24,37 @@ export class RecentVideosRecommendationService implements RecommendationService this.config = this.serverService.getHTMLConfig() } - getRecommendations (recommendation: RecommendationInfo): Observable { + getRecommentationHistory () { + return this.videoIdsHistory + } - return this.fetchPage(1, recommendation) + getRecommendations (currentVideo: VideoDetails, exceptions = new Set()): Observable { + this.videoIdsHistory.add(currentVideo.id) + + // We want 5 results max + // +1 to exclude the currentVideo if needed + // +exceptions.size to exclude the videos we don't want to include + // Cap to 30 results maximum + const totalVideos = 5 + const internalTotalVideos = Math.min(totalVideos + 1 + exceptions.size, 30) + + return this.fetchPage(currentVideo, internalTotalVideos) .pipe( map(videos => { - const otherVideos = videos.filter(v => v.uuid !== recommendation.uuid) - return otherVideos.slice(0, this.pageSize) + let otherVideos = videos.filter(v => v.uuid !== currentVideo.uuid && !exceptions.has(v.id)) + + // Stop using exclude list if we excluded all videos + if (otherVideos.length === 0 && videos.length !== 0) { + otherVideos = videos.filter(v => v.uuid !== currentVideo.uuid) + } + + return otherVideos.slice(0, totalVideos) }) ) } - private fetchPage (page: number, recommendation: RecommendationInfo): Observable { - const pagination = { currentPage: page, itemsPerPage: this.pageSize + 1 } + private fetchPage (currentVideo: VideoDetails, totalItems: number): Observable { + const pagination = { currentPage: 1, itemsPerPage: totalItems } return this.userService.getAnonymousOrLoggedUser() .pipe( @@ -66,7 +80,7 @@ export class RecentVideosRecommendationService implements RecommendationService componentPagination: pagination, skipCount: true, advancedSearch: new AdvancedSearch({ - tagsOneOf: recommendation.tags.join(','), + tagsOneOf: currentVideo.tags.join(','), sort: '-publishedAt', searchTarget: 'local', nsfw, 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 51248e232..d9e2f698c 100644 --- a/client/src/app/+videos/+video-watch/video-watch.component.html +++ b/client/src/app/+videos/+video-watch/video-watch.component.html @@ -102,7 +102,7 @@ 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 51663a1ab..0bc0ac728 100644 --- a/client/src/app/+videos/+video-watch/video-watch.component.ts +++ b/client/src/app/+videos/+video-watch/video-watch.component.ts @@ -1,25 +1,34 @@ -import { forkJoin, map, Observable, of, Subscription, switchMap } from 'rxjs' -import { PlatformLocation, NgClass, NgIf, NgTemplateOutlet } from '@angular/common' +import { NgClass, NgIf, NgTemplateOutlet, PlatformLocation } from '@angular/common' import { Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' import { ActivatedRoute, Router, RouterLink } from '@angular/router' import { AuthService, AuthUser, ConfirmService, + Hotkey, + HotkeysService, + MetaService, Notifier, PeerTubeSocket, PluginService, RestExtractor, ScreenService, ServerService, - Hotkey, - HotkeysService, User, - UserService, - MetaService + UserService } from '@app/core' import { HooksService } from '@app/core/plugins/hooks.service' import { isXPercentInViewport, scrollToTop, toBoolean } from '@app/helpers' +import { VideoCaptionService } from '@app/shared/shared-main/video-caption/video-caption.service' +import { VideoChapterService } from '@app/shared/shared-main/video/video-chapter.service' +import { VideoDetails } from '@app/shared/shared-main/video/video-details.model' +import { VideoFileTokenService } from '@app/shared/shared-main/video/video-file-token.service' +import { Video } from '@app/shared/shared-main/video/video.model' +import { VideoService } from '@app/shared/shared-main/video/video.service' +import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription/subscribe-button.component' +import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service' +import { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.model' +import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service' import { timeToInt } from '@peertube/peertube-core-utils' import { HTMLServerConfig, @@ -36,6 +45,7 @@ import { } from '@peertube/peertube-models' import { logger } from '@root-helpers/logger' import { isP2PEnabled, videoRequiresFileToken, videoRequiresUserAuth } from '@root-helpers/video' +import { forkJoin, map, Observable, of, Subscription, switchMap } from 'rxjs' import { HLSOptions, PeerTubePlayer, @@ -46,29 +56,19 @@ import { } from '../../../assets/player' import { cleanupVideoWatch, getStoredTheater, getStoredVideoWatchHistory } from '../../../assets/player/peertube-player-local-storage' import { environment } from '../../../environments/environment' -import { VideoWatchPlaylistComponent } from './shared' -import { PlayerStylesComponent } from './player-styles.component' -import { PrivacyConcernsComponent } from './shared/information/privacy-concerns.component' -import { RecommendedVideosComponent } from './shared/recommendations/recommended-videos.component' -import { VideoCommentsComponent } from './shared/comment/video-comments.component' -import { VideoAttributesComponent } from './shared/metadata/video-attributes.component' -import { VideoDescriptionComponent } from './shared/metadata/video-description.component' -import { VideoAvatarChannelComponent } from './shared/metadata/video-avatar-channel.component' -import { ActionButtonsComponent } from './shared/action-buttons/action-buttons.component' -import { VideoViewsCounterComponent } from '../../shared/shared-video/video-views-counter.component' import { DateToggleComponent } from '../../shared/shared-main/date/date-toggle.component' -import { VideoAlertComponent } from './shared/information/video-alert.component' import { PluginPlaceholderComponent } from '../../shared/shared-main/plugins/plugin-placeholder.component' -import { VideoDetails } from '@app/shared/shared-main/video/video-details.model' -import { VideoCaptionService } from '@app/shared/shared-main/video-caption/video-caption.service' -import { VideoChapterService } from '@app/shared/shared-main/video/video-chapter.service' -import { VideoFileTokenService } from '@app/shared/shared-main/video/video-file-token.service' -import { VideoService } from '@app/shared/shared-main/video/video.service' -import { Video } from '@app/shared/shared-main/video/video.model' -import { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.model' -import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription/subscribe-button.component' -import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service' -import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service' +import { VideoViewsCounterComponent } from '../../shared/shared-video/video-views-counter.component' +import { PlayerStylesComponent } from './player-styles.component' +import { VideoWatchPlaylistComponent } from './shared' +import { ActionButtonsComponent } from './shared/action-buttons/action-buttons.component' +import { VideoCommentsComponent } from './shared/comment/video-comments.component' +import { PrivacyConcernsComponent } from './shared/information/privacy-concerns.component' +import { VideoAlertComponent } from './shared/information/video-alert.component' +import { VideoAttributesComponent } from './shared/metadata/video-attributes.component' +import { VideoAvatarChannelComponent } from './shared/metadata/video-avatar-channel.component' +import { VideoDescriptionComponent } from './shared/metadata/video-description.component' +import { RecommendedVideosComponent } from './shared/recommendations/recommended-videos.component' type URLOptions = { playerMode: PlayerMode