]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - server/models/video/video-playlist.ts
Add ability to search by uuids/actor names
[github/Chocobozzz/PeerTube.git] / server / models / video / video-playlist.ts
index 93b8c2f58e3d81e539e385dc4046a71770a62d2a..caa79952de743acde24c5a4e76397920843b3e09 100644 (file)
@@ -1,6 +1,7 @@
+import { join } from 'path'
+import { FindOptions, literal, Op, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
 import {
   AllowNull,
-  BeforeDestroy,
   BelongsTo,
   Column,
   CreatedAt,
@@ -8,6 +9,7 @@ import {
   Default,
   ForeignKey,
   HasMany,
+  HasOne,
   Is,
   IsUUID,
   Model,
@@ -15,84 +17,170 @@ import {
   Table,
   UpdatedAt
 } from 'sequelize-typescript'
-import * as Sequelize from 'sequelize'
+import { buildUUID, uuidToShort } from '@server/helpers/uuid'
+import { MAccountId, MChannelId } from '@server/types/models'
+import { AttributesOnly, buildPlaylistEmbedPath, buildPlaylistWatchPath } from '@shared/core-utils'
+import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
+import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
 import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
-import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getSort, throwIfNotValid } from '../utils'
+import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
+import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
+import { activityPubCollectionPagination } from '../../helpers/activitypub'
+import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
 import {
   isVideoPlaylistDescriptionValid,
   isVideoPlaylistNameValid,
   isVideoPlaylistPrivacyValid
 } from '../../helpers/custom-validators/video-playlists'
-import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
-import { CONFIG, CONSTRAINTS_FIELDS, STATIC_PATHS, THUMBNAILS_SIZE, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers'
-import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
-import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
+import {
+  ACTIVITY_PUB,
+  CONSTRAINTS_FIELDS,
+  STATIC_PATHS,
+  THUMBNAILS_SIZE,
+  VIDEO_PLAYLIST_PRIVACIES,
+  VIDEO_PLAYLIST_TYPES,
+  WEBSERVER
+} from '../../initializers/constants'
+import { MThumbnail } from '../../types/models/video/thumbnail'
+import {
+  MVideoPlaylistAccountThumbnail,
+  MVideoPlaylistAP,
+  MVideoPlaylistFormattable,
+  MVideoPlaylistFull,
+  MVideoPlaylistFullSummary,
+  MVideoPlaylistIdWithElements
+} from '../../types/models/video/video-playlist'
+import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
+import { ActorModel } from '../actor/actor'
+import { setAsUpdated } from '../shared'
+import {
+  buildServerIdsFollowedBy,
+  buildTrigramSearchIndex,
+  buildWhereIdOrUUID,
+  createSimilarityAttribute,
+  getPlaylistSort,
+  isOutdated,
+  throwIfNotValid
+} from '../utils'
+import { ThumbnailModel } from './thumbnail'
 import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
-import { join } from 'path'
 import { VideoPlaylistElementModel } from './video-playlist-element'
-import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
-import { activityPubCollectionPagination } from '../../helpers/activitypub'
-import { remove } from 'fs-extra'
-import { logger } from '../../helpers/logger'
 
 enum ScopeNames {
   AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
   WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH',
+  WITH_ACCOUNT_AND_CHANNEL_SUMMARY = 'WITH_ACCOUNT_AND_CHANNEL_SUMMARY',
+  WITH_ACCOUNT = 'WITH_ACCOUNT',
+  WITH_THUMBNAIL = 'WITH_THUMBNAIL',
   WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL'
 }
 
 type AvailableForListOptions = {
-  followerActorId: number
-  accountId?: number,
+  followerActorId?: number
+  type?: VideoPlaylistType
+  accountId?: number
   videoChannelId?: number
-  privateAndUnlisted?: boolean
+  listMyPlaylists?: boolean
+  search?: string
+  host?: string
+  uuids?: string[]
+  withVideos?: boolean
 }
 
-@Scopes({
+function getVideoLengthSelect () {
+  return 'SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id"'
+}
+
+@Scopes(() => ({
+  [ScopeNames.WITH_THUMBNAIL]: {
+    include: [
+      {
+        model: ThumbnailModel,
+        required: false
+      }
+    ]
+  },
   [ScopeNames.WITH_VIDEOS_LENGTH]: {
     attributes: {
       include: [
         [
-          Sequelize.literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'),
+          literal(`(${getVideoLengthSelect()})`),
           'videosLength'
         ]
       ]
     }
+  } as FindOptions,
+  [ScopeNames.WITH_ACCOUNT]: {
+    include: [
+      {
+        model: AccountModel,
+        required: true
+      }
+    ]
+  },
+  [ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY]: {
+    include: [
+      {
+        model: AccountModel.scope(AccountScopeNames.SUMMARY),
+        required: true
+      },
+      {
+        model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
+        required: false
+      }
+    ]
   },
   [ScopeNames.WITH_ACCOUNT_AND_CHANNEL]: {
     include: [
       {
-        model: () => AccountModel.scope(AccountScopeNames.SUMMARY),
+        model: AccountModel,
         required: true
       },
       {
-        model: () => VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
+        model: VideoChannelModel,
         required: false
       }
     ]
   },
   [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
-    // Only list local playlists OR playlists that are on an instance followed by actorId
-    const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId)
-    const actorWhere = {
-      [ Sequelize.Op.or ]: [
+    const whereAnd: WhereOptions[] = []
+
+    const whereServer = options.host && options.host !== WEBSERVER.HOST
+      ? { host: options.host }
+      : undefined
+
+    let whereActor: WhereOptions = {}
+
+    if (options.host === WEBSERVER.HOST) {
+      whereActor = {
+        [Op.and]: [ { serverId: null } ]
+      }
+    }
+
+    if (options.listMyPlaylists !== true) {
+      whereAnd.push({
+        privacy: VideoPlaylistPrivacy.PUBLIC
+      })
+
+      // Only list local playlists
+      const whereActorOr: WhereOptions[] = [
         {
           serverId: null
-        },
-        {
-          serverId: {
-            [ Sequelize.Op.in ]: Sequelize.literal(inQueryInstanceFollow)
-          }
         }
       ]
-    }
 
-    const whereAnd: any[] = []
+      // … OR playlists that are on an instance followed by actorId
+      if (options.followerActorId) {
+        const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId)
 
-    if (options.privateAndUnlisted !== true) {
-      whereAnd.push({
-        privacy: VideoPlaylistPrivacy.PUBLIC
-      })
+        whereActorOr.push({
+          serverId: {
+            [Op.in]: literal(inQueryInstanceFollow)
+          }
+        })
+      }
+
+      Object.assign(whereActor, { [Op.or]: whereActorOr })
     }
 
     if (options.accountId) {
@@ -107,19 +195,59 @@ type AvailableForListOptions = {
       })
     }
 
-    const where = {
-      [Sequelize.Op.and]: whereAnd
+    if (options.type) {
+      whereAnd.push({
+        type: options.type
+      })
     }
 
-    const accountScope = {
-      method: [ AccountScopeNames.SUMMARY, actorWhere ]
+    if (options.uuids) {
+      whereAnd.push({
+        uuid: {
+          [Op.in]: options.uuids
+        }
+      })
+    }
+
+    if (options.withVideos === true) {
+      whereAnd.push(
+        literal(`(${getVideoLengthSelect()}) != 0`)
+      )
+    }
+
+    let attributesInclude: any[] = [ literal('0 as similarity') ]
+
+    if (options.search) {
+      const escapedSearch = VideoPlaylistModel.sequelize.escape(options.search)
+      const escapedLikeSearch = VideoPlaylistModel.sequelize.escape('%' + options.search + '%')
+      attributesInclude = [ createSimilarityAttribute('VideoPlaylistModel.name', options.search) ]
+
+      whereAnd.push({
+        [Op.or]: [
+          Sequelize.literal(
+            'lower(immutable_unaccent("VideoPlaylistModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
+          ),
+          Sequelize.literal(
+            'lower(immutable_unaccent("VideoPlaylistModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
+          )
+        ]
+      })
+    }
+
+    const where = {
+      [Op.and]: whereAnd
     }
 
     return {
+      attributes: {
+        include: attributesInclude
+      },
       where,
       include: [
         {
-          model: AccountModel.scope(accountScope),
+          model: AccountModel.scope({
+            method: [ AccountScopeNames.SUMMARY, { whereActor, whereServer } as SummaryOptions ]
+          }),
           required: true
         },
         {
@@ -127,13 +255,15 @@ type AvailableForListOptions = {
           required: false
         }
       ]
-    }
+    } as FindOptions
   }
-})
+}))
 
 @Table({
   tableName: 'videoPlaylist',
   indexes: [
+    buildTrigramSearchIndex('video_playlist_name_trigram', 'name'),
+
     {
       fields: [ 'ownerAccountId' ]
     },
@@ -146,7 +276,7 @@ type AvailableForListOptions = {
     }
   ]
 })
-export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
+export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlaylistModel>>> {
   @CreatedAt
   createdAt: Date
 
@@ -159,8 +289,8 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
   name: string
 
   @AllowNull(true)
-  @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description'))
-  @Column
+  @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description', true))
+  @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.DESCRIPTION.max))
   description: string
 
   @AllowNull(false)
@@ -179,6 +309,11 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
   @Column(DataType.UUID)
   uuid: string
 
+  @AllowNull(false)
+  @Default(VideoPlaylistType.REGULAR)
+  @Column
+  type: VideoPlaylistType
+
   @ForeignKey(() => AccountModel)
   @Column
   ownerAccountId: number
@@ -197,7 +332,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
 
   @BelongsTo(() => VideoChannelModel, {
     foreignKey: {
-      allowNull: false
+      allowNull: true
     },
     onDelete: 'CASCADE'
   })
@@ -208,48 +343,59 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
       name: 'videoPlaylistId',
       allowNull: false
     },
-    onDelete: 'cascade'
+    onDelete: 'CASCADE'
   })
   VideoPlaylistElements: VideoPlaylistElementModel[]
 
