]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Handle playlist oembed
authorChocobozzz <me@florianbigard.com>
Wed, 5 Aug 2020 13:35:58 +0000 (15:35 +0200)
committerChocobozzz <chocobozzz@cpy.re>
Fri, 7 Aug 2020 06:58:29 +0000 (08:58 +0200)
client/src/standalone/videos/embed.ts
server/controllers/services.ts
server/lib/client-html.ts
server/middlewares/validators/oembed.ts
server/models/video/video-playlist.ts
server/tests/api/check-params/services.ts
server/tests/api/videos/services.ts
server/tests/client.ts

index a7fb087b19995a51fad3ff57983302cfa638d93f..8d1720f7565f5775663d91924dfa248f99ea3f24 100644 (file)
@@ -438,7 +438,7 @@ export class PeerTubeEmbed {
         return videoInfo
       })
 
-    const [ videoInfo, serverTranslations, captionsResponse, config, PeertubePlayerManagerModule ] = await Promise.all([
+    const [ videoInfoTmp, serverTranslations, captionsResponse, config, PeertubePlayerManagerModule ] = await Promise.all([
       videoInfoPromise,
       this.translationsPromise,
       captionsPromise,
@@ -446,6 +446,8 @@ export class PeerTubeEmbed {
       this.PeertubePlayerManagerModulePromise
     ])
 
+    const videoInfo: VideoDetails = videoInfoTmp
+
     const PeertubePlayerManager = PeertubePlayerManagerModule.PeertubePlayerManager
     const videoCaptions = await this.buildCaptions(serverTranslations, captionsResponse)
 
index ec057235fbb3f6958694dc31330b10f23026e39f..d0217c30ae9db1dc7ee882674f79fa04c00488a0 100644 (file)
@@ -1,7 +1,8 @@
 import * as express from 'express'
-import { EMBED_SIZE, PREVIEWS_SIZE, WEBSERVER } from '../initializers/constants'
+import { EMBED_SIZE, PREVIEWS_SIZE, WEBSERVER, THUMBNAILS_SIZE } from '../initializers/constants'
 import { asyncMiddleware, oembedValidator } from '../middlewares'
 import { accountNameWithHostGetValidator } from '../middlewares/validators'
+import { MChannelSummary } from '@server/types/models'
 
 const servicesRouter = express.Router()
 
@@ -23,23 +24,73 @@ export {
 // ---------------------------------------------------------------------------
 
 function generateOEmbed (req: express.Request, res: express.Response) {
+  if (res.locals.videoAll) return generateVideoOEmbed(req, res)
+
+  return generatePlaylistOEmbed(req, res)
+}
+
+function generatePlaylistOEmbed (req: express.Request, res: express.Response) {
+  const playlist = res.locals.videoPlaylistSummary
+
+  const json = buildOEmbed({
+    channel: playlist.VideoChannel,
+    title: playlist.name,
+    embedPath: playlist.getEmbedStaticPath(),
+    previewPath: playlist.getThumbnailStaticPath(),
+    previewSize: THUMBNAILS_SIZE,
+    req
+  })
+
+  return res.json(json)
+}
+
+function generateVideoOEmbed (req: express.Request, res: express.Response) {
   const video = res.locals.videoAll
+
+  const json = buildOEmbed({
+    channel: video.VideoChannel,
+    title: video.name,
+    embedPath: video.getEmbedStaticPath(),
+    previewPath: video.getPreviewStaticPath(),
+    previewSize: PREVIEWS_SIZE,
+    req
+  })
+
+  return res.json(json)
+}
+
+function buildOEmbed (options: {
+  req: express.Request
+  title: string
+  channel: MChannelSummary
+  previewPath: string | null
+  embedPath: string
+  previewSize: {
+    height: number
+    width: number
+  }
+}) {
+  const { req, previewSize, previewPath, title, channel, embedPath } = options
+
   const webserverUrl = WEBSERVER.URL
   const maxHeight = parseInt(req.query.maxheight, 10)
   const maxWidth = parseInt(req.query.maxwidth, 10)
 
-  const embedUrl = webserverUrl + video.getEmbedStaticPath()
-  let thumbnailUrl = webserverUrl + video.getPreviewStaticPath()
+  const embedUrl = webserverUrl + embedPath
   let embedWidth = EMBED_SIZE.width
   let embedHeight = EMBED_SIZE.height
 
+  let thumbnailUrl = previewPath
+    ? webserverUrl + previewPath
+    : undefined
+
   if (maxHeight < embedHeight) embedHeight = maxHeight
   if (maxWidth < embedWidth) embedWidth = maxWidth
 
   // Our thumbnail is too big for the consumer
   if (
-    (maxHeight !== undefined && maxHeight < PREVIEWS_SIZE.height) ||
-    (maxWidth !== undefined && maxWidth < PREVIEWS_SIZE.width)
+    (maxHeight !== undefined && maxHeight < previewSize.height) ||
+    (maxWidth !== undefined && maxWidth < previewSize.width)
   ) {
     thumbnailUrl = undefined
   }
@@ -53,20 +104,20 @@ function generateOEmbed (req: express.Request, res: express.Response) {
     html,
     width: embedWidth,
     height: embedHeight,
-    title: video.name,
-    author_name: video.VideoChannel.Account.name,
-    author_url: video.VideoChannel.Account.Actor.url,
+    title: title,
+    author_name: channel.name,
+    author_url: channel.Actor.url,
     provider_name: 'PeerTube',
     provider_url: webserverUrl
   }
 
   if (thumbnailUrl !== undefined) {
     json.thumbnail_url = thumbnailUrl
-    json.thumbnail_width = PREVIEWS_SIZE.width
-    json.thumbnail_height = PREVIEWS_SIZE.height
+    json.thumbnail_width = previewSize.width
+    json.thumbnail_height = previewSize.height
   }
 
-  return res.json(json)
+  return json
 }
 
 function redirectToAccountUrl (req: express.Request, res: express.Response, next: express.NextFunction) {
index d8ae73b5de6ea058fe294664be64258f95100e47..85fced10ddb25dea956c89f8f6291ac4c4ffb107 100644 (file)
@@ -23,6 +23,33 @@ import { CONFIG } from '../initializers/config'
 import { logger } from '../helpers/logger'
 import { MAccountActor, MChannelActor } from '../types/models'
 
+type Tags = {
+  ogType: string
+  twitterCard: string
+  schemaType: string
+
+  list?: {
+    numberOfItems: number
+  }
+
+  title: string
+  url: string
+  description: string
+
+  embed?: {
+    url: string
+    createdAt: string
+    duration?: string
+    views?: number
+  }
+
+  image: {
+    url: string
+    width?: number
+    height?: number
+  }
+}
+
 export class ClientHtml {
 
   private static htmlCache: { [path: string]: string } = {}
@@ -118,15 +145,20 @@ export class ClientHtml {
       url: videoPlaylist.getThumbnailUrl()
     }
 
+    const embed = {
+      url: WEBSERVER.URL + videoPlaylist.getEmbedStaticPath(),
+      createdAt: videoPlaylist.createdAt.toISOString()
+    }
+
     const list = {
-      numberOfItems: videoPlaylist.get('videosLength')
+      numberOfItems: videoPlaylist.get('videosLength') as number
     }
 
     const ogType = 'video'
-    const twitterCard = 'summary'
+    const twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED ? 'player' : 'summary'
     const schemaType = 'ItemList'
 
-    customHtml = ClientHtml.addTags(customHtml, { url, title, description, image, list, ogType, twitterCard, schemaType })
+    customHtml = ClientHtml.addTags(customHtml, { url, embed, title, description, image, list, ogType, twitterCard, schemaType })
 
     return customHtml
   }
@@ -268,7 +300,7 @@ export class ClientHtml {
     return htmlStringPage.replace('</head>', linkTag + '</head>')
   }
 
-  private static generateOpenGraphMetaTags (tags) {
+  private static generateOpenGraphMetaTags (tags: Tags) {
     const metaTags = {
       'og:type': tags.ogType,
       'og:title': tags.title,
@@ -294,7 +326,7 @@ export class ClientHtml {
     return metaTags
   }
 
-  private static generateStandardMetaTags (tags) {
+  private static generateStandardMetaTags (tags: Tags) {
     return {
       name: tags.title,
       description: tags.description,
@@ -302,7 +334,7 @@ export class ClientHtml {
     }
   }
 
-  private static generateTwitterCardMetaTags (tags) {
+  private static generateTwitterCardMetaTags (tags: Tags) {
     const metaTags = {
       'twitter:card': tags.twitterCard,
       'twitter:site': CONFIG.SERVICES.TWITTER.USERNAME,
@@ -319,7 +351,7 @@ export class ClientHtml {
     return metaTags
   }
 
-  private static generateSchemaTags (tags) {
+  private static generateSchemaTags (tags: Tags) {
     const schema = {
       '@context': 'http://schema.org',
       '@type': tags.schemaType,
@@ -337,8 +369,10 @@ export class ClientHtml {
     if (tags.embed) {
       schema['embedUrl'] = tags.embed.url
       schema['uploadDate'] = tags.embed.createdAt
-      schema['duration'] = tags.embed.duration
-      schema['iterationCount'] = tags.embed.views
+
+      if (tags.embed.duration) schema['duration'] = tags.embed.duration
+      if (tags.embed.views) schema['iterationCount'] = tags.embed.views
+
       schema['thumbnailUrl'] = tags.image.url
       schema['contentUrl'] = tags.url
     }
@@ -346,7 +380,7 @@ export class ClientHtml {
     return schema
   }
 
-  private static addTags (htmlStringPage: string, tagsValues: any) {
+  private static addTags (htmlStringPage: string, tagsValues: Tags) {
     const openGraphMetaTags = this.generateOpenGraphMetaTags(tagsValues)
     const standardMetaTags = this.generateStandardMetaTags(tagsValues)
     const twitterCardMetaTags = this.generateTwitterCardMetaTags(tagsValues)
@@ -354,7 +388,7 @@ export class ClientHtml {
 
     const { url, title, embed } = tagsValues
 
-    const oembedLinkTags = []
+    const oembedLinkTags: { type: string, href: string, title: string }[] = []
 
     if (embed) {
       oembedLinkTags.push({
index ab4dbb4d1814438b33b7821b9bcb11993ed02b0f..c9f9ea0c0708ed1e8db2d389e85290498fc5b3b5 100644 (file)
@@ -1,15 +1,19 @@
 import * as express from 'express'
 import { query } from 'express-validator'
 import { join } from 'path'
+import { fetchVideo } from '@server/helpers/video'
+import { VideoPlaylistModel } from '@server/models/video/video-playlist'
+import { VideoPlaylistPrivacy, VideoPrivacy } from '@shared/models'
 import { isTestInstance } from '../../helpers/core-utils'
 import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc'
 import { logger } from '../../helpers/logger'
-import { areValidationErrors } from './utils'
 import { WEBSERVER } from '../../initializers/constants'
-import { doesVideoExist } from '../../helpers/middlewares'
+import { areValidationErrors } from './utils'
 
-const urlShouldStartWith = WEBSERVER.SCHEME + '://' + join(WEBSERVER.HOST, 'videos', 'watch') + '/'
-const videoWatchRegex = new RegExp('([^/]+)$')
+const startVideoPlaylistsURL = WEBSERVER.SCHEME + '://' + join(WEBSERVER.HOST, 'videos', 'watch', 'playlist') + '/'
+const startVideosURL = WEBSERVER.SCHEME + '://' + join(WEBSERVER.HOST, 'videos', 'watch') + '/'
+
+const watchRegex = new RegExp('([^/]+)$')
 const isURLOptions = {
   require_host: true,
   require_tld: true
@@ -33,32 +37,63 @@ const oembedValidator = [
 
     if (req.query.format !== undefined && req.query.format !== 'json') {
       return res.status(501)
-                .json({ error: 'Requested format is not implemented on server.' })
-                .end()
+        .json({ error: 'Requested format is not implemented on server.' })
     }
 
     const url = req.query.url as string
 
-    const startIsOk = url.startsWith(urlShouldStartWith)
-    const matches = videoWatchRegex.exec(url)
+    const isPlaylist = url.startsWith(startVideoPlaylistsURL)
+    const isVideo = isPlaylist ? false : url.startsWith(startVideosURL)
+
+    const startIsOk = isVideo || isPlaylist
+
+    const matches = watchRegex.exec(url)
 
     if (startIsOk === false || matches === null) {
       return res.status(400)
-                .json({ error: 'Invalid url.' })
-                .end()
+        .json({ error: 'Invalid url.' })
     }
 
-    const videoId = matches[1]
-    if (isIdOrUUIDValid(videoId) === false) {
+    const elementId = matches[1]
+    if (isIdOrUUIDValid(elementId) === false) {
       return res.status(400)
-                .json({ error: 'Invalid video id.' })
-                .end()
+        .json({ error: 'Invalid video or playlist id.' })
     }
 
-    if (!await doesVideoExist(videoId, res)) return
+    if (isVideo) {
+      const video = await fetchVideo(elementId, 'all')
+
+      if (!video) {
+        return res.status(404)
+          .json({ error: 'Video not found' })
+      }
 
+      if (video.privacy !== VideoPrivacy.PUBLIC) {
+        return res.status(403)
+          .json({ error: 'Video is not public' })
+      }
+
+      res.locals.videoAll = video
+      return next()
+    }
+
+    // Is playlist
+
+    const videoPlaylist = await VideoPlaylistModel.loadWithAccountAndChannelSummary(elementId, undefined)
+    if (!videoPlaylist) {
+      return res.status(404)
+        .json({ error: 'Video playlist not found' })
+    }
+
+    if (videoPlaylist.privacy !== VideoPlaylistPrivacy.PUBLIC) {
+      return res.status(403)
+        .json({ error: 'Playlist is not public' })
+    }
+
+    res.locals.videoPlaylistSummary = videoPlaylist
     return next()
   }
+
 ]
 
 // ---------------------------------------------------------------------------
index b38cf9c6aa511ad697b2289cf2331c89e7fe91be..f935bf4f05e06de6e0f7d60c5b921edbd3808e7c 100644 (file)
@@ -494,6 +494,10 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
     return WEBSERVER.URL + '/videos/watch/playlist/' + this.uuid
   }
 
+  getEmbedStaticPath () {
+    return '/video-playlists/embed/' + this.uuid
+  }
+
   setAsRefreshed () {
     this.changed('updatedAt', true)
 
index 457adfaabf0033f2ce43e5ac01dd0d806b1331c0..e57edd9e4aee68ad98d575e85a33a05f1de4b0eb 100644 (file)
@@ -8,11 +8,15 @@ import {
   makeGetRequest,
   ServerInfo,
   setAccessTokensToServers,
-  uploadVideo
+  uploadVideo,
+  createVideoPlaylist,
+  setDefaultVideoChannel
 } from '../../../../shared/extra-utils'
+import { VideoPlaylistPrivacy } from '@shared/models'
 
 describe('Test services API validators', function () {
   let server: ServerInfo
+  let playlistUUID: string
 
   // ---------------------------------------------------------------
 
@@ -21,9 +25,26 @@ describe('Test services API validators', function () {
 
     server = await flushAndRunServer(1)
     await setAccessTokensToServers([ server ])
-
-    const res = await uploadVideo(server.url, server.accessToken, { name: 'my super name' })
-    server.video = res.body.video
+    await setDefaultVideoChannel([ server ])
+
+    {
+      const res = await uploadVideo(server.url, server.accessToken, { name: 'my super name' })
+      server.video = res.body.video
+    }
+
+    {
+      const res = await createVideoPlaylist({
+        url: server.url,
+        token: server.accessToken,
+        playlistAttrs: {
+          displayName: 'super playlist',
+          privacy: VideoPlaylistPrivacy.PUBLIC,
+          videoChannelId: server.videoChannel.id
+        }
+      })
+
+      playlistUUID = res.body.videoPlaylist.uuid
+    }
   })
 
   describe('Test oEmbed API validators', function () {
@@ -38,12 +59,12 @@ describe('Test services API validators', function () {
       await checkParamEmbed(server, embedUrl)
     })
 
-    it('Should fail with an invalid video id', async function () {
+    it('Should fail with an invalid element id', async function () {
       const embedUrl = `http://localhost:${server.port}/videos/watch/blabla`
       await checkParamEmbed(server, embedUrl)
     })
 
-    it('Should fail with an unknown video', async function () {
+    it('Should fail with an unknown element', async function () {
       const embedUrl = `http://localhost:${server.port}/videos/watch/88fc0165-d1f0-4a35-a51a-3b47f668689c`
       await checkParamEmbed(server, embedUrl, 404)
     })
@@ -78,7 +99,7 @@ describe('Test services API validators', function () {
       await checkParamEmbed(server, embedUrl, 501, { format: 'xml' })
     })
 
-    it('Should succeed with the correct params', async function () {
+    it('Should succeed with the correct params with a video', async function () {
       const embedUrl = `http://localhost:${server.port}/videos/watch/${server.video.uuid}`
       const query = {
         format: 'json',
@@ -88,6 +109,17 @@ describe('Test services API validators', function () {
 
       await checkParamEmbed(server, embedUrl, 200, query)
     })
+
+    it('Should succeed with the correct params with a playlist', async function () {
+      const embedUrl = `http://localhost:${server.port}/videos/watch/playlist/${playlistUUID}`
+      const query = {
+        format: 'json',
+        maxheight: 400,
+        maxwidth: 400
+      }
+
+      await checkParamEmbed(server, embedUrl, 200, query)
+    })
   })
 
   after(async function () {
index 5505a845a65306c48ce00667b05f6dc2b5f7103c..897f37c040d3c8de5bf4f9e5abb5ef05b335d5d3 100644 (file)
@@ -1,14 +1,25 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 
-import * as chai from 'chai'
 import 'mocha'
-import { getOEmbed, getVideosList, ServerInfo, setAccessTokensToServers, uploadVideo } from '../../../../shared/extra-utils/index'
+import * as chai from 'chai'
+import {
+  getOEmbed,
+  getVideosList,
+  ServerInfo,
+  setAccessTokensToServers,
+  setDefaultVideoChannel,
+  uploadVideo,
+  createVideoPlaylist,
+  addVideoInPlaylist
+} from '../../../../shared/extra-utils'
 import { cleanupTests, flushAndRunServer } from '../../../../shared/extra-utils/server/servers'
+import { VideoPlaylistPrivacy } from '@shared/models'
 
 const expect = chai.expect
 
 describe('Test services', function () {
   let server: ServerInfo = null
+  let playlistUUID: string
 
   before(async function () {
     this.timeout(30000)
@@ -16,17 +27,43 @@ describe('Test services', function () {
     server = await flushAndRunServer(1)
 
     await setAccessTokensToServers([ server ])
+    await setDefaultVideoChannel([ server ])
 
-    const videoAttributes = {
-      name: 'my super name'
+    {
+      const videoAttributes = {
+        name: 'my super name'
+      }
+      await uploadVideo(server.url, server.accessToken, videoAttributes)
+
+      const res = await getVideosList(server.url)
+      server.video = res.body.data[0]
     }
-    await uploadVideo(server.url, server.accessToken, videoAttributes)
 
-    const res = await getVideosList(server.url)
-    server.video = res.body.data[0]
+    {
+      const res = await createVideoPlaylist({
+        url: server.url,
+        token: server.accessToken,
+        playlistAttrs: {
+          displayName: 'The Life and Times of Scrooge McDuck',
+          privacy: VideoPlaylistPrivacy.PUBLIC,
+          videoChannelId: server.videoChannel.id
+        }
+      })
+
+      playlistUUID = res.body.videoPlaylist.uuid
+
+      await addVideoInPlaylist({
+        url: server.url,
+        token: server.accessToken,
+        playlistId: res.body.videoPlaylist.id,
+        elementAttrs: {
+          videoId: server.video.id
+        }
+      })
+    }
   })
 
-  it('Should have a valid oEmbed response', async function () {
+  it('Should have a valid oEmbed video response', async function () {
     const oembedUrl = 'http://localhost:' + server.port + '/videos/watch/' + server.video.uuid
 
     const res = await getOEmbed(server.url, oembedUrl)
@@ -37,7 +74,7 @@ describe('Test services', function () {
 
     expect(res.body.html).to.equal(expectedHtml)
     expect(res.body.title).to.equal(server.video.name)
-    expect(res.body.author_name).to.equal(server.video.account.name)
+    expect(res.body.author_name).to.equal(server.videoChannel.displayName)
     expect(res.body.width).to.equal(560)
     expect(res.body.height).to.equal(315)
     expect(res.body.thumbnail_url).to.equal(expectedThumbnailUrl)
@@ -45,6 +82,24 @@ describe('Test services', function () {
     expect(res.body.thumbnail_height).to.equal(480)
   })
 
+  it('Should have a valid playlist oEmbed response', async function () {
+    const oembedUrl = 'http://localhost:' + server.port + '/videos/watch/playlist/' + playlistUUID
+
+    const res = await getOEmbed(server.url, oembedUrl)
+    const expectedHtml = '<iframe width="560" height="315" sandbox="allow-same-origin allow-scripts" ' +
+      `src="http://localhost:${server.port}/video-playlists/embed/${playlistUUID}" ` +
+      'frameborder="0" allowfullscreen></iframe>'
+
+    expect(res.body.html).to.equal(expectedHtml)
+    expect(res.body.title).to.equal('The Life and Times of Scrooge McDuck')
+    expect(res.body.author_name).to.equal(server.videoChannel.displayName)
+    expect(res.body.width).to.equal(560)
+    expect(res.body.height).to.equal(315)
+    expect(res.body.thumbnail_url).exist
+    expect(res.body.thumbnail_width).to.equal(223)
+    expect(res.body.thumbnail_height).to.equal(122)
+  })
+
   it('Should have a valid oEmbed response with small max height query', async function () {
     const oembedUrl = 'http://localhost:' + server.port + '/videos/watch/' + server.video.uuid
     const format = 'json'
@@ -58,7 +113,7 @@ describe('Test services', function () {
 
     expect(res.body.html).to.equal(expectedHtml)
     expect(res.body.title).to.equal(server.video.name)
-    expect(res.body.author_name).to.equal(server.video.account.name)
+    expect(res.body.author_name).to.equal(server.videoChannel.displayName)
     expect(res.body.height).to.equal(50)
     expect(res.body.width).to.equal(50)
     expect(res.body).to.not.have.property('thumbnail_url')
index f55859b6f6d90d88dc55a670d1b39b32c699fdc7..96821eb6f75aecb33df9e9ffc8086995be21d023 100644 (file)
@@ -94,204 +94,230 @@ describe('Test a client controllers', function () {
     account = resAccountRequest.body
   })
 
-  it('Should have valid Open Graph tags on the watch page with video id', async function () {
-    const res = await request(server.url)
-      .get('/videos/watch/' + server.video.id)
-      .set('Accept', 'text/html')
-      .expect(200)
-
-    expect(res.text).to.contain(`<meta property="og:title" content="${videoName}" />`)
-    expect(res.text).to.contain(`<meta property="og:description" content="${videoDescription}" />`)
-    expect(res.text).to.contain('<meta property="og:type" content="video" />')
-    expect(res.text).to.contain(`<meta property="og:url" content="${server.url}/videos/watch/${server.video.uuid}" />`)
-  })
+  describe('oEmbed', function () {
+    it('Should have valid oEmbed discovery tags for videos', async function () {
+      const path = '/videos/watch/' + server.video.uuid
+      const res = await request(server.url)
+        .get(path)
+        .set('Accept', 'text/html')
+        .expect(200)
 
-  it('Should have valid Open Graph tags on the watch page with video uuid', async function () {
-    const res = await request(server.url)
-      .get('/videos/watch/' + server.video.uuid)
-      .set('Accept', 'text/html')
-      .expect(200)
+      const port = server.port
 
-    expect(res.text).to.contain(`<meta property="og:title" content="${videoName}" />`)
-    expect(res.text).to.contain(`<meta property="og:description" content="${videoDescription}" />`)
-    expect(res.text).to.contain('<meta property="og:type" content="video" />')
-    expect(res.text).to.contain(`<meta property="og:url" content="${server.url}/videos/watch/${server.video.uuid}" />`)
-  })
+      const expectedLink = '<link rel="alternate" type="application/json+oembed" href="http://localhost:' + port + '/services/oembed?' +
+        `url=http%3A%2F%2Flocalhost%3A${port}%2Fvideos%2Fwatch%2F${server.video.uuid}" ` +
+        `title="${server.video.name}" />`
 
-  it('Should have valid Open Graph tags on the watch playlist page', async function () {
-    const res = await request(server.url)
-      .get('/videos/watch/playlist/' + playlistUUID)
-      .set('Accept', 'text/html')
-      .expect(200)
+      expect(res.text).to.contain(expectedLink)
+    })
 
-    expect(res.text).to.contain(`<meta property="og:title" content="${playlistName}" />`)
-    expect(res.text).to.contain(`<meta property="og:description" content="${playlistDescription}" />`)
-    expect(res.text).to.contain('<meta property="og:type" content="video" />')
-    expect(res.text).to.contain(`<meta property="og:url" content="${server.url}/videos/watch/playlist/${playlistUUID}" />`)
-  })
+    it('Should have valid oEmbed discovery tags for a playlist', async function () {
+      const res = await request(server.url)
+        .get('/videos/watch/playlist/' + playlistUUID)
+        .set('Accept', 'text/html')
+        .expect(200)
+
+      const port = server.port
 
-  it('Should have valid Open Graph tags on the account page', async function () {
-    const res = await request(server.url)
-      .get('/accounts/' + server.user.username)
-      .set('Accept', 'text/html')
-      .expect(200)
+      const expectedLink = '<link rel="alternate" type="application/json+oembed" href="http://localhost:' + port + '/services/oembed?' +
+        `url=http%3A%2F%2Flocalhost%3A${port}%2Fvideos%2Fwatch%2Fplaylist%2F${playlistUUID}" ` +
+        `title="${playlistName}" />`
 
-    expect(res.text).to.contain(`<meta property="og:title" content="${account.displayName}" />`)
-    expect(res.text).to.contain(`<meta property="og:description" content="${account.description}" />`)
-    expect(res.text).to.contain('<meta property="og:type" content="website" />')
-    expect(res.text).to.contain(`<meta property="og:url" content="${server.url}/accounts/${server.user.username}" />`)
+      expect(res.text).to.contain(expectedLink)
+    })
   })
 
-  it('Should have valid Open Graph tags on the channel page', async function () {
-    const res = await request(server.url)
-      .get('/video-channels/' + server.videoChannel.name)
-      .set('Accept', 'text/html')
-      .expect(200)
+  describe('Open Graph', function () {
 
-    expect(res.text).to.contain(`<meta property="og:title" content="${server.videoChannel.displayName}" />`)
-    expect(res.text).to.contain(`<meta property="og:description" content="${channelDescription}" />`)
-    expect(res.text).to.contain('<meta property="og:type" content="website" />')
-    expect(res.text).to.contain(`<meta property="og:url" content="${server.url}/video-channels/${server.videoChannel.name}" />`)
-  })
+    it('Should have valid Open Graph tags on the account page', async function () {
+      const res = await request(server.url)
+        .get('/accounts/' + server.user.username)
+        .set('Accept', 'text/html')
+        .expect(200)
 
-  it('Should have valid oEmbed discovery tags', async function () {
-    const path = '/videos/watch/' + server.video.uuid
-    const res = await request(server.url)
-      .get(path)
-      .set('Accept', 'text/html')
-      .expect(200)
+      expect(res.text).to.contain(`<meta property="og:title" content="${account.displayName}" />`)
+      expect(res.text).to.contain(`<meta property="og:description" content="${account.description}" />`)
+      expect(res.text).to.contain('<meta property="og:type" content="website" />')
+      expect(res.text).to.contain(`<meta property="og:url" content="${server.url}/accounts/${server.user.username}" />`)
+    })
 
-    const port = server.port
+    it('Should have valid Open Graph tags on the channel page', async function () {
+      const res = await request(server.url)
+        .get('/video-channels/' + server.videoChannel.name)
+        .set('Accept', 'text/html')
+        .expect(200)
 
-    const expectedLink = '<link rel="alternate" type="application/json+oembed" href="http://localhost:' + port + '/services/oembed?' +
-      `url=http%3A%2F%2Flocalhost%3A${port}%2Fvideos%2Fwatch%2F${server.video.uuid}" ` +
-      `title="${server.video.name}" />`
+      expect(res.text).to.contain(`<meta property="og:title" content="${server.videoChannel.displayName}" />`)
+      expect(res.text).to.contain(`<meta property="og:description" content="${channelDescription}" />`)
+      expect(res.text).to.contain('<meta property="og:type" content="website" />')
+      expect(res.text).to.contain(`<meta property="og:url" content="${server.url}/video-channels/${server.videoChannel.name}" />`)
+    })
 
-    expect(res.text).to.contain(expectedLink)
-  })
+    it('Should have valid Open Graph tags on the watch page with video id', async function () {
+      const res = await request(server.url)
+        .get('/videos/watch/' + server.video.id)
+        .set('Accept', 'text/html')
+        .expect(200)
 
-  it('Should have valid twitter card on the watch video page', async function () {
-    const res = await request(server.url)
-      .get('/videos/watch/' + server.video.uuid)
-      .set('Accept', 'text/html')
-      .expect(200)
+      expect(res.text).to.contain(`<meta property="og:title" content="${videoName}" />`)
+      expect(res.text).to.contain(`<meta property="og:description" content="${videoDescription}" />`)
+      expect(res.text).to.contain('<meta property="og:type" content="video" />')
+      expect(res.text).to.contain(`<meta property="og:url" content="${server.url}/videos/watch/${server.video.uuid}" />`)
+    })
 
-    expect(res.text).to.contain('<meta property="twitter:card" content="summary_large_image" />')
-    expect(res.text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
-    expect(res.text).to.contain(`<meta property="twitter:title" content="${videoName}" />`)
-    expect(res.text).to.contain(`<meta property="twitter:description" content="${videoDescription}" />`)
-  })
+    it('Should have valid Open Graph tags on the watch page with video uuid', async function () {
+      const res = await request(server.url)
+        .get('/videos/watch/' + server.video.uuid)
+        .set('Accept', 'text/html')
+        .expect(200)
 
-  it('Should have valid twitter card on the watch playlist page', async function () {
-    const res = await request(server.url)
-      .get('/videos/watch/playlist/' + playlistUUID)
-      .set('Accept', 'text/html')
-      .expect(200)
+      expect(res.text).to.contain(`<meta property="og:title" content="${videoName}" />`)
+      expect(res.text).to.contain(`<meta property="og:description" content="${videoDescription}" />`)
+      expect(res.text).to.contain('<meta property="og:type" content="video" />')
+      expect(res.text).to.contain(`<meta property="og:url" content="${server.url}/videos/watch/${server.video.uuid}" />`)
+    })
 
-    expect(res.text).to.contain('<meta property="twitter:card" content="summary" />')
-    expect(res.text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
-    expect(res.text).to.contain(`<meta property="twitter:title" content="${playlistName}" />`)
-    expect(res.text).to.contain(`<meta property="twitter:description" content="${playlistDescription}" />`)
+    it('Should have valid Open Graph tags on the watch playlist page', async function () {
+      const res = await request(server.url)
+        .get('/videos/watch/playlist/' + playlistUUID)
+        .set('Accept', 'text/html')
+        .expect(200)
+
+      expect(res.text).to.contain(`<meta property="og:title" content="${playlistName}" />`)
+      expect(res.text).to.contain(`<meta property="og:description" content="${playlistDescription}" />`)
+      expect(res.text).to.contain('<meta property="og:type" content="video" />')
+      expect(res.text).to.contain(`<meta property="og:url" content="${server.url}/videos/watch/playlist/${playlistUUID}" />`)
+    })
   })
 
-  it('Should have valid twitter card on the account page', async function () {
-    const res = await request(server.url)
-      .get('/accounts/' + account.name)
-      .set('Accept', 'text/html')
-      .expect(200)
+  describe('Twitter card', async function () {
 
-    expect(res.text).to.contain('<meta property="twitter:card" content="summary" />')
-    expect(res.text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
-    expect(res.text).to.contain(`<meta property="twitter:title" content="${account.name}" />`)
-    expect(res.text).to.contain(`<meta property="twitter:description" content="${account.description}" />`)
-  })
+    it('Should have valid twitter card on the watch video page', async function () {
+      const res = await request(server.url)
+        .get('/videos/watch/' + server.video.uuid)
+        .set('Accept', 'text/html')
+        .expect(200)
 
-  it('Should have valid twitter card on the channel page', async function () {
-    const res = await request(server.url)
-      .get('/video-channels/' + server.videoChannel.name)
-      .set('Accept', 'text/html')
-      .expect(200)
+      expect(res.text).to.contain('<meta property="twitter:card" content="summary_large_image" />')
+      expect(res.text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
+      expect(res.text).to.contain(`<meta property="twitter:title" content="${videoName}" />`)
+      expect(res.text).to.contain(`<meta property="twitter:description" content="${videoDescription}" />`)
+    })
 
-    expect(res.text).to.contain('<meta property="twitter:card" content="summary" />')
-    expect(res.text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
-    expect(res.text).to.contain(`<meta property="twitter:title" content="${server.videoChannel.displayName}" />`)
-    expect(res.text).to.contain(`<meta property="twitter:description" content="${channelDescription}" />`)
-  })
+    it('Should have valid twitter card on the watch playlist page', async function () {
+      const res = await request(server.url)
+        .get('/videos/watch/playlist/' + playlistUUID)
+        .set('Accept', 'text/html')
+        .expect(200)
 
-  it('Should have valid twitter card if Twitter is whitelisted', async function () {
-    const res1 = await getCustomConfig(server.url, server.accessToken)
-    const config = res1.body
-    config.services.twitter = {
-      username: '@Kuja',
-      whitelisted: true
-    }
-    await updateCustomConfig(server.url, server.accessToken, config)
+      expect(res.text).to.contain('<meta property="twitter:card" content="summary" />')
+      expect(res.text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
+      expect(res.text).to.contain(`<meta property="twitter:title" content="${playlistName}" />`)
+      expect(res.text).to.contain(`<meta property="twitter:description" content="${playlistDescription}" />`)
+    })
 
-    const resVideoRequest = await request(server.url)
-      .get('/videos/watch/' + server.video.uuid)
-      .set('Accept', 'text/html')
-      .expect(200)
+    it('Should have valid twitter card on the account page', async function () {
+      const res = await request(server.url)
+        .get('/accounts/' + account.name)
+        .set('Accept', 'text/html')
+        .expect(200)
 
-    expect(resVideoRequest.text).to.contain('<meta property="twitter:card" content="player" />')
-    expect(resVideoRequest.text).to.contain('<meta property="twitter:site" content="@Kuja" />')
+      expect(res.text).to.contain('<meta property="twitter:card" content="summary" />')
+      expect(res.text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
+      expect(res.text).to.contain(`<meta property="twitter:title" content="${account.name}" />`)
+      expect(res.text).to.contain(`<meta property="twitter:description" content="${account.description}" />`)
+    })
 
-    const resVideoPlaylistRequest = await request(server.url)
-      .get('/videos/watch/playlist/' + playlistUUID)
-      .set('Accept', 'text/html')
-      .expect(200)
+    it('Should have valid twitter card on the channel page', async function () {
+      const res = await request(server.url)
+        .get('/video-channels/' + server.videoChannel.name)
+        .set('Accept', 'text/html')
+        .expect(200)
 
-    expect(resVideoPlaylistRequest.text).to.contain('<meta property="twitter:card" content="summary" />')
-    expect(resVideoPlaylistRequest.text).to.contain('<meta property="twitter:site" content="@Kuja" />')
+      expect(res.text).to.contain('<meta property="twitter:card" content="summary" />')
+      expect(res.text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
+      expect(res.text).to.contain(`<meta property="twitter:title" content="${server.videoChannel.displayName}" />`)
+      expect(res.text).to.contain(`<meta property="twitter:description" content="${channelDescription}" />`)
+    })
 
-    const resAccountRequest = await request(server.url)
-      .get('/accounts/' + account.name)
-      .set('Accept', 'text/html')
-      .expect(200)
+    it('Should have valid twitter card if Twitter is whitelisted', async function () {
+      const res1 = await getCustomConfig(server.url, server.accessToken)
+      const config = res1.body
+      config.services.twitter = {
+        username: '@Kuja',
+        whitelisted: true
+      }
+      await updateCustomConfig(server.url, server.accessToken, config)
 
-    expect(resAccountRequest.text).to.contain('<meta property="twitter:card" content="summary" />')
-    expect(resAccountRequest.text).to.contain('<meta property="twitter:site" content="@Kuja" />')
+      const resVideoRequest = await request(server.url)
+        .get('/videos/watch/' + server.video.uuid)
+        .set('Accept', 'text/html')
+        .expect(200)
 
-    const resChannelRequest = await request(server.url)
-      .get('/video-channels/' + server.videoChannel.name)
-      .set('Accept', 'text/html')
-      .expect(200)
+      expect(resVideoRequest.text).to.contain('<meta property="twitter:card" content="player" />')
+      expect(resVideoRequest.text).to.contain('<meta property="twitter:site" content="@Kuja" />')
 
-    expect(resChannelRequest.text).to.contain('<meta property="twitter:card" content="summary" />')
-    expect(resChannelRequest.text).to.contain('<meta property="twitter:site" content="@Kuja" />')
-  })
+      const resVideoPlaylistRequest = await request(server.url)
+        .get('/videos/watch/playlist/' + playlistUUID)
+        .set('Accept', 'text/html')
+        .expect(200)
 
-  it('Should have valid index html tags (title, description...)', async function () {
-    const res = await makeHTMLRequest(server.url, '/videos/trending')
+      expect(resVideoPlaylistRequest.text).to.contain('<meta property="twitter:card" content="player" />')
+      expect(resVideoPlaylistRequest.text).to.contain('<meta property="twitter:site" content="@Kuja" />')
 
-    const description = 'PeerTube, an ActivityPub-federated video streaming platform using P2P directly in your web browser.'
-    checkIndexTags(res.text, 'PeerTube', description, '')
+      const resAccountRequest = await request(server.url)
+        .get('/accounts/' + account.name)
+        .set('Accept', 'text/html')
+        .expect(200)
+
+      expect(resAccountRequest.text).to.contain('<meta property="twitter:card" content="summary" />')
+      expect(resAccountRequest.text).to.contain('<meta property="twitter:site" content="@Kuja" />')
+
+      const resChannelRequest = await request(server.url)
+        .get('/video-channels/' + server.videoChannel.name)
+        .set('Accept', 'text/html')
+        .expect(200)
+
+      expect(resChannelRequest.text).to.contain('<meta property="twitter:card" content="summary" />')
+      expect(resChannelRequest.text).to.contain('<meta property="twitter:site" content="@Kuja" />')
+    })
   })
 
-  it('Should update the customized configuration and have the correct index html tags', async function () {
-    await updateCustomSubConfig(server.url, server.accessToken, {
-      instance: {
-        name: 'PeerTube updated',
-        shortDescription: 'my short description',
-        description: 'my super description',
-        terms: 'my super terms',
-        defaultClientRoute: '/videos/recently-added',
-        defaultNSFWPolicy: 'blur',
-        customizations: {
-          javascript: 'alert("coucou")',
-          css: 'body { background-color: red; }'
-        }
-      }
+  describe('Index HTML', function () {
+
+    it('Should have valid index html tags (title, description...)', async function () {
+      const res = await makeHTMLRequest(server.url, '/videos/trending')
+
+      const description = 'PeerTube, an ActivityPub-federated video streaming platform using P2P directly in your web browser.'
+      checkIndexTags(res.text, 'PeerTube', description, '')
     })
 
-    const res = await makeHTMLRequest(server.url, '/videos/trending')
+    it('Should update the customized configuration and have the correct index html tags', async function () {
+      await updateCustomSubConfig(server.url, server.accessToken, {
+        instance: {
+          name: 'PeerTube updated',
+          shortDescription: 'my short description',
+          description: 'my super description',
+          terms: 'my super terms',
+          defaultClientRoute: '/videos/recently-added',
+          defaultNSFWPolicy: 'blur',
+          customizations: {
+            javascript: 'alert("coucou")',
+            css: 'body { background-color: red; }'
+          }
+        }
+      })
 
-    checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }')
-  })
+      const res = await makeHTMLRequest(server.url, '/videos/trending')
+
+      checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }')
+    })
 
-  it('Should have valid index html updated tags (title, description...)', async function () {
-    const res = await makeHTMLRequest(server.url, '/videos/trending')
+    it('Should have valid index html updated tags (title, description...)', async function () {
+      const res = await makeHTMLRequest(server.url, '/videos/trending')
 
-    checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }')
+      checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }')
+    })
   })
 
   after(async function () {