Browse Source

feat: show contained playlists under My videos (#5125)

* feat: show contained playlists under My videos

closes #4769

* refactor(server): remove unused types

* fixes after code review

* fix(client/video-miniature): add to playlist

* fix(server/user/me): shortUUID response

* Revert "fix(client/video-miniature): add to playlist"

This reverts commit f1a0412391c7e2370b87df2594c9fe3f39a40ddc.

* fix(client/PlaylistService): caching

* Revert "fix(server/user/me): shortUUID response"

This reverts commit e3f1ee4e335739b895bced938540c003df24af73.

* Fix fetching playlists

Co-authored-by: Chocobozzz <me@florianbigard.com>
kontrollanten 1 year ago
parent
commit
38a3ccc7f8

+ 1 - 0
client/src/app/+my-library/my-videos/my-videos.component.html

@@ -34,6 +34,7 @@
 </div>
 
 <my-videos-selection
+  [videosContainedInPlaylists]="videosContainedInPlaylists"
   [pagination]="pagination"
   [(selection)]="selection"
   [(videosModel)]="videos"

+ 21 - 7
client/src/app/+my-library/my-videos/my-videos.component.ts

@@ -1,10 +1,11 @@
+import { uniqBy } from 'lodash'
 import { concat, Observable } from 'rxjs'
 import { tap, toArray } from 'rxjs/operators'
 import { Component, OnInit, ViewChild } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
 import { AuthService, ComponentPagination, ConfirmService, Notifier, ScreenService, ServerService, User } from '@app/core'
 import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
-import { prepareIcu, immutableAssign } from '@app/helpers'
+import { immutableAssign, prepareIcu } from '@app/helpers'
 import { AdvancedInputFilter } from '@app/shared/shared-forms'
 import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
 import { LiveStreamInformationComponent } from '@app/shared/shared-video-live'
@@ -14,7 +15,8 @@ import {
   VideoActionsDisplayType,
   VideosSelectionComponent
 } from '@app/shared/shared-video-miniature'
-import { VideoChannel, VideoSortField } from '@shared/models'
+import { VideoPlaylistService } from '@app/shared/shared-video-playlist'
+import { VideoChannel, VideoExistInPlaylist, VideosExistInPlaylists, VideoSortField } from '@shared/models'
 import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
 
 @Component({
@@ -26,6 +28,7 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
   @ViewChild('videoChangeOwnershipModal', { static: true }) videoChangeOwnershipModal: VideoChangeOwnershipComponent
   @ViewChild('liveStreamInformationModal', { static: true }) liveStreamInformationModal: LiveStreamInformationComponent
 
+  videosContainedInPlaylists: VideosExistInPlaylists = {}
   titlePage: string
   selection: SelectionType = {}
   pagination: ComponentPagination = {
@@ -83,7 +86,8 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
     protected notifier: Notifier,
     protected screenService: ScreenService,
     private confirmService: ConfirmService,
-    private videoService: VideoService
+    private videoService: VideoService,
+    private playlistService: VideoPlaylistService
   ) {
     this.titlePage = $localize`My videos`
   }
@@ -156,10 +160,20 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
       sort: this.sort,
       userChannels: this.userChannels,
       search: this.search
-    })
-      .pipe(
-        tap(res => this.pagination.totalItems = res.total)
-      )
+    }).pipe(
+      tap(res => this.pagination.totalItems = res.total),
+      tap(({ data }) => this.fetchVideosContainedInPlaylists(data))
+    )
+  }
+
+  private fetchVideosContainedInPlaylists (videos: Video[]) {
+    this.playlistService.doVideosExistInPlaylist(videos.map(v => v.id))
+      .subscribe(result => {
+        this.videosContainedInPlaylists = Object.keys(result).reduce((acc, videoId) => ({
+          ...acc,
+          [videoId]: uniqBy(result[videoId], (p: VideoExistInPlaylist) => p.playlistId)
+        }), this.videosContainedInPlaylists)
+      })
   }
 
   async deleteSelectedVideos () {

+ 6 - 0
client/src/app/shared/shared-video-miniature/video-miniature.component.html

@@ -52,6 +52,12 @@
             <ng-container *ngIf="displayOptions.privacyText && displayOptions.state && getStateLabel(video)"> - </ng-container>
             <ng-container *ngIf="displayOptions.state">{{ getStateLabel(video) }}</ng-container>
           </div>
+
+          <div *ngIf="containedInPlaylists" class="video-contained-in-playlists">
+            <a *ngFor="let playlist of containedInPlaylists" class="chip rectangular bg-secondary text-light" [routerLink]="['/w/p/', playlist.playlistShortUUID]">
+              {{ playlist.playlistDisplayName }}
+            </a>
+          </div>
         </div>
       </div>
 

+ 4 - 0
client/src/app/shared/shared-video-miniature/video-miniature.component.scss

@@ -4,6 +4,10 @@
 
 $more-button-width: 40px;
 
+.chip {
+  @include chip;
+}
+
 .video-miniature {
   font-size: 14px;
 }

+ 2 - 1
client/src/app/shared/shared-video-miniature/video-miniature.component.ts

@@ -11,7 +11,7 @@ import {
   Output
 } from '@angular/core'
 import { AuthService, ScreenService, ServerService, User } from '@app/core'
-import { HTMLServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '@shared/models'
+import { HTMLServerConfig, VideoExistInPlaylist, VideoPlaylistType, VideoPrivacy, VideoState } from '@shared/models'
 import { LinkType } from '../../../types/link.type'
 import { ActorAvatarSize } from '../shared-actor-image/actor-avatar.component'
 import { Video } from '../shared-main'
@@ -40,6 +40,7 @@ export type MiniatureDisplayOptions = {
 export class VideoMiniatureComponent implements OnInit {
   @Input() user: User
   @Input() video: Video
+  @Input() containedInPlaylists: VideoExistInPlaylist[]
 
   @Input() displayOptions: MiniatureDisplayOptions = {
     date: true,

+ 1 - 0
client/src/app/shared/shared-video-miniature/videos-selection.component.html

@@ -12,6 +12,7 @@
     </div>
 
     <my-video-miniature
+      [containedInPlaylists]="videosContainedInPlaylists ? videosContainedInPlaylists[video.id] : undefined"
       [video]="video" [displayAsRow]="true" [displayOptions]="miniatureDisplayOptions"
       [displayVideoActions]="false" [user]="user"
     ></my-video-miniature>

+ 2 - 1
client/src/app/shared/shared-video-miniature/videos-selection.component.ts

@@ -2,7 +2,7 @@ import { Observable, Subject } from 'rxjs'
 import { AfterContentInit, Component, ContentChildren, EventEmitter, Input, Output, QueryList, TemplateRef } from '@angular/core'
 import { ComponentPagination, Notifier, User } from '@app/core'
 import { logger } from '@root-helpers/logger'
-import { ResultList, VideoSortField } from '@shared/models'
+import { ResultList, VideosExistInPlaylists, VideoSortField } from '@shared/models'
 import { PeerTubeTemplateDirective, Video } from '../shared-main'
 import { MiniatureDisplayOptions } from './video-miniature.component'
 
@@ -14,6 +14,7 @@ export type SelectionType = { [ id: number ]: boolean }
   styleUrls: [ './videos-selection.component.scss' ]
 })
 export class VideosSelectionComponent implements AfterContentInit {
+  @Input() videosContainedInPlaylists: VideosExistInPlaylists
   @Input() user: User
   @Input() pagination: ComponentPagination
 

+ 2 - 2
client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts

@@ -6,8 +6,8 @@ import { AuthService, DisableForReuseHook, Notifier } from '@app/core'
 import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { secondsToTime } from '@shared/core-utils'
 import {
+  CachedVideoExistInPlaylist,
   Video,
-  VideoExistInPlaylist,
   VideoPlaylistCreate,
   VideoPlaylistElementCreate,
   VideoPlaylistElementUpdate,
@@ -330,7 +330,7 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
     }
   }
 
-  private rebuildPlaylists (existResult: VideoExistInPlaylist[]) {
+  private rebuildPlaylists (existResult: CachedVideoExistInPlaylist[]) {
     debugLogger('Got existing results for %d.', this.video.id, existResult)
 
     const oldPlaylists = this.videoPlaylists

+ 7 - 5
client/src/app/shared/shared-video-playlist/video-playlist.service.ts

@@ -8,6 +8,8 @@ import { buildBulkObservable, objectToFormData } from '@app/helpers'
 import { Account, AccountService, VideoChannel, VideoChannelService } from '@app/shared/shared-main'
 import { NGX_LOADING_BAR_IGNORED } from '@ngx-loading-bar/http-client'
 import {
+  CachedVideoExistInPlaylist,
+  CachedVideosExistInPlaylists,
   ResultList,
   VideoExistInPlaylist,
   VideoPlaylist as VideoPlaylistServerModel,
@@ -34,11 +36,11 @@ export class VideoPlaylistService {
 
   // Use a replay subject because we "next" a value before subscribing
   private videoExistsInPlaylistNotifier = new ReplaySubject<number>(1)
-  private videoExistsInPlaylistCacheSubject = new Subject<VideosExistInPlaylists>()
-  private readonly videoExistsInPlaylistObservable: Observable<VideosExistInPlaylists>
+  private videoExistsInPlaylistCacheSubject = new Subject<CachedVideosExistInPlaylists>()
+  private readonly videoExistsInPlaylistObservable: Observable<CachedVideosExistInPlaylists>
 
-  private videoExistsObservableCache: { [ id: number ]: Observable<VideoExistInPlaylist[]> } = {}
-  private videoExistsCache: { [ id: number ]: VideoExistInPlaylist[] } = {}
+  private videoExistsObservableCache: { [ id: number ]: Observable<CachedVideoExistInPlaylist[]> } = {}
+  private videoExistsCache: { [ id: number ]: CachedVideoExistInPlaylist[] } = {}
 
   private myAccountPlaylistCache: ResultList<CachedPlaylist> = undefined
   private myAccountPlaylistCacheRunning: Observable<ResultList<CachedPlaylist>>
@@ -346,7 +348,7 @@ export class VideoPlaylistService {
                )
   }
 
-  private doVideosExistInPlaylist (videoIds: number[]): Observable<VideosExistInPlaylists> {
+  doVideosExistInPlaylist (videoIds: number[]): Observable<VideosExistInPlaylists> {
     const url = VideoPlaylistService.MY_VIDEO_PLAYLIST_URL + 'videos-exist'
 
     let params = new HttpParams()

+ 4 - 1
server/controllers/api/users/my-video-playlists.ts

@@ -1,3 +1,4 @@
+import { uuidToShort } from '@shared/extra-utils'
 import express from 'express'
 import { VideosExistInPlaylists } from '../../../../shared/models/videos/playlist/video-exist-in-playlist.model'
 import { asyncMiddleware, authenticate } from '../../../middlewares'
@@ -24,7 +25,7 @@ async function doVideosInPlaylistExist (req: express.Request, res: express.Respo
   const videoIds = req.query.videoIds.map(i => parseInt(i + '', 10))
   const user = res.locals.oauth.token.User
 
-  const results = await VideoPlaylistModel.listPlaylistIdsOf(user.Account.id, videoIds)
+  const results = await VideoPlaylistModel.listPlaylistSummariesOf(user.Account.id, videoIds)
 
   const existObject: VideosExistInPlaylists = {}
 
@@ -37,6 +38,8 @@ async function doVideosInPlaylistExist (req: express.Request, res: express.Respo
       existObject[element.videoId].push({
         playlistElementId: element.id,
         playlistId: result.id,
+        playlistDisplayName: result.name,
+        playlistShortUUID: uuidToShort(result.uuid),
         startTimestamp: element.startTimestamp,
         stopTimestamp: element.stopTimestamp
       })

+ 3 - 3
server/models/video/video-playlist.ts

@@ -49,7 +49,7 @@ import {
   MVideoPlaylistFormattable,
   MVideoPlaylistFull,
   MVideoPlaylistFullSummary,
-  MVideoPlaylistIdWithElements
+  MVideoPlaylistSummaryWithElements
 } from '../../types/models/video/video-playlist'
 import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
 import { ActorModel } from '../actor/actor'
@@ -470,9 +470,9 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
     }))
   }
 
-  static listPlaylistIdsOf (accountId: number, videoIds: number[]): Promise<MVideoPlaylistIdWithElements[]> {
+  static listPlaylistSummariesOf (accountId: number, videoIds: number[]): Promise<MVideoPlaylistSummaryWithElements[]> {
     const query = {
-      attributes: [ 'id' ],
+      attributes: [ 'id', 'name', 'uuid' ],
       where: {
         ownerAccountId: accountId
       },

+ 11 - 1
server/tests/api/videos/video-playlists.ts

@@ -23,6 +23,7 @@ import {
   setDefaultVideoChannel,
   waitJobs
 } from '@shared/server-commands'
+import { uuidToShort } from '@shared/extra-utils'
 
 async function checkPlaylistElementType (
   servers: PeerTubeServer[],
@@ -56,6 +57,7 @@ describe('Test video playlists', function () {
   let playlistServer2UUID2: string
 
   let playlistServer1Id: number
+  let playlistServer1DisplayName: string
   let playlistServer1UUID: string
   let playlistServer1UUID2: string
 
@@ -489,15 +491,17 @@ describe('Test video playlists', function () {
         return commands[0].addElement({ playlistId: playlistServer1Id, attributes })
       }
 
+      const playlistDisplayName = 'playlist 4'
       const playlist = await commands[0].create({
         attributes: {
-          displayName: 'playlist 4',
+          displayName: playlistDisplayName,
           privacy: VideoPlaylistPrivacy.PUBLIC,
           videoChannelId: servers[0].store.channel.id
         }
       })
 
       playlistServer1Id = playlist.id
+      playlistServer1DisplayName = playlistDisplayName
       playlistServer1UUID = playlist.uuid
 
       await addVideo({ videoId: servers[0].store.videos[0].uuid, startTimestamp: 15, stopTimestamp: 28 })
@@ -908,6 +912,8 @@ describe('Test video playlists', function () {
         const elem = obj[servers[0].store.videos[0].id]
         expect(elem).to.have.lengthOf(1)
         expect(elem[0].playlistElementId).to.exist
+        expect(elem[0].playlistDisplayName).to.equal(playlistServer1DisplayName)
+        expect(elem[0].playlistShortUUID).to.equal(uuidToShort(playlistServer1UUID))
         expect(elem[0].playlistId).to.equal(playlistServer1Id)
         expect(elem[0].startTimestamp).to.equal(15)
         expect(elem[0].stopTimestamp).to.equal(28)
@@ -917,6 +923,8 @@ describe('Test video playlists', function () {
         const elem = obj[servers[0].store.videos[3].id]
         expect(elem).to.have.lengthOf(1)
         expect(elem[0].playlistElementId).to.equal(playlistElementServer1Video4)
+        expect(elem[0].playlistDisplayName).to.equal(playlistServer1DisplayName)
+        expect(elem[0].playlistShortUUID).to.equal(uuidToShort(playlistServer1UUID))
         expect(elem[0].playlistId).to.equal(playlistServer1Id)
         expect(elem[0].startTimestamp).to.equal(1)
         expect(elem[0].stopTimestamp).to.equal(35)
@@ -926,6 +934,8 @@ describe('Test video playlists', function () {
         const elem = obj[servers[0].store.videos[4].id]
         expect(elem).to.have.lengthOf(1)
         expect(elem[0].playlistId).to.equal(playlistServer1Id)
+        expect(elem[0].playlistDisplayName).to.equal(playlistServer1DisplayName)
+        expect(elem[0].playlistShortUUID).to.equal(uuidToShort(playlistServer1UUID))
         expect(elem[0].startTimestamp).to.equal(45)
         expect(elem[0].stopTimestamp).to.equal(null)
       }

+ 6 - 6
server/types/models/video/video-playlist.ts

@@ -14,6 +14,10 @@ export type MVideoPlaylist = Omit<VideoPlaylistModel, 'OwnerAccount' | 'VideoCha
 // ############################################################################
 
 export type MVideoPlaylistId = Pick<MVideoPlaylist, 'id'>
+export type MVideoPlaylistSummary =
+  Pick<MVideoPlaylist, 'id'> &
+  Pick<MVideoPlaylist, 'name'> &
+  Pick<MVideoPlaylist, 'uuid'>
 export type MVideoPlaylistPrivacy = Pick<MVideoPlaylist, 'privacy'>
 export type MVideoPlaylistUUID = Pick<MVideoPlaylist, 'uuid'>
 export type MVideoPlaylistVideosLength = MVideoPlaylist & { videosLength?: number }
@@ -22,12 +26,8 @@ export type MVideoPlaylistVideosLength = MVideoPlaylist & { videosLength?: numbe
 
 // With elements
 
-export type MVideoPlaylistWithElements =
-  MVideoPlaylist &
-  Use<'VideoPlaylistElements', MVideoPlaylistElementLight[]>
-
-export type MVideoPlaylistIdWithElements =
-  MVideoPlaylistId &
+export type MVideoPlaylistSummaryWithElements =
+  MVideoPlaylistSummary &
   Use<'VideoPlaylistElements', MVideoPlaylistElementLight[]>
 
 // ############################################################################

+ 9 - 1
shared/models/videos/playlist/video-exist-in-playlist.model.ts

@@ -1,10 +1,18 @@
 export type VideosExistInPlaylists = {
   [videoId: number ]: VideoExistInPlaylist[]
 }
+export type CachedVideosExistInPlaylists = {
+  [videoId: number ]: CachedVideoExistInPlaylist[]
+}
 
-export type VideoExistInPlaylist = {
+export type CachedVideoExistInPlaylist = {
   playlistElementId: number
   playlistId: number
   startTimestamp?: number
   stopTimestamp?: number
 }
+
+export type VideoExistInPlaylist = CachedVideoExistInPlaylist & {
+  playlistDisplayName: string
+  playlistShortUUID: string
+}