-  // Calculated field
-  videosLength?: number
-
-  @BeforeDestroy
-  static async removeFiles (instance: VideoPlaylistModel) {
-    logger.info('Removing files of video playlist %s.', instance.url)
-
-    return instance.removeThumbnail()
-  }
+  @HasOne(() => ThumbnailModel, {
+    foreignKey: {
+      name: 'videoPlaylistId',
+      allowNull: true
+    },
+    onDelete: 'CASCADE',
+    hooks: true
+  })
+  Thumbnail: ThumbnailModel
 
   static listForApi (options: {
     followerActorId: number
-    start: number,
-    count: number,
-    sort: string,
-    accountId?: number,
-    videoChannelId?: number,
-    privateAndUnlisted?: boolean
+    start: number
+    count: number
+    sort: string
+    type?: VideoPlaylistType
+    accountId?: number
+    videoChannelId?: number
+    listMyPlaylists?: boolean
+    search?: string
+    host?: string
+    uuids?: string[]
+    withVideos?: boolean // false by default
   }) {
     const query = {
       offset: options.start,
       limit: options.count,
-      order: getSort(options.sort)
+      order: getPlaylistSort(options.sort)
     }
 
-    const scopes = [
+    const scopes: (string | ScopeOptions)[] = [
       {
         method: [
           ScopeNames.AVAILABLE_FOR_LIST,
           {
+            type: options.type,
             followerActorId: options.followerActorId,
             accountId: options.accountId,
             videoChannelId: options.videoChannelId,
-            privateAndUnlisted: options.privateAndUnlisted
+            listMyPlaylists: options.listMyPlaylists,
+            search: options.search,
+            host: options.host,
+            uuids: options.uuids,
+            withVideos: options.withVideos || false
           } as AvailableForListOptions
         ]
-      } as any, // FIXME: typings
-      ScopeNames.WITH_VIDEOS_LENGTH
+      },
+      ScopeNames.WITH_VIDEOS_LENGTH,
+      ScopeNames.WITH_THUMBNAIL
     ]
 
     return VideoPlaylistModel
@@ -260,14 +406,41 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
       })
   }
 
