]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - server/models/video/video-playlist.ts
Fix lint
[github/Chocobozzz/PeerTube.git] / server / models / video / video-playlist.ts
index 581179640f74054751223eb2c596e6d95d8ad9c2..81ce3dc9eae2485941898cbc2430f1fb420a2efc 100644 (file)
@@ -1,3 +1,5 @@
+import { join } from 'path'
+import { FindOptions, Includeable, literal, Op, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
 import {
   AllowNull,
   BelongsTo,
@@ -15,14 +17,22 @@ import {
   Table,
   UpdatedAt
 } from 'sequelize-typescript'
+import { activityPubCollectionPagination } from '@server/lib/activitypub/collection'
+import { MAccountId, MChannelId } from '@server/types/models'
+import { buildPlaylistEmbedPath, buildPlaylistWatchPath, pick } from '@shared/core-utils'
+import { buildUUID, uuidToShort } from '@shared/extra-utils'
+import { AttributesOnly } from '@shared/typescript-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, isOutdated, 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 { 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 {
   ACTIVITY_PUB,
   CONSTRAINTS_FIELDS,
@@ -32,18 +42,7 @@ import {
   VIDEO_PLAYLIST_TYPES,
   WEBSERVER
 } from '../../initializers/constants'
-import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
-import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
-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 { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
-import { ThumbnailModel } from './thumbnail'
-import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
-import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
-import * as Bluebird from 'bluebird'
+import { MThumbnail } from '../../types/models/video/thumbnail'
 import {
   MVideoPlaylistAccountThumbnail,
   MVideoPlaylistAP,
@@ -52,8 +51,21 @@ import {
   MVideoPlaylistFullSummary,
   MVideoPlaylistIdWithElements
 } from '../../types/models/video/video-playlist'
-import { MThumbnail } from '../../types/models/video/thumbnail'
-import { MAccountId, MChannelId } from '@server/types/models'
+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 { VideoPlaylistElementModel } from './video-playlist-element'
 
 enum ScopeNames {
   AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
@@ -65,12 +77,20 @@ enum ScopeNames {
 }
 
 type AvailableForListOptions = {
-  followerActorId: number
+  followerActorId?: number
   type?: VideoPlaylistType
   accountId?: number
   videoChannelId?: number
   listMyPlaylists?: boolean
   search?: string
+  host?: string
+  uuids?: string[]
+  withVideos?: boolean
+  forCount?: boolean
+}
+
+function getVideoLengthSelect () {
+  return 'SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id"'
 }
 
 @Scopes(() => ({
@@ -86,7 +106,7 @@ type AvailableForListOptions = {
     attributes: {
       include: [
         [
-          literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'),
+          literal(`(${getVideoLengthSelect()})`),
           'videosLength'
         ]
       ]
@@ -125,31 +145,44 @@ type AvailableForListOptions = {
     ]
   },
   [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
+    const whereAnd: WhereOptions[] = []
+
+    const whereServer = options.host && options.host !== WEBSERVER.HOST
+      ? { host: options.host }
+      : undefined
 
     let whereActor: WhereOptions = {}
 
-    const whereAnd: WhereOptions[] = []
+    if (options.host === WEBSERVER.HOST) {
+      whereActor = {
+        [Op.and]: [ { serverId: null } ]
+      }
+    }
 
     if (options.listMyPlaylists !== true) {
       whereAnd.push({
         privacy: VideoPlaylistPrivacy.PUBLIC
       })
 
-      // Only list local playlists OR playlists that are on an instance followed by actorId
-      const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId)
+      // Only list local playlists
+      const whereActorOr: WhereOptions[] = [
+        {
+          serverId: null
+        }
+      ]
 
-      whereActor = {
-        [Op.or]: [
-          {
-            serverId: null
-          },
-          {
-            serverId: {
-              [Op.in]: literal(inQueryInstanceFollow)
-            }
+      // … OR playlists that are on an instance followed by actorId
+      if (options.followerActorId) {
+        const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId)
+
+        whereActorOr.push({
+          serverId: {
+            [Op.in]: literal(inQueryInstanceFollow)
           }
-        ]
+        })
       }
+
+      Object.assign(whereActor, { [Op.or]: whereActorOr })
     }
 
     if (options.accountId) {
@@ -170,34 +203,65 @@ type AvailableForListOptions = {
       })
     }
 
-    if (options.search) {
+    if (options.uuids) {
       whereAnd.push({
-        name: {
-          [Op.iLike]: '%' + options.search + '%'
+        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
     }
 
-    const accountScope = {
-      method: [ AccountScopeNames.SUMMARY, { whereActor } as SummaryOptions ]
+    const include: Includeable[] = [
+      {
+        model: AccountModel.scope({
+          method: [ AccountScopeNames.SUMMARY, { whereActor, whereServer, forCount: options.forCount } as SummaryOptions ]
+        }),
+        required: true
+      }
+    ]
+
+    if (options.forCount !== true) {
+      include.push({
+        model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
+        required: false
+      })
     }
 
     return {
+      attributes: {
+        include: attributesInclude
+      },
       where,
-      include: [
-        {
-          model: AccountModel.scope(accountScope),
-          required: true
-        },
-        {
-          model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
-          required: false
-        }
-      ]
+      include
     } as FindOptions
   }
 }))
@@ -205,6 +269,8 @@ type AvailableForListOptions = {
 @Table({
   tableName: 'videoPlaylist',
   indexes: [
+    buildTrigramSearchIndex('video_playlist_name_trigram', 'name'),
+
     {
       fields: [ 'ownerAccountId' ]
     },
@@ -217,7 +283,7 @@ type AvailableForListOptions = {
     }
   ]
 })
-export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
+export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlaylistModel>>> {
   @CreatedAt
   createdAt: Date
 
@@ -298,34 +364,36 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
   })
   Thumbnail: ThumbnailModel
 
-  static listForApi (options: {
-    followerActorId: number
+  static listForApi (options: AvailableForListOptions & {
     start: number
     count: number
     sort: string
-    type?: VideoPlaylistType
-    accountId?: number
-    videoChannelId?: number
-    listMyPlaylists?: boolean
-    search?: string
   }) {
     const query = {
       offset: options.start,
       limit: options.count,
-      order: getSort(options.sort)
+      order: getPlaylistSort(options.sort)
     }
 
-    const scopes: (string | ScopeOptions)[] = [
+    const commonAvailableForListOptions = pick(options, [
+      'type',
+      'followerActorId',
+      'accountId',
+      'videoChannelId',
+      'listMyPlaylists',
+      'search',
+      'host',
+      'uuids'
+    ])
+
+    const scopesFind: (string | ScopeOptions)[] = [
       {
         method: [
           ScopeNames.AVAILABLE_FOR_LIST,
           {
-            type: options.type,
-            followerActorId: options.followerActorId,
-            accountId: options.accountId,
-            videoChannelId: options.videoChannelId,
-            listMyPlaylists: options.listMyPlaylists,
-            search: options.search
+            ...commonAvailableForListOptions,
+
+            withVideos: options.withVideos || false
           } as AvailableForListOptions
         ]
       },
@@ -333,12 +401,40 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
       ScopeNames.WITH_THUMBNAIL
     ]
 
-    return VideoPlaylistModel
-      .scope(scopes)
-      .findAndCountAll(query)
-      .then(({ rows, count }) => {
-        return { total: count, data: rows }
-      })
+    const scopesCount: (string | ScopeOptions)[] = [
+      {
+        method: [
+          ScopeNames.AVAILABLE_FOR_LIST,
+
+          {
+            ...commonAvailableForListOptions,
+
+            withVideos: options.withVideos || false,
+            forCount: true
+          } as AvailableForListOptions
+        ]
+      },
+      ScopeNames.WITH_VIDEOS_LENGTH
+    ]
+
+    return Promise.all([
+      VideoPlaylistModel.scope(scopesCount).count(),
+      VideoPlaylistModel.scope(scopesFind).findAll(query)
+    ]).then(([ count, rows ]) => ({ total: count, data: rows }))
+  }
+
+  static searchForApi (options: Pick<AvailableForListOptions, 'followerActorId' | 'search' | 'host' | 'uuids'> & {
+    start: number
+    count: number
+    sort: string
+  }) {
+    return VideoPlaylistModel.listForApi({
+      ...options,
+
+      type: VideoPlaylistType.REGULAR,
+      listMyPlaylists: false,
+      withVideos: true
+    })
   }
 
   static listPublicUrlsOfForAP (options: { account?: MAccountId, channel?: MChannelId }, start: number, count: number) {
@@ -354,20 +450,27 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
       Object.assign(where, { videoChannelId: options.channel.id })
     }
 
-    const query = {
-      attributes: [ 'url' ],
-      offset: start,
-      limit: count,
-      where
+    const getQuery = (forCount: boolean) => {
+      return {
+        attributes: forCount === true
+          ? []
+          : [ 'url' ],
+        offset: start,
+        limit: count,
+        where
+      }
     }
 
-    return VideoPlaylistModel.findAndCountAll(query)
-                             .then(({ rows, count }) => {
-                               return { total: count, data: rows.map(p => p.url) }
-                             })
+    return Promise.all([
+      VideoPlaylistModel.count(getQuery(true)),
+      VideoPlaylistModel.findAll(getQuery(false))
+    ]).then(([ total, rows ]) => ({
+      total,
+      data: rows.map(p => p.url)
+    }))
   }
 
-  static listPlaylistIdsOf (accountId: number, videoIds: number[]): Bluebird<MVideoPlaylistIdWithElements[]> {
+  static listPlaylistIdsOf (accountId: number, videoIds: number[]): Promise<MVideoPlaylistIdWithElements[]> {
     const query = {
       attributes: [ 'id' ],
       where: {
@@ -392,7 +495,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
 
   static doesPlaylistExist (url: string) {
     const query = {
-      attributes: [],
+      attributes: [ 'id' ],
       where: {
         url
       }
@@ -403,7 +506,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
       .then(e => !!e)
   }
 
-  static loadWithAccountAndChannelSummary (id: number | string, transaction: Transaction): Bluebird<MVideoPlaylistFullSummary> {
+  static loadWithAccountAndChannelSummary (id: number | string, transaction: Transaction): Promise<MVideoPlaylistFullSummary> {
     const where = buildWhereIdOrUUID(id)
 
     const query = {
@@ -416,7 +519,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
       .findOne(query)
   }
 
-  static loadWithAccountAndChannel (id: number | string, transaction: Transaction): Bluebird<MVideoPlaylistFull> {
+  static loadWithAccountAndChannel (id: number | string, transaction: Transaction): Promise<MVideoPlaylistFull> {
     const where = buildWhereIdOrUUID(id)
 
     const query = {
@@ -429,7 +532,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
       .findOne(query)
   }
 
-  static loadByUrlAndPopulateAccount (url: string): Bluebird<MVideoPlaylistAccountThumbnail> {
+  static loadByUrlAndPopulateAccount (url: string): Promise<MVideoPlaylistAccountThumbnail> {
     const query = {
       where: {
         url
@@ -439,6 +542,18 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
     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)
+  }
+
   static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
     return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
   }
@@ -475,7 +590,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
   generateThumbnailName () {
     const extension = '.jpg'
 
-    return 'playlist-' + this.uuid + extension
+    return 'playlist-' + buildUUID() + extension
   }
 
   getThumbnailUrl () {
@@ -490,18 +605,47 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
     return join(STATIC_PATHS.THUMBNAILS, this.Thumbnail.filename)
   }
 
-  getWatchUrl () {
-    return WEBSERVER.URL + '/videos/watch/playlist/' + this.uuid
+  getWatchStaticPath () {
+    return buildPlaylistWatchPath({ shortUUID: uuidToShort(this.uuid) })
   }
 
   getEmbedStaticPath () {
-    return '/video-playlists/embed/' + this.uuid
+    return buildPlaylistEmbedPath(this)
+  }
+
+  static async getStats () {
+    const totalLocalPlaylists = await VideoPlaylistModel.count({
+      include: [
+        {
+          model: AccountModel.unscoped(),
+          required: true,
+          include: [
+            {
+              model: ActorModel.unscoped(),
+              required: true,
+              where: {
+                serverId: null
+              }
+            }
+          ]
+        }
+      ],
+      where: {
+        privacy: VideoPlaylistPrivacy.PUBLIC
+      }
+    })
+
+    return {
+      totalLocalPlaylists
+    }
   }
 
   setAsRefreshed () {
-    this.changed('updatedAt', true)
+    return setAsUpdated('videoPlaylist', this.id)
+  }
 
-    return this.save()
+  setVideosLength (videosLength: number) {
+    this.set('videosLength' as any, videosLength, { raw: true })
   }
 
   isOwned () {
@@ -518,8 +662,12 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
     return {
       id: this.id,
       uuid: this.uuid,
+      shortUUID: uuidToShort(this.uuid),
+
       isLocal: this.isOwned(),
 
+      url: this.url,
+
       displayName: this.name,
       description: this.description,
       privacy: {
@@ -569,6 +717,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
           type: 'Playlist' as 'Playlist',
           name: this.name,
           content: this.description,
+          mediaType: 'text/markdown' as 'text/markdown',
           uuid: this.uuid,
           published: this.createdAt.toISOString(),
           updated: this.updatedAt.toISOString(),