-  static listUrlsOfForAP (accountId: number, start: number, count: number) {
+  static searchForApi (options: {
+    followerActorId: number
+    start: number
+    count: number
+    sort: string
+    search?: string
+    host?: string
+    uuids?: string[]
+  }) {
+    return VideoPlaylistModel.listForApi({
+      ...options,
+      type: VideoPlaylistType.REGULAR,
+      listMyPlaylists: false,
+      withVideos: true
+    })
+  }
+
+  static listPublicUrlsOfForAP (options: { account?: MAccountId, channel?: MChannelId }, start: number, count: number) {
+    const where = {
+      privacy: VideoPlaylistPrivacy.PUBLIC
+    }
+
+    if (options.account) {
+      Object.assign(where, { ownerAccountId: options.account.id })
+    }
+
+    if (options.channel) {
+      Object.assign(where, { videoChannelId: options.channel.id })
+    }
+
     const query = {
       attributes: [ 'url' ],
       offset: start,
       limit: count,
-      where: {
-        ownerAccountId: accountId
-      }
+      where
     }
 
     return VideoPlaylistModel.findAndCountAll(query)
@@ -276,9 +449,32 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
                              })
   }
 
+  static listPlaylistIdsOf (accountId: number, videoIds: number[]): Promise<MVideoPlaylistIdWithElements[]> {
+    const query = {
+      attributes: [ 'id' ],
+      where: {
+        ownerAccountId: accountId
+      },
+      include: [
+        {
+          attributes: [ 'id', 'videoId', 'startTimestamp', 'stopTimestamp' ],
+          model: VideoPlaylistElementModel.unscoped(),
+          where: {
+            videoId: {
+              [Op.in]: videoIds
+            }
+          },
+          required: true
+        }
+      ]
+    }
+
+    return VideoPlaylistModel.findAll(query)
+  }
+
   static doesPlaylistExist (url: string) {
     const query = {
-      attributes: [],
+      attributes: [ 'id' ],
       where: {
         url
       }
@@ -289,7 +485,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
       .then(e => !!e)
   }
 
-  static load (id: number | string, transaction: Sequelize.Transaction) {
+  static loadWithAccountAndChannelSummary (id: number | string, transaction: Transaction): Promise<MVideoPlaylistFullSummary> {
     const where = buildWhereIdOrUUID(id)
 
     const query = {
@@ -298,7 +494,42 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
     }
 
     return VideoPlaylistModel
-      .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH ])
+      .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
+      .findOne(query)
+  }
+
+  static loadWithAccountAndChannel (id: number | string, transaction: Transaction): Promise<MVideoPlaylistFull> {
+    const where = buildWhereIdOrUUID(id)
+
+    const query = {
+      where,
+      transaction
+    }
+
+    return VideoPlaylistModel
+      .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
+      .findOne(query)
+  }
+
+  static loadByUrlAndPopulateAccount (url: string): Promise<MVideoPlaylistAccountThumbnail> {
+    const query = {
+      where: {
+        url
+      }
+    }
+
+    return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query)
+  }
+
+  static loadByUrlWithAccountAndChannelSummary (url: string): Promise<MVideoPlaylistFullSummary> {
+    const query = {
+      where: {
+        url
+      }
+    }
+
+    return VideoPlaylistModel
+      .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
       .findOne(query)
   }
 
@@ -306,36 +537,116 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
     return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
   }
 
-  getThumbnailName () {
+  static getTypeLabel (type: VideoPlaylistType) {
+    return VIDEO_PLAYLIST_TYPES[type] || 'Unknown'
+  }
+
+  static resetPlaylistsOfChannel (videoChannelId: number, transaction: Transaction) {
+    const query = {
+      where: {
+        videoChannelId
+      },
+      transaction
+    }
+
+    return VideoPlaylistModel.update({ privacy: VideoPlaylistPrivacy.PRIVATE, videoChannelId: null }, query)
+  }
+
+  async setAndSaveThumbnail (thumbnail: MThumbnail, t: Transaction) {
+    thumbnail.videoPlaylistId = this.id
+
+    this.Thumbnail = await thumbnail.save({ transaction: t })
+  }
+
+  hasThumbnail () {
+    return !!this.Thumbnail
+  }
+
+  hasGeneratedThumbnail () {
+    return this.hasThumbnail() && this.Thumbnail.automaticallyGenerated === true
+  }
+
+  generateThumbnailName () {
     const extension = '.jpg'
 
-    return 'playlist-' + this.uuid + extension
+    return 'playlist-' + buildUUID() + extension
   }
 
   getThumbnailUrl () {
-    return CONFIG.WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
+    if (!this.hasThumbnail()) return null
+
+    return WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.Thumbnail.filename
   }
 
   getThumbnailStaticPath () {
-    return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
+    if (!this.hasThumbnail()) return null
+
+    return join(STATIC_PATHS.THUMBNAILS, this.Thumbnail.filename)
+  }
+
+  getWatchStaticPath () {
+    return buildPlaylistWatchPath({ shortUUID: uuidToShort(this.uuid) })
   }
 
-  removeThumbnail () {
-    const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
-    return remove(thumbnailPath)
-      .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err }))
+  getEmbedStaticPath () {
+    return buildPlaylistEmbedPath(this)
+  }
+
+  static async getStats () {
+    const totalLocalPlaylists = await VideoPlaylistModel.count({
+      include: [
+        {
+          model: AccountModel,
+          required: true,
+          include: [
+            {
+              model: ActorModel,
+              required: true,
+              where: {
+                serverId: null
+              }
+            }
+          ]
+        }
+      ],
+      where: {
+        privacy: VideoPlaylistPrivacy.PUBLIC
+      }
+    })
+
+    return {
+      totalLocalPlaylists
+    }
+  }
+
+  setAsRefreshed () {
+    return setAsUpdated('videoPlaylist', this.id)
+  }
+
+  setVideosLength (videosLength: number) {
+    this.set('videosLength' as any, videosLength, { raw: true })
   }
 
   isOwned () {
     return this.OwnerAccount.isOwned()
   }
 
-  toFormattedJSON (): VideoPlaylist {
+  isOutdated () {
+    if (this.isOwned()) return false
+
+    return isOutdated(this, ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL)
+  }
+
+  toFormattedJSON (this: MVideoPlaylistFormattable): VideoPlaylist {
     return {
       id: this.id,
       uuid: this.uuid,
+      shortUUID: uuidToShort(this.uuid),
+
       isLocal: this.isOwned(),
 
+      url: this.url,
+
       displayName: this.name,
       description: this.description,
       privacy: {
@@ -344,37 +655,52 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
       },
 
       thumbnailPath: this.getThumbnailStaticPath(),
+      embedPath: this.getEmbedStaticPath(),
 
-      videosLength: this.videosLength,
+      type: {
+        id: this.type,
+        label: VideoPlaylistModel.getTypeLabel(this.type)
+      },
+
+      videosLength: this.get('videosLength') as number,
 
       createdAt: this.createdAt,
       updatedAt: this.updatedAt,
 
       ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(),
-      videoChannel: this.VideoChannel.toFormattedSummaryJSON()
+      videoChannel: this.VideoChannel
+        ? this.VideoChannel.toFormattedSummaryJSON()
+        : null
     }
   }
 
-  toActivityPubObject (): Promise<PlaylistObject> {
+  toActivityPubObject (this: MVideoPlaylistAP, page: number, t: Transaction): Promise<PlaylistObject> {
     const handler = (start: number, count: number) => {
-      return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count)
+      return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count, t)
+    }
+
+    let icon: ActivityIconObject
+    if (this.hasThumbnail()) {
+      icon = {
+        type: 'Image' as 'Image',
+        url: this.getThumbnailUrl(),
+        mediaType: 'image/jpeg' as 'image/jpeg',
+        width: THUMBNAILS_SIZE.width,
+        height: THUMBNAILS_SIZE.height
+      }
     }
 
-    return activityPubCollectionPagination(this.url, handler, null)
+    return activityPubCollectionPagination(this.url, handler, page)
       .then(o => {
         return Object.assign(o, {
           type: 'Playlist' as 'Playlist',
           name: this.name,
           content: this.description,
           uuid: this.uuid,
+          published: this.createdAt.toISOString(),
+          updated: this.updatedAt.toISOString(),
           attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [],
-          icon: {
-            type: 'Image' as 'Image',
-            url: this.getThumbnailUrl(),
-            mediaType: 'image/jpeg' as 'image/jpeg',
-            width: THUMBNAILS_SIZE.width,
-            height: THUMBNAILS_SIZE.height
-          }
+          icon
         })
       })
